test: expand Android lifecycle and API matrix coverage

This commit is contained in:
SimpleTest 2026-07-19 17:38:58 +03:00
parent 378d0b4563
commit 460ec21253
10 changed files with 386 additions and 36 deletions

View File

@ -27,6 +27,8 @@ WNH_BASE_URL=
# production capacity recommendations. # production capacity recommendations.
WNH_TRACKING_MIN_TIME_MS=5000 WNH_TRACKING_MIN_TIME_MS=5000
WNH_TRACKING_HTTP_TIMEOUT_MS=15000 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 # Public raster tile template used by MapLibre. Use a provider whose policy and
# capacity match the deployment before a public launch. # capacity match the deployment before a public launch.
MAP_TILE_URL=https://tile.openstreetmap.org/{z}/{x}/{y}.png MAP_TILE_URL=https://tile.openstreetmap.org/{z}/{x}/{y}.png

View File

@ -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 networks are removed automatically; the normal `who_need_help` Compose project
is not recreated. is not recreated.
The Android device suite builds a dedicated debug and instrumentation APK, The Android device suite builds dedicated debug and instrumentation APKs,
boots an API 37 emulator in an isolated container without external networking, boots a fresh emulator in an isolated container without external networking,
and serves its HTTP fixture only on device loopback: and serves its HTTP fixture only on device loopback. Run the default API 37
probe or the complete API 30/34/37 matrix:
```bash ```bash
./scripts/android-instrumentation-test.sh ./scripts/android-instrumentation-test.sh
./scripts/android-matrix-test.sh
``` ```
On first run it generates the ignored `.env.android-test` with a randomized 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 device-loopback origin and mode `0600`. The suite covers denied and granted
location permission, same-origin deep links, Activity recreation, foreground location permission, same-origin deep links, Activity recreation, foreground
location upload, the persistent notification Stop action, and a disconnected location upload while the Activity is backgrounded and destroyed, the
Stop request with visible retry state. Results and failure diagnostics are persistent notification Stop action, a disconnected Stop request with visible
retained under ignored `output/android-instrumentation/`; the exact emulator retry state, and externally forced process death for the non-sticky service.
container and one-run image are removed automatically. 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 ## First administrator

View File

