diff --git a/.github/workflows/android-launch-smoke.yml b/.github/workflows/android-launch-smoke.yml
new file mode 100644
index 000000000..d69fe475e
--- /dev/null
+++ b/.github/workflows/android-launch-smoke.yml
@@ -0,0 +1,54 @@
+name: Android TWA startup smoke (emulator)
+
+on:
+ pull_request:
+ paths:
+ - android/**
+ - scripts/android/launch-smoke.sh
+ - .github/workflows/android-launch-smoke.yml
+ workflow_dispatch:
+
+permissions:
+ contents: read
+
+jobs:
+ startup:
+ name: Launch Android TWA on a real emulator and capture crashes
+ runs-on: ubuntu-24.04
+ timeout-minutes: 35
+ steps:
+ - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
+ - uses: actions/setup-java@v4
+ with:
+ distribution: temurin
+ java-version: "17"
+ - uses: android-actions/setup-android@v4
+ - run: sdkmanager "platforms;android-36" "build-tools;36.0.0"
+ - uses: gradle/actions/setup-gradle@v4
+ with:
+ gradle-version: "8.13"
+ - name: Assemble unsigned debug APK with real launcher configuration
+ working-directory: android
+ run: gradle --no-daemon --stacktrace :app:assembleDebug
+ - name: Enable hardware virtualization for emulator
+ run: |
+ echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' |
+ sudo tee /etc/udev/rules.d/99-kvm4all.rules
+ sudo udevadm control --reload-rules
+ sudo udevadm trigger --name-match=kvm
+ - name: Install, launch, and inspect TWA in Google Play emulator
+ uses: reactivecircus/android-emulator-runner@a421e43855164a8197daf9d8d40fe71c6996bb0d # v2
+ with:
+ api-level: 35
+ target: google_apis_playstore
+ arch: x86_64
+ profile: pixel_7
+ script: bash scripts/android/launch-smoke.sh
+ - name: Preserve startup logs for debugging even after a failure
+ if: always()
+ uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
+ with:
+ name: menuminute-android-startup-logcat
+ path: android-launch-smoke/
+ if-no-files-found: warn
+ retention-days: 14
diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index c71ec1405..5f30c53fd 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -11,11 +11,24 @@
android:usesCleartextTraffic="false"
android:label="@string/app_name"
android:icon="@drawable/ic_launcher"
+ android:manageSpaceActivity="com.google.androidbrowserhelper.trusted.ManageDataLauncherActivity"
android:supportsRtl="true"
android:theme="@android:style/Theme.Translucent.NoTitleBar">
+
+
+
+
+
+
"$out/packages.txt"
+adb shell dumpsys package "$pkg" > "$out/installed-package.txt"
+adb shell getprop ro.build.version.release > "$out/android-version.txt"
+adb shell settings put global window_animation_scale 0
+adb logcat -c
+adb shell am force-stop "$pkg"
+echo "Launching $pkg/$activity in the clean emulator..."
+if ! adb shell am start -W -n "$pkg/$activity" > "$out/launch-result.txt" 2>&1; then
+ cat "$out/launch-result.txt"
+ exit 1
+fi
+sleep 22
+adb logcat -d -v threadtime -b crash > "$out/crash-buffer.txt" 2>&1 || true
+adb logcat -d -v threadtime AndroidRuntime:E TWALauncherActivity:V TwaLauncher:V QualityEnforcer:V ActivityTaskManager:W '*:S' > "$out/twa-startup-log.txt" 2>&1 || true
+adb shell dumpsys activity activities > "$out/activity-state.txt" || true
+cat "$out/launch-result.txt"
+if grep -Eq "(Process: $pkg|Process $pkg .* has died|FATAL EXCEPTION)" "$out/crash-buffer.txt"; then
+ echo "::error::Android launcher reported a Java crash. See captured crash-buffer.txt artifact."
+ sed -n '1,100p' "$out/crash-buffer.txt"
+ exit 1
+fi
+echo "Clean-emulator startup did not report a Java fatal exception."
+echo "NOTE: This checks the unsigned debug APK, not Google Play's re-signed bundle on the user's device."
diff --git a/tests/android-launch-manifest.test.ts b/tests/android-launch-manifest.test.ts
new file mode 100644
index 000000000..47b2db4d9
--- /dev/null
+++ b/tests/android-launch-manifest.test.ts
@@ -0,0 +1,35 @@
+import { readFileSync } from "node:fs";
+import path from "node:path";
+import { describe, expect, it } from "vitest";
+
+const manifest = readFileSync(path.resolve("android/app/src/main/AndroidManifest.xml"), "utf8");
+const application = manifest.split("")[0] ?? "";
+
+describe("Android Browser Helper startup manifest completeness", () => {
+ it("declares the helper's site-settings activity before the TWA launcher", () => {
+ // LauncherActivity calls ManageDataLauncherActivity.addSiteSettingsShortcut()
+ // unconditionally; Android throws if its component is missing from the manifest.
+ expect(application).toContain(
+ 'android:manageSpaceActivity="com.google.androidbrowserhelper.trusted.ManageDataLauncherActivity"'
+ );
+ expect(application).toContain(
+ 'android:name="com.google.androidbrowserhelper.trusted.ManageDataLauncherActivity"'
+ );
+ expect(application).toContain('android:exported="false"');
+ expect(application).toContain(
+ 'android:name="android.support.customtabs.trusted.MANAGE_SPACE_URL"'
+ );
+ expect(application.indexOf("ManageDataLauncherActivity")).toBeLessThan(
+ application.indexOf('android:name="com.google.androidbrowserhelper.trusted.LauncherActivity"')
+ );
+ });
+
+ it("preserves the installed package, Play Billing integrations, and trusted origin", () => {
+ expect(application).toContain("@string/asset_statements");
+ expect(application).toContain("@string/launch_url");
+ expect(application).toContain('android:name=".ExtraFeaturesService"');
+ expect(application).toContain(
+ 'android:name="com.google.androidbrowserhelper.playbilling.provider.PaymentActivity"'
+ );
+ });
+});