From 460ec21253791c0299c2fb4cd57877f4cce54257 Mon Sep 17 00:00:00 2001 From: SimpleTest Date: Sun, 19 Jul 2026 17:38:58 +0300 Subject: [PATCH] test: expand Android lifecycle and API matrix coverage --- .env.example | 2 + README.md | 18 ++- android/Dockerfile | 15 ++- android/README.md | 23 ++-- .../mobile/AndroidClientInstrumentedTest.java | 87 +++++++++++- .../whoneedhelp/mobile/FixtureHttpServer.java | 60 ++++++++- .../mobile/TrackingProcessDeathProbeTest.java | 57 ++++++++ docs/local-hardening-plan.md | 13 +- scripts/android-instrumentation-test.sh | 127 +++++++++++++++++- scripts/android-matrix-test.sh | 20 +++ 10 files changed, 386 insertions(+), 36 deletions(-) create mode 100644 android/app/src/androidTest/java/org/whoneedhelp/mobile/TrackingProcessDeathProbeTest.java create mode 100755 scripts/android-matrix-test.sh diff --git a/.env.example b/.env.example index 46f0af3..0f85e5d 100644 --- a/.env.example +++ b/.env.example @@ -27,6 +27,8 @@ WNH_BASE_URL= # production capacity recommendations. WNH_TRACKING_MIN_TIME_MS=5000 WNH_TRACKING_HTTP_TIMEOUT_MS=15000 +WNH_ANDROID_TEST_API_MATRIX=30 34 37.0 +WNH_ANDROID_TEST_DATA_PARTITION_SIZE=4G # Public raster tile template used by MapLibre. Use a provider whose policy and # capacity match the deployment before a public launch. MAP_TILE_URL=https://tile.openstreetmap.org/{z}/{x}/{y}.png diff --git a/README.md b/README.md index a61407d..f4e957e 100644 --- a/README.md +++ b/README.md @@ -247,21 +247,25 @@ It retains traces, screenshots, video, and Compose logs under the ignored networks are removed automatically; the normal `who_need_help` Compose project is not recreated. -The Android device suite builds a dedicated debug and instrumentation APK, -boots an API 37 emulator in an isolated container without external networking, -and serves its HTTP fixture only on device loopback: +The Android device suite builds dedicated debug and instrumentation APKs, +boots a fresh emulator in an isolated container without external networking, +and serves its HTTP fixture only on device loopback. Run the default API 37 +probe or the complete API 30/34/37 matrix: ```bash ./scripts/android-instrumentation-test.sh +./scripts/android-matrix-test.sh ``` On first run it generates the ignored `.env.android-test` with a randomized device-loopback origin and mode `0600`. The suite covers denied and granted location permission, same-origin deep links, Activity recreation, foreground -location upload, the persistent notification Stop action, and a disconnected -Stop request with visible retry state. Results and failure diagnostics are -retained under ignored `output/android-instrumentation/`; the exact emulator -container and one-run image are removed automatically. +location upload while the Activity is backgrounded and destroyed, the +persistent notification Stop action, a disconnected Stop request with visible +retry state, and externally forced process death for the non-sticky service. +Results and failure diagnostics are retained by API under ignored +`output/android-instrumentation/`; the exact emulator container and one-run +image are removed automatically. ## First administrator diff --git a/android/Dockerfile b/android/Dockerfile index ce27c20..fecb9bd 100644 --- a/android/Dockerfile +++ b/android/Dockerfile @@ -57,11 +57,15 @@ FROM android-base AS emulator USER root SHELL ["/bin/bash", "-o", "pipefail", "-c"] +ARG ANDROID_EMULATOR_SYSTEM_IMAGE=system-images/android-37.0/google_apis_ps16k/x86_64 + RUN android --no-metrics --sdk="${ANDROID_HOME}" sdk install \ "emulator" \ - "system-images/android-37.0/google_apis_ps16k/x86_64" \ + "${ANDROID_EMULATOR_SYSTEM_IMAGE}" \ && chown -R gradle:gradle "${ANDROID_HOME}" +ARG ANDROID_EMULATOR_DATA_PARTITION_SIZE=4G + RUN apt-get update \ && apt-get install -y --no-install-recommends \ libdbus-1-3=1.16.2-2ubuntu4 \ @@ -89,8 +93,11 @@ USER gradle RUN echo no | avdmanager create avd \ --force \ - --name who_need_help_api37 \ - --package "system-images;android-37.0;google_apis_ps16k;x86_64" + --name who_need_help_test \ + --package "$(printf '%s' "${ANDROID_EMULATOR_SYSTEM_IMAGE}" | tr '/' ';')" \ + && sed -i \ + "s/^disk.dataPartition.size=.*/disk.dataPartition.size=${ANDROID_EMULATOR_DATA_PARTITION_SIZE}/" \ + /home/gradle/.android/avd/who_need_help_test.avd/config.ini COPY --from=android-sdk --chown=gradle:gradle \ /workspace/android/app/build/outputs/apk/debug/app-debug.apk \ @@ -100,7 +107,7 @@ COPY --from=android-sdk --chown=gradle:gradle \ /opt/who-need-help/who-need-help-debug-androidTest.apk CMD ["/opt/android-sdk/emulator/emulator", \ - "-avd", "who_need_help_api37", \ + "-avd", "who_need_help_test", \ "-no-window", \ "-no-audio", \ "-no-boot-anim", \ diff --git a/android/README.md b/android/README.md index 76cf5dc..9cccb8d 100644 --- a/android/README.md +++ b/android/README.md @@ -73,28 +73,33 @@ The Docker build runs JVM unit tests, Android lint, `assembleDebug`, and `assembleDebugAndroidTest` before it exports the application APK and lint report. -## Automated API 37 device tests +## Automated device tests -Run the complete device suite from the repository root: +Run the default API 37 suite or the complete API 30/34/37 matrix from the +repository root: ```sh ./scripts/android-instrumentation-test.sh +./scripts/android-matrix-test.sh ``` The command requires `/dev/kvm`. It generates an ignored `.env.android-test` once with a randomized `http://127.0.0.1:PORT` origin and mode `0600`; the application and its in-process fixture server both derive the -origin from that file. It then builds both APKs, boots a fresh API 37 emulator +origin from that file. It then builds both APKs, boots a fresh selected emulator container without external networking, injects emulator coordinates, and runs -`AndroidJUnitRunner`. +`AndroidJUnitRunner`. `WNH_ANDROID_TEST_API` selects one supported API, while +`WNH_ANDROID_TEST_API_MATRIX` controls the matrix command. The tests cover the missing-location-permission boundary, the WebView-triggered Android permission dialog, same-origin deep-link routing across Activity -recreation, foreground location upload, the persistent notification Stop -action, and a disconnected Stop request with visible retry state. Results are -stored under ignored `output/android-instrumentation/`. On failure, logcat, -service state, and emulator logs are retained; the exact container and -one-run image are removed in either outcome. +recreation, foreground location upload after Home and Activity destruction, the +persistent notification Stop action, a disconnected Stop request with visible +retry state, and externally forced process death for the non-sticky tracking +service. Results are stored by API under ignored +`output/android-instrumentation/`. On failure, logcat, service state, and +emulator logs are retained; the exact container and one-run image are removed +in either outcome. ## Emulator verification diff --git a/android/app/src/androidTest/java/org/whoneedhelp/mobile/AndroidClientInstrumentedTest.java b/android/app/src/androidTest/java/org/whoneedhelp/mobile/AndroidClientInstrumentedTest.java index 919b84c..030ea8b 100644 --- a/android/app/src/androidTest/java/org/whoneedhelp/mobile/AndroidClientInstrumentedTest.java +++ b/android/app/src/androidTest/java/org/whoneedhelp/mobile/AndroidClientInstrumentedTest.java @@ -89,7 +89,7 @@ public final class AndroidClientInstrumentedTest { } @Test - public void test02WebGeolocationRequestsAndGrantsRuntimePermission() { + public void test02WebGeolocationRequestsAndGrantsRuntimePermission() throws Exception { assertFalse(hasLocationPermission()); try ( @@ -119,7 +119,18 @@ public final class AndroidClientInstrumentedTest { assertNotNull("Android location permission prompt was not shown", allow); allow.click(); - assertTrue(hasLocationPermission()); + waitForLocationPermission(); + assertNotNull( + "WebView geolocation did not report a granted position", + server.awaitRequestEndingWith( + "/geolocation-granted", + 15, + TimeUnit.SECONDS + ) + ); + onWebView() + .withElement(findElement(Locator.ID, "marker")) + .check(webMatches(getText(), containsString("location-granted"))); } } @@ -213,6 +224,64 @@ public final class AndroidClientInstrumentedTest { } } + @Test + public void test06TrackingSurvivesHomeAndActivityDestruction() throws Exception { + grantTrackingPermissions(); + String assignmentId = UUID.randomUUID().toString(); + String positionPath = "/mobile/tracking/" + assignmentId + "/position"; + ActivityScenario scenario = launch("/tracking-lifecycle"); + + try { + startTracking(scenario, assignmentId); + waitForServiceState(true); + assertNotNull( + "Foreground service did not upload its first location", + server.awaitRequestEndingWith(positionPath, 15, TimeUnit.SECONDS) + ); + + int foregroundCount = server.requestCountEndingWith(positionPath); + device.pressHome(); + + assertNotNull( + "Tracking stopped after the Activity left the foreground", + server.awaitRequestAfterCount( + positionPath, + foregroundCount, + 15, + TimeUnit.SECONDS + ) + ); + assertTrue(serviceIsRunning()); + + int stoppedActivityCount = server.requestCountEndingWith(positionPath); + scenario.close(); + + assertNotNull( + "Tracking stopped after the Activity was destroyed", + server.awaitRequestAfterCount( + positionPath, + stoppedActivityCount, + 15, + TimeUnit.SECONDS + ) + ); + assertTrue(serviceIsRunning()); + + openNotificationAndClickStop(); + assertNotNull( + "Notification Stop did not work after Activity destruction", + server.awaitRequestEndingWith( + "/mobile/tracking/" + assignmentId + "/stop", + 10, + TimeUnit.SECONDS + ) + ); + waitForServiceState(false); + } finally { + scenario.close(); + } + } + private ActivityScenario launch(String path) { Intent intent = new Intent( Intent.ACTION_VIEW, @@ -262,6 +331,20 @@ public final class AndroidClientInstrumentedTest { == android.content.pm.PackageManager.PERMISSION_GRANTED; } + private void waitForLocationPermission() throws InterruptedException { + long deadline = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(UI_TIMEOUT_MS); + + while (System.nanoTime() < deadline) { + if (hasLocationPermission()) { + return; + } + + Thread.sleep(50); + } + + assertTrue("Android location permission was not granted", hasLocationPermission()); + } + private void grantTrackingPermissions() throws Exception { String packageName = context.getPackageName(); device.executeShellCommand( diff --git a/android/app/src/androidTest/java/org/whoneedhelp/mobile/FixtureHttpServer.java b/android/app/src/androidTest/java/org/whoneedhelp/mobile/FixtureHttpServer.java index 7f8f531..e66f50e 100644 --- a/android/app/src/androidTest/java/org/whoneedhelp/mobile/FixtureHttpServer.java +++ b/android/app/src/androidTest/java/org/whoneedhelp/mobile/FixtureHttpServer.java @@ -66,13 +66,42 @@ final class FixtureHttpServer implements Closeable { RecordedRequest awaitRequestEndingWith(String suffix, long timeout, TimeUnit unit) throws InterruptedException { + return awaitRequestAfterCount(suffix, 0, timeout, unit); + } + + int requestCountEndingWith(String suffix) { + int count = 0; + + synchronized (requests) { + for (RecordedRequest request : requests) { + if (request.path.endsWith(suffix)) { + count++; + } + } + } + + return count; + } + + RecordedRequest awaitRequestAfterCount( + String suffix, + int previousCount, + long timeout, + TimeUnit unit + ) throws InterruptedException { long deadline = System.nanoTime() + unit.toNanos(timeout); while (System.nanoTime() < deadline) { + int count = 0; + synchronized (requests) { for (RecordedRequest request : requests) { if (request.path.endsWith(suffix)) { - return request; + count++; + + if (count > previousCount) { + return request; + } } } } @@ -138,7 +167,7 @@ final class FixtureHttpServer implements Closeable { } } - byte[] bodyBytes = input.readNBytes(contentLength); + byte[] bodyBytes = readExactly(input, contentLength); RecordedRequest request = new RecordedRequest( parts[0], parts[1], @@ -182,8 +211,11 @@ final class FixtureHttpServer implements Closeable { + "Who Need Help Android fixture" + "