@ -57,11 +57,15 @@ FROM android-base AS emulator
USER root USER root
SHELL ["/bin/bash", "-o", "pipefail", "-c"] 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 \ RUN android --no-metrics --sdk="${ANDROID_HOME}" sdk install \
"emulator" \ "emulator" \
"system-images/android-37.0/google_apis_ps16k/x86_64" \ "${ANDROID_EMULATOR_SYSTEM_IMAGE}" \
&& chown -R gradle:gradle "${ANDROID_HOME}" && chown -R gradle:gradle "${ANDROID_HOME}"
ARG ANDROID_EMULATOR_DATA_PARTITION_SIZE=4G
RUN apt-get update \ RUN apt-get update \
&& apt-get install -y --no-install-recommends \ && apt-get install -y --no-install-recommends \
libdbus-1-3=1.16.2-2ubuntu4 \ libdbus-1-3=1.16.2-2ubuntu4 \
@ -89,8 +93,11 @@ USER gradle
RUN echo no | avdmanager create avd \ RUN echo no | avdmanager create avd \
--force \ --force \
--name who_need_help_api37 \ --name who_need_help_test \
--package "system-images;android-37.0;google_apis_ps16k;x86_64" --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 \ COPY --from=android-sdk --chown=gradle:gradle \
/workspace/android/app/build/outputs/apk/debug/app-debug.apk \ /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 /opt/who-need-help/who-need-help-debug-androidTest.apk
CMD ["/opt/android-sdk/emulator/emulator", \ CMD ["/opt/android-sdk/emulator/emulator", \
"-avd", "who_need_help_api37", \ "-avd", "who_need_help_test", \
"-no-window", \ "-no-window", \
"-no-audio", \ "-no-audio", \
"-no-boot-anim", \ "-no-boot-anim", \

View File

@ -73,28 +73,33 @@ The Docker build runs JVM unit tests, Android lint, `assembleDebug`, and
`assembleDebugAndroidTest` before it exports the application APK and lint `assembleDebugAndroidTest` before it exports the application APK and lint
report. 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 ```sh
./scripts/android-instrumentation-test.sh ./scripts/android-instrumentation-test.sh
./scripts/android-matrix-test.sh
``` ```
The command requires `/dev/kvm`. It generates an ignored The command requires `/dev/kvm`. It generates an ignored
`.env.android-test` once with a randomized `http://127.0.0.1:PORT` origin and `.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 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 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 The tests cover the missing-location-permission boundary, the WebView-triggered
Android permission dialog, same-origin deep-link routing across Activity Android permission dialog, same-origin deep-link routing across Activity
recreation, foreground location upload, the persistent notification Stop recreation, foreground location upload after Home and Activity destruction, the
action, and a disconnected Stop request with visible retry state. Results are persistent notification Stop action, a disconnected Stop request with visible
stored under ignored `output/android-instrumentation/`. On failure, logcat, retry state, and externally forced process death for the non-sticky tracking
service state, and emulator logs are retained; the exact container and service. Results are stored by API under ignored
one-run image are removed in either outcome. `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 ## Emulator verification

View File

@ -89,7 +89,7 @@ public final class AndroidClientInstrumentedTest {
} }
@Test @Test
public void test02WebGeolocationRequestsAndGrantsRuntimePermission() { public void test02WebGeolocationRequestsAndGrantsRuntimePermission() throws Exception {
assertFalse(hasLocationPermission()); assertFalse(hasLocationPermission());
try ( try (
@ -119,7 +119,18 @@ public final class AndroidClientInstrumentedTest {
assertNotNull("Android location permission prompt was not shown", allow); assertNotNull("Android location permission prompt was not shown", allow);
allow.click(); 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<MainActivity> 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<MainActivity> launch(String path) { private ActivityScenario<MainActivity> launch(String path) {
Intent intent = new Intent( Intent intent = new Intent(
Intent.ACTION_VIEW, Intent.ACTION_VIEW,
@ -262,6 +331,20 @@ public final class AndroidClientInstrumentedTest {
== android.content.pm.PackageManager.PERMISSION_GRANTED; == 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 { private void grantTrackingPermissions() throws Exception {
String packageName = context.getPackageName(); String packageName = context.getPackageName();
device.executeShellCommand( device.executeShellCommand(

View File

@ -66,13 +66,42 @@ final class FixtureHttpServer implements Closeable {
RecordedRequest awaitRequestEndingWith(String suffix, long timeout, TimeUnit unit) RecordedRequest awaitRequestEndingWith(String suffix, long timeout, TimeUnit unit)
throws InterruptedException { 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); long deadline = System.nanoTime() + unit.toNanos(timeout);
while (System.nanoTime() < deadline) { while (System.nanoTime() < deadline) {
int count = 0;
synchronized (requests) { synchronized (requests) {
for (RecordedRequest request : requests) { for (RecordedRequest request : requests) {
if (request.path.endsWith(suffix)) { 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( RecordedRequest request = new RecordedRequest(
parts[0], parts[0],
parts[1], parts[1],
@ -182,8 +211,11 @@ final class FixtureHttpServer implements Closeable {
+ "<title>Who Need Help Android fixture</title></head><body>" + "<title>Who Need Help Android fixture</title></head><body>"
+ "<h1 id=\"marker\">loaded:" + escapedPath + "</h1>" + "<h1 id=\"marker\">loaded:" + escapedPath + "</h1>"
+ "<button id=\"location\" onclick=\"navigator.geolocation.getCurrentPosition(" + "<button id=\"location\" onclick=\"navigator.geolocation.getCurrentPosition("
+ "()=>document.getElementById('marker').textContent='location-granted'," + "()=>{document.getElementById('marker').textContent='location-granted';"
+ "()=>document.getElementById('marker').textContent='location-denied')\">" + "fetch('/geolocation-granted')},"
+ "()=>{document.getElementById('marker').textContent='location-denied';"
+ "fetch('/geolocation-denied')},"
+ "{enableHighAccuracy:true,maximumAge:0,timeout:10000})\">"
+ "Request location</button>" + "Request location</button>"
+ "</body></html>"; + "</body></html>";
} }
@ -206,7 +238,25 @@ final class FixtureHttpServer implements Closeable {
return null; 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( private static void writeResponse(

View File

@ -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");
}
}

View File

@ -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 - A forced LiveSocket transport loss while Chromium is offline exposes the
reconnect alert, and restoring the network clears it and reconnects the reconnect alert, and restoring the network clears it and reconnects the
authenticated LiveView. authenticated LiveView.
- The isolated API 37 Android suite passes five device tests: missing and - The isolated API 30/34/37 Android matrix passes six device tests per API:
granted location permission, same-origin deep-link routing across Activity missing and granted location permission, same-origin deep-link routing across
recreation, native foreground location upload, notification Stop with remote Activity recreation, native foreground location upload, notification Stop
cleanup, and a disconnected Stop request with visible retry state. It uses an with remote cleanup, a disconnected Stop request with visible retry state,
in-process loopback fixture, runs its emulator without external networking, 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. retains diagnostics on failure, and removes its exact container and image.
- The containerized `scripts/quality.sh` gate passes ShellCheck, Hadolint, - The containerized `scripts/quality.sh` gate passes ShellCheck, Hadolint,
actionlint, all Compose renders, Helm lint, a Trivy scan of tracked source and actionlint, all Compose renders, Helm lint, a Trivy scan of tracked source and

View File

@ -5,10 +5,37 @@ ROOT=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd)
ANDROID_ENV="$ROOT/.env" ANDROID_ENV="$ROOT/.env"
TEST_ENV="$ROOT/.env.android-test" TEST_ENV="$ROOT/.env.android-test"
run_id=$(date -u +%Y%m%d%H%M%S)-$$ run_id=$(date -u +%Y%m%d%H%M%S)-$$
image="who-need-help-android:instrumentation-$run_id" android_api=${WNH_ANDROID_TEST_API:-37.0}
container="who-need-help-android-instrumentation-$run_id" android_data_partition_size=${WNH_ANDROID_TEST_DATA_PARTITION_SIZE:-4G}
output="$ROOT/output/android-instrumentation/$run_id"
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="" geo_pid=""
death_runner_pid=""
if [ ! -e /dev/kvm ]; then if [ ! -e /dev/kvm ]; then
echo "/dev/kvm is required for the containerized Android emulator." >&2 echo "/dev/kvm is required for the containerized Android emulator." >&2
@ -62,6 +89,11 @@ cleanup() {
wait "$geo_pid" >/dev/null 2>&1 || true wait "$geo_pid" >/dev/null 2>&1 || true
fi 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 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 logcat -d > "$output/logcat.txt" 2>&1 || true
docker exec "$container" adb shell dumpsys activity services \ 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_DEBUG_BASE_URL=$WNH_ANDROID_TEST_BASE_URL" \
--build-arg "WNH_TRACKING_MIN_TIME_MS=$WNH_TRACKING_MIN_TIME_MS" \ --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 "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 \ --target emulator \
-t "$image" \ -t "$image" \
"$ROOT/android" "$ROOT/android"
@ -136,6 +170,7 @@ geo_pid=$!
set +e set +e
docker exec "$container" adb shell am instrument -w -r \ docker exec "$container" adb shell am instrument -w -r \
-e class org.whoneedhelp.mobile.AndroidClientInstrumentedTest \
org.whoneedhelp.mobile.debug.test/androidx.test.runner.AndroidJUnitRunner \ org.whoneedhelp.mobile.debug.test/androidx.test.runner.AndroidJUnitRunner \
> "$output/results.txt" 2>&1 > "$output/results.txt" 2>&1
instrumentation_status=$? instrumentation_status=$?
@ -149,4 +184,88 @@ if [ "$instrumentation_status" -ne 0 ] \
exit 1 exit 1
fi 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"

20
scripts/android-matrix-test.sh Executable file
View File

@ -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"