diff --git a/android/README.md b/android/README.md index 9cccb8d..319d94f 100644 --- a/android/README.md +++ b/android/README.md @@ -60,6 +60,19 @@ be published as a release. The manifest accepts same-origin HTTPS deep links, but verified Android App Links additionally require the final signing certificate fingerprint in the deployment's `/.well-known/assetlinks.json`. +With the temporary public origin reachable, run the API 37 emulator smoke test: + +```sh +./scripts/android-staging-smoke.sh +``` + +The script installs the exported staging APK into a fresh project-scoped +emulator container, loads the configured HTTPS home page, follows a +same-origin `/safety` deep link, verifies that the package does not claim an +external HTTPS origin, and retains UI dumps, screenshots, package metadata, +and logcat diagnostics under ignored `output/android-staging-smoke/`. The +one-run container and image are removed on success or failure. + ## Reproducible Docker build From the repository root: diff --git a/scripts/android-staging-smoke.sh b/scripts/android-staging-smoke.sh new file mode 100755 index 0000000..74aed94 --- /dev/null +++ b/scripts/android-staging-smoke.sh @@ -0,0 +1,273 @@ +#!/bin/sh +set -eu +umask 077 + +ROOT=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd) +ENV_FILE="$ROOT/.env" +APK="$ROOT/android/dist-staging/who-need-help-staging.apk" +run_id=$(date -u +%Y%m%d%H%M%S)-$$ +image="who-need-help-android:staging-smoke-$run_id" +container="who-need-help-android-staging-smoke-$run_id" +output="$ROOT/output/android-staging-smoke/$run_id" +package=org.whoneedhelp.mobile.staging +activity=org.whoneedhelp.mobile.MainActivity + +if [ ! -e /dev/kvm ]; then + echo "/dev/kvm is required for the containerized Android emulator." >&2 + exit 1 +fi + +if [ ! -f "$ENV_FILE" ]; then + echo "Missing $ENV_FILE." >&2 + exit 1 +fi + +if [ ! -s "$APK" ]; then + echo "Missing staging APK: $APK. Run scripts/android-staging-build.sh first." >&2 + exit 1 +fi + +set -a +# shellcheck source=/dev/null +. "$ENV_FILE" +set +a + +: "${WNH_BASE_URL:?Set WNH_BASE_URL in .env}" +: "${WNH_TRACKING_MIN_TIME_MS:?Set WNH_TRACKING_MIN_TIME_MS in .env}" +: "${WNH_TRACKING_HTTP_TIMEOUT_MS:?Set WNH_TRACKING_HTTP_TIMEOUT_MS in .env}" + +case "$WNH_BASE_URL" in + https://*/* | https://*) ;; + *) + echo "WNH_BASE_URL must be an HTTPS origin." >&2 + exit 1 + ;; +esac + +origin_without_scheme=${WNH_BASE_URL#https://} + +case "$origin_without_scheme" in + "" | */* | *\?* | *\#* | *@*) + echo "WNH_BASE_URL must be a credential-free HTTPS origin without a path." >&2 + exit 1 + ;; +esac + +expected_host=${origin_without_scheme%%:*} + +mkdir -p "$output" +chmod 700 "$ROOT/output" "$ROOT/output/android-staging-smoke" "$output" + +cleanup() { + status=$? + trap - EXIT HUP INT TERM + + if 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 activities \ + >"$output/activities.txt" 2>&1 || true + docker logs "$container" >"$output/emulator.log" 2>&1 || true + fi + + docker rm -f "$container" >/dev/null 2>&1 || true + docker image rm "$image" >/dev/null 2>&1 || true + + { + printf 'container_absent=' + if docker inspect "$container" >/dev/null 2>&1; then + printf 'false\n' + else + printf 'true\n' + fi + + printf 'image_absent=' + if docker image inspect "$image" >/dev/null 2>&1; then + printf 'false\n' + else + printf 'true\n' + fi + } >"$output/cleanup.txt" + + exit "$status" +} + +trap cleanup EXIT HUP INT TERM + +sha256sum "$APK" >"$output/apk.sha256" + +docker build \ + --build-arg "WNH_DEBUG_BASE_URL=$WNH_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=system-images/android-37.0/google_apis_ps16k/x86_64" \ + --target emulator \ + --tag "$image" \ + "$ROOT/android" >"$output/image-build.txt" + +docker run -d \ + --name "$container" \ + --device /dev/kvm \ + "$image" >"$output/container-id.txt" + +docker exec "$container" adb wait-for-device + +booted= +attempt=0 +while [ "$attempt" -lt 90 ]; do + booted=$( + docker exec "$container" adb shell getprop sys.boot_completed 2>/dev/null | + tr -d '\r' + ) + + if [ "$booted" = "1" ]; then + break + fi + + attempt=$((attempt + 1)) + sleep 2 +done + +if [ "$booted" != "1" ]; then + echo "Android staging emulator did not finish booting." >&2 + exit 1 +fi + +docker exec "$container" adb shell input keyevent 82 +docker exec "$container" adb shell settings put global window_animation_scale 0 +docker exec "$container" adb shell settings put global transition_animation_scale 0 +docker exec "$container" adb shell settings put global animator_duration_scale 0 +docker cp "$APK" "$container:/tmp/who-need-help-staging.apk" +docker exec "$container" adb install -r /tmp/who-need-help-staging.apk \ + >"$output/install.txt" +docker exec "$container" adb shell dumpsys package "$package" \ + >"$output/package.txt" + +if ! grep -Fq "versionName=0.1.0-staging" "$output/package.txt"; then + echo "The installed package is not the expected staging variant." >&2 + exit 1 +fi + +same_origin="$WNH_BASE_URL/safety" +external_origin=https://example.com/ + +if ! grep -Fq "Authority: \"$expected_host\"" "$output/package.txt"; then + echo "The staging APK does not declare its exact HTTPS host." >&2 + exit 1 +fi + +if grep -Fq 'Authority: "example.com"' "$output/package.txt"; then + echo "The staging APK incorrectly declares an external HTTPS host." >&2 + exit 1 +fi + +docker exec "$container" adb shell cmd package resolve-activity --brief \ + -a android.intent.action.VIEW \ + -c android.intent.category.BROWSABLE \ + -d "$same_origin" >"$output/same-origin-resolver.txt" + +docker exec "$container" adb shell cmd package resolve-activity --brief \ + -a android.intent.action.VIEW \ + -c android.intent.category.BROWSABLE \ + -d "$external_origin" >"$output/external-origin-resolver.txt" + +if grep -Fq "$package/" "$output/external-origin-resolver.txt"; then + echo "The staging APK incorrectly claimed an external HTTPS origin." >&2 + exit 1 +fi + +docker exec "$container" adb shell pm get-app-links "$package" \ + >"$output/app-links.txt" + +docker exec "$container" adb logcat -c +docker exec "$container" adb shell am start -W \ + -n "$package/$activity" >"$output/home-start.txt" + +home_loaded=false +attempt=0 +while [ "$attempt" -lt 45 ]; do + docker exec "$container" adb logcat -d -s WhoNeedHelpWebView:D '*:S' \ + >"$output/webview-current.txt" 2>&1 || true + + if grep -Fq "Main-frame load finished: path=/" "$output/webview-current.txt"; then + home_loaded=true + break + fi + + attempt=$((attempt + 1)) + sleep 2 +done + +if [ "$home_loaded" != true ]; then + echo "The staging WebView did not finish loading the public home page." >&2 + exit 1 +fi + +docker exec "$container" adb shell uiautomator dump /sdcard/home-window.xml \ + >"$output/home-ui-dump-command.txt" 2>&1 +docker exec "$container" adb exec-out cat /sdcard/home-window.xml \ + >"$output/home-window.xml" +docker exec "$container" adb exec-out screencap -p >"$output/home.png" + +docker exec "$container" adb shell am start -W \ + -a android.intent.action.VIEW \ + -c android.intent.category.BROWSABLE \ + -d "$same_origin" \ + -n "$package/$activity" >"$output/safety-start.txt" + +safety_loaded=false +attempt=0 +while [ "$attempt" -lt 45 ]; do + docker exec "$container" adb logcat -d -s WhoNeedHelpWebView:D '*:S' \ + >"$output/webview-current.txt" 2>&1 || true + + if grep -Fq "Main-frame load finished: path=/safety" "$output/webview-current.txt"; then + safety_loaded=true + break + fi + + attempt=$((attempt + 1)) + sleep 2 +done + +if [ "$safety_loaded" != true ]; then + echo "The same-origin Android deep link did not finish loading /safety." >&2 + exit 1 +fi + +docker exec "$container" adb shell uiautomator dump /sdcard/safety-window.xml \ + >"$output/safety-ui-dump-command.txt" 2>&1 +docker exec "$container" adb exec-out cat /sdcard/safety-window.xml \ + >"$output/safety-window.xml" +docker exec "$container" adb exec-out screencap -p >"$output/safety.png" +docker exec "$container" adb logcat -d >"$output/logcat-before-cleanup.txt" +docker exec "$container" adb shell dumpsys activity activities \ + >"$output/activities-before-cleanup.txt" + +if grep -Eqi \ + 'Main-frame load failed|net::ERR_|ERR_CERT|SSL handshake failed|chromium.*crash' \ + "$output/logcat-before-cleanup.txt"; then + echo "Android staging logcat contains a public-page load or TLS failure." >&2 + exit 1 +fi + +if ! grep -Fq "$package/$activity" "$output/activities-before-cleanup.txt"; then + echo "The staging activity was not observed after the deep-link load." >&2 + exit 1 +fi + +{ + printf 'run_id=%s\n' "$run_id" + printf 'android_api=37.0\n' + printf 'package=%s\n' "$package" + printf 'public_origin=%s\n' "$WNH_BASE_URL" + printf 'home_loaded=true\n' + printf 'same_origin_manifest_filter=true\n' + printf 'same_origin_deep_link_loaded=true\n' + printf 'external_origin_manifest_filter=false\n' + printf 'load_or_tls_errors=0\n' +} >"$output/summary.txt" + +echo "Android public staging smoke passed." +echo "Evidence: $output"