loaded:" + escapedPath + "

" + "" + ""; } @@ -206,7 +238,25 @@ final class FixtureHttpServer implements Closeable { return null; } - return line.toString(StandardCharsets.US_ASCII); + return new String(line.toByteArray(), StandardCharsets.US_ASCII); + } + + private static byte[] readExactly(BufferedInputStream input, int length) + throws IOException { + byte[] result = new byte[length]; + int offset = 0; + + while (offset < length) { + int count = input.read(result, offset, length - offset); + + if (count == -1) { + throw new IOException("Unexpected end of HTTP request body"); + } + + offset += count; + } + + return result; } private static void writeResponse( diff --git a/android/app/src/androidTest/java/org/whoneedhelp/mobile/TrackingProcessDeathProbeTest.java b/android/app/src/androidTest/java/org/whoneedhelp/mobile/TrackingProcessDeathProbeTest.java new file mode 100644 index 0000000..69303f5 --- /dev/null +++ b/android/app/src/androidTest/java/org/whoneedhelp/mobile/TrackingProcessDeathProbeTest.java @@ -0,0 +1,57 @@ +package org.whoneedhelp.mobile; + +import static org.junit.Assert.fail; + +import android.Manifest; +import android.app.NotificationManager; +import android.content.Context; +import android.content.Intent; +import android.os.Build; + +import androidx.test.core.app.ApplicationProvider; +import androidx.test.ext.junit.runners.AndroidJUnit4; +import androidx.test.platform.app.InstrumentationRegistry; +import androidx.test.uiautomator.UiDevice; + +import org.junit.Test; +import org.junit.runner.RunWith; + +import java.util.UUID; +import java.util.concurrent.TimeUnit; + +@RunWith(AndroidJUnit4.class) +public final class TrackingProcessDeathProbeTest { + @Test + public void startsNonStickyTrackingForExternalProcessKill() throws Exception { + Context context = ApplicationProvider.getApplicationContext(); + UiDevice device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()); + String packageName = context.getPackageName(); + + context.stopService(new Intent(context, TrackingService.class)); + context.getSystemService(NotificationManager.class).cancelAll(); + device.executeShellCommand( + "pm grant " + packageName + " " + Manifest.permission.ACCESS_FINE_LOCATION + ); + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + device.executeShellCommand( + "pm grant " + packageName + " " + Manifest.permission.POST_NOTIFICATIONS + ); + } + + context.startForegroundService( + TrackingService.startIntent( + context, + UUID.randomUUID().toString(), + "process-death-csrf", + "_session=process-death" + ) + ); + + // The host harness kills this exact process after observing the active + // service and notification. Reaching the deadline means the harness did + // not perform the required process-death probe. + TimeUnit.SECONDS.sleep(30); + fail("The external process-death harness did not kill the tracking process"); + } +} diff --git a/docs/local-hardening-plan.md b/docs/local-hardening-plan.md index d8237dd..367388b 100644 --- a/docs/local-hardening-plan.md +++ b/docs/local-hardening-plan.md @@ -46,11 +46,14 @@ The goal remains open while any row lacks reproducible local evidence. - A forced LiveSocket transport loss while Chromium is offline exposes the reconnect alert, and restoring the network clears it and reconnects the authenticated LiveView. -- The isolated API 37 Android suite passes five device tests: missing and - granted location permission, same-origin deep-link routing across Activity - recreation, native foreground location upload, notification Stop with remote - cleanup, and a disconnected Stop request with visible retry state. It uses an - in-process loopback fixture, runs its emulator without external networking, +- The isolated API 30/34/37 Android matrix passes six device tests per API: + missing and granted location permission, same-origin deep-link routing across + Activity recreation, native foreground location upload, notification Stop + with remote cleanup, a disconnected Stop request with visible retry state, + and continued uploads after Home and Activity destruction. A separate probe + externally kills the instrumentation process and verifies that the + `START_NOT_STICKY` service and notification do not return. The suite uses an + in-process loopback fixture, runs each emulator without external networking, retains diagnostics on failure, and removes its exact container and image. - The containerized `scripts/quality.sh` gate passes ShellCheck, Hadolint, actionlint, all Compose renders, Helm lint, a Trivy scan of tracked source and diff --git a/scripts/android-instrumentation-test.sh b/scripts/android-instrumentation-test.sh index 82af921..f0d4f0e 100755 --- a/scripts/android-instrumentation-test.sh +++ b/scripts/android-instrumentation-test.sh @@ -5,10 +5,37 @@ ROOT=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd) ANDROID_ENV="$ROOT/.env" TEST_ENV="$ROOT/.env.android-test" run_id=$(date -u +%Y%m%d%H%M%S)-$$ -image="who-need-help-android:instrumentation-$run_id" -container="who-need-help-android-instrumentation-$run_id" -output="$ROOT/output/android-instrumentation/$run_id" +android_api=${WNH_ANDROID_TEST_API:-37.0} +android_data_partition_size=${WNH_ANDROID_TEST_DATA_PARTITION_SIZE:-4G} + +if ! printf '%s\n' "$android_data_partition_size" | + grep -Eq '^[1-9][0-9]*[GKM]$'; then + echo "WNH_ANDROID_TEST_DATA_PARTITION_SIZE must be a positive integer followed by G, M, or K." >&2 + exit 1 +fi + +case "$android_api" in + 30) + android_system_image="system-images/android-30/google_apis/x86_64" + ;; + 34) + android_system_image="system-images/android-34/google_apis/x86_64" + ;; + 37.0) + android_system_image="system-images/android-37.0/google_apis_ps16k/x86_64" + ;; + *) + echo "WNH_ANDROID_TEST_API must be one of: 30, 34, 37.0." >&2 + exit 1 + ;; +esac + +api_label=$(printf '%s' "$android_api" | tr '.' '-') +image="who-need-help-android:instrumentation-api${api_label}-$run_id" +container="who-need-help-android-instrumentation-api${api_label}-$run_id" +output="$ROOT/output/android-instrumentation/api${api_label}/$run_id" geo_pid="" +death_runner_pid="" if [ ! -e /dev/kvm ]; then echo "/dev/kvm is required for the containerized Android emulator." >&2 @@ -62,6 +89,11 @@ cleanup() { wait "$geo_pid" >/dev/null 2>&1 || true fi + if [ -n "$death_runner_pid" ]; then + kill "$death_runner_pid" >/dev/null 2>&1 || true + wait "$death_runner_pid" >/dev/null 2>&1 || true + fi + if [ "$status" -ne 0 ] && docker inspect "$container" >/dev/null 2>&1; then docker exec "$container" adb logcat -d > "$output/logcat.txt" 2>&1 || true docker exec "$container" adb shell dumpsys activity services \ @@ -80,6 +112,8 @@ docker build \ --build-arg "WNH_DEBUG_BASE_URL=$WNH_ANDROID_TEST_BASE_URL" \ --build-arg "WNH_TRACKING_MIN_TIME_MS=$WNH_TRACKING_MIN_TIME_MS" \ --build-arg "WNH_TRACKING_HTTP_TIMEOUT_MS=$WNH_TRACKING_HTTP_TIMEOUT_MS" \ + --build-arg "ANDROID_EMULATOR_SYSTEM_IMAGE=$android_system_image" \ + --build-arg "ANDROID_EMULATOR_DATA_PARTITION_SIZE=$android_data_partition_size" \ --target emulator \ -t "$image" \ "$ROOT/android" @@ -136,6 +170,7 @@ geo_pid=$! set +e docker exec "$container" adb shell am instrument -w -r \ + -e class org.whoneedhelp.mobile.AndroidClientInstrumentedTest \ org.whoneedhelp.mobile.debug.test/androidx.test.runner.AndroidJUnitRunner \ > "$output/results.txt" 2>&1 instrumentation_status=$? @@ -149,4 +184,88 @@ if [ "$instrumentation_status" -ne 0 ] \ exit 1 fi -echo "Android instrumentation passed. Evidence: $output" +docker exec "$container" adb shell am force-stop org.whoneedhelp.mobile.debug +docker exec "$container" adb root >"$output/adb-root.txt" +docker exec "$container" adb wait-for-device +docker exec "$container" adb shell am instrument -w -r \ + -e class org.whoneedhelp.mobile.TrackingProcessDeathProbeTest \ + org.whoneedhelp.mobile.debug.test/androidx.test.runner.AndroidJUnitRunner \ + >"$output/process-death-results.txt" 2>&1 & +death_runner_pid=$! + +service_observed="" +attempt=0 +while [ "$attempt" -lt 20 ]; do + if docker exec "$container" adb shell dumpsys activity services \ + org.whoneedhelp.mobile.debug | + grep -q 'org.whoneedhelp.mobile.TrackingService'; then + service_observed=1 + break + fi + + attempt=$((attempt + 1)) + sleep 1 +done + +if [ -z "$service_observed" ]; then + echo "Process-death probe never observed the foreground service." >&2 + exit 1 +fi + +app_pid=$(docker exec "$container" adb shell pidof org.whoneedhelp.mobile.debug | tr -d '\r') + +case "$app_pid" in + ""|*[!0-9]*) + echo "Process-death probe could not identify one target process." >&2 + exit 1 + ;; +esac + +docker exec "$container" adb logcat -c +docker exec "$container" adb shell kill -9 "$app_pid" + +set +e +wait "$death_runner_pid" +death_status=$? +set -e +death_runner_pid="" +cat "$output/process-death-results.txt" +docker exec "$container" adb logcat -d >"$output/process-death-logcat.txt" + +if ! grep -Eq \ + 'Process org\.whoneedhelp\.mobile\.debug .* has died:.*FGS|Crash of app org\.whoneedhelp\.mobile\.debug running instrumentation|Process crashed' \ + "$output/process-death-logcat.txt"; then + echo "The expected instrumentation process death was not observed." >&2 + exit 1 +fi + +printf 'Expected killed instrumentation exit status: %s\n' "$death_status" \ + >"$output/process-death-status.txt" + +attempt=0 +while [ "$attempt" -lt 10 ]; do + if ! docker exec "$container" adb shell dumpsys activity services \ + org.whoneedhelp.mobile.debug | + grep -q 'org.whoneedhelp.mobile.TrackingService'; then + break + fi + + attempt=$((attempt + 1)) + sleep 1 +done + +if docker exec "$container" adb shell dumpsys activity services \ + org.whoneedhelp.mobile.debug | + grep -q 'org.whoneedhelp.mobile.TrackingService'; then + echo "START_NOT_STICKY tracking restarted after process death." >&2 + exit 1 +fi + +if docker exec "$container" adb shell dumpsys notification --noredact | + grep -q 'Sharing live location'; then + echo "The tracking notification remained after process death." >&2 + exit 1 +fi + +echo "Android API $android_api instrumentation and process-death probes passed." +echo "Evidence: $output" diff --git a/scripts/android-matrix-test.sh b/scripts/android-matrix-test.sh new file mode 100755 index 0000000..fc1aa9b --- /dev/null +++ b/scripts/android-matrix-test.sh @@ -0,0 +1,20 @@ +#!/bin/sh +set -eu + +ROOT=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd) +matrix=${WNH_ANDROID_TEST_API_MATRIX:-"30 34 37.0"} + +for api in $matrix; do + case "$api" in + 30|34|37.0) ;; + *) + echo "WNH_ANDROID_TEST_API_MATRIX contains unsupported API: $api" >&2 + exit 1 + ;; + esac + + echo "Running Android device suite on API $api." + WNH_ANDROID_TEST_API=$api "$ROOT/scripts/android-instrumentation-test.sh" +done + +echo "Android device matrix passed: $matrix"