test: expand Android lifecycle and API matrix coverage
This commit is contained in:
parent
378d0b4563
commit
460ec21253
|
|
@ -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
|
||||||
|
|
|
||||||
18
README.md
18
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
|
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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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", \
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
20
scripts/android-matrix-test.sh
Executable 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"
|
||||||
Loading…
Reference in New Issue
Block a user