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.
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

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

View File

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

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

View File

@ -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<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) {
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(

View File

@ -66,16 +66,45 @@ final class FixtureHttpServer implements Closeable {
RecordedRequest awaitRequestEndingWith(String suffix, long timeout, TimeUnit unit)
throws InterruptedException {
long deadline = System.nanoTime() + unit.toNanos(timeout);
return awaitRequestAfterCount(suffix, 0, timeout, unit);
}
int requestCountEndingWith(String suffix) {
int count = 0;
while (System.nanoTime() < deadline) {
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)) {
count++;
if (count > previousCount) {
return request;
}
}
}
}
Thread.sleep(50);
}
@ -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 {
+ "<title>Who Need Help Android fixture</title></head><body>"
+ "<h1 id=\"marker\">loaded:" + escapedPath + "</h1>"
+ "<button id=\"location\" onclick=\"navigator.geolocation.getCurrentPosition("
+ "()=>document.getElementById('marker').textContent='location-granted',"
+ "()=>document.getElementById('marker').textContent='location-denied')\">"
+ "()=>{document.getElementById('marker').textContent='location-granted';"
+ "fetch('/geolocation-granted')},"
+ "()=>{document.getElementById('marker').textContent='location-denied';"
+ "fetch('/geolocation-denied')},"
+ "{enableHighAccuracy:true,maximumAge:0,timeout:10000})\">"
+ "Request location</button>"
+ "</body></html>";
}
@ -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(

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

View File

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

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"