diff --git a/.gitignore b/.gitignore index e4c0b0a06ed..b31482bb89b 100644 --- a/.gitignore +++ b/.gitignore @@ -112,6 +112,7 @@ docs/** !docs/reference/plans/2026-07-22-mobile-hybrid-webview-implementation-checklist.md !docs/reference/plans/2026-07-22-mobile-hybrid-webview-parity-inventory.md !docs/reference/macos-press-and-hold.md +!docs/reference/mobile-hybrid-webview-devtools.md !docs/reference/orcad-operations.md !docs/reference/relay-grace-time-reconfiguration.md !docs/reference/windows-edr-posture.md diff --git a/docs/reference/mobile-hybrid-webview-devtools.md b/docs/reference/mobile-hybrid-webview-devtools.md new file mode 100644 index 00000000000..33911f52806 --- /dev/null +++ b/docs/reference/mobile-hybrid-webview-devtools.md @@ -0,0 +1,152 @@ +# Inspecting the Android hybrid WebView with Chrome DevTools + +The hybrid Android build renders the hosted React Native Web page inside a native +`MobileWebShellView` WebView. That WebView is normally not inspectable, even in a +release build made for dogfooding, because +`WebView.setWebContentsDebuggingEnabled` is driven by a policy that requires both a +debug build and the OS `ApplicationInfo.FLAG_DEBUGGABLE` flag. + +This runbook covers the opt-in that makes a **release** APK inspectable while keeping +the release JS bundle, the hybrid architecture, and the same application id, so it +upgrades a dogfood install in place. + +## The opt-in + +A single Gradle property, `-PorcaInspectableRelease=true`, drives both halves: + +- `mobile/plugins/android-inspectable-release.js` marks the release variant + `debuggable`, which sets `FLAG_DEBUGGABLE` on the installed package and makes + Android publish a `webview_devtools_remote_` abstract socket for the process. + `mobile/android/` is generated and gitignored, so this lives in an Expo config + plugin and is reapplied on every `expo prebuild`. +- `mobile/packages/expo-mobile-web-shell/android/build.gradle` sets the + `ORCA_INSPECTABLE_RELEASE` build config field that + `MobileWebInspectionPolicy.kt` reads. + +Both default to `false`. A normal release build is unchanged and stays +uninspectable. + +The policy keeps `FLAG_DEBUGGABLE` as a hard requirement: + +```kotlin +return isDebuggable && (isDebugBuild || isInspectableRelease) +``` + +so a production APK signed and shipped without `debuggable` can never be inspected, +whatever the Gradle property said at build time. The loopback end-to-end security +probe stays `BuildConfig.DEBUG`-only and is not installed in an inspectable release. + +## Build + +The Expo module is consumed through a pnpm `file:` dependency, which is a **copy**, +not a symlink. Edits under `mobile/packages/expo-mobile-web-shell/` do not reach the +Gradle build until that copy is refreshed: + +```bash +cd mobile +rsync -a --exclude 'build/' \ + packages/expo-mobile-web-shell/android/ \ + node_modules/@orca/expo-mobile-web-shell/android/ +``` + +Then build: + +```bash +cd mobile/android +EXPO_PUBLIC_ORCA_MOBILE_ARCHITECTURE=hybrid NODE_ENV=production \ + ./gradlew --rerun-tasks -PorcaInspectableRelease=true \ + :app:createBundleReleaseJsAndAssets :app:assembleRelease -q +``` + +The APK lands at `mobile/android/app/build/outputs/apk/release/app-release.apk`. + +Confirm it is still a production hybrid bundle and that the manifest is debuggable: + +```bash +cd mobile/android/app/build/outputs/apk/release +unzip -p app-release.apk assets/index.android.bundle | rg -c connectionLog +aapt dump badging app-release.apk | rg "application-debuggable|package: name" +``` + +## Install and launch + +The phone occasionally drops off adb, so wait for a stable device before installing: + +```bash +until adb devices | rg -q "^\s+device$"; do sleep 1; done +adb -s install -r mobile/android/app/build/outputs/apk/release/app-release.apk +adb -s shell monkey -p com.stably.orca.mobile.ota 1 +``` + +Verify the OS agrees the package is debuggable: + +```bash +adb -s shell dumpsys package com.stably.orca.mobile.ota | rg flags +``` + +The `flags=` line must contain `DEBUGGABLE`. + +## Forward and attach + +The socket does not exist at app launch. `MobileWebShellView` only calls +`setWebContentsDebuggingEnabled` when a session activates, so open a workspace on the +phone first, then look for the socket. On the home screen you will see other apps' +sockets and no `webview_devtools_remote_` entry for Orca. + +Android names the DevTools socket after the process id, so read it rather than +guessing: + +```bash +adb -s shell cat /proc/net/unix | rg devtools_remote +adb -s forward tcp: localabstract:webview_devtools_remote_ +curl -s http://127.0.0.1:/json +``` + +Ports `9345` and `9444` are taken by other Orca tooling; pick something else. + +The hosted page appears as a target whose URL is on the private asset origin, +`https://.orca-mobile-web.invalid/#`. Its `webSocketDebuggerUrl` +is the CDP endpoint. + +Attach either way: + +- **Chrome**: open `chrome://inspect`, add `127.0.0.1:` under *Discover network + targets*, then click *inspect* on the hosted target. +- **playwright-cli**: `playwright-cli attach --cdp http://127.0.0.1:`. + +Tear the forward down with `adb -s forward --remove tcp:`. + +Note that the socket name changes whenever the app process restarts, so re-read +`/proc/net/unix` and re-forward after a cold start. + +## Verifying the default is still closed + +A build without the property must produce no `android:debuggable` attribute and a +false build config field: + +```bash +cd mobile/android +./gradlew --rerun-tasks :app:processReleaseMainManifest \ + :orca-expo-mobile-web-shell:generateReleaseBuildConfig -q +grep -o 'android:debuggable="[^"]*"' \ + app/build/intermediates/merged_manifest/release/processReleaseMainManifest/AndroidManifest.xml +grep ORCA_INSPECTABLE_RELEASE \ + ../node_modules/@orca/expo-mobile-web-shell/android/build/generated/source/buildConfig/release/expo/modules/mobilewebshell/BuildConfig.java +``` + +The first `grep` must find nothing and the second must report `= false`. Note this +overwrites the release intermediates, so rebuild with the property before shipping an +inspectable APK again. + +## Gates + +```bash +pnpm test mobile/src/mobile-web/hosted-webview-cdp-session.test.ts +pnpm test mobile/src/mobile-web/hosted-android-inspectable-release.test.ts +cd mobile && npx tsc --noEmit -p . +pnpm run check:code-quality:changed +``` + +The Kotlin truth table for the policy lives in +`mobile/packages/expo-mobile-web-shell/android/src/test/java/expo/modules/mobilewebshell/MobileWebInspectionPolicyTest.kt` +and runs with the module's `testDebugUnitTest` task. diff --git a/mobile/app.json b/mobile/app.json index d5a420cc74b..0636b91239a 100644 --- a/mobile/app.json +++ b/mobile/app.json @@ -80,6 +80,7 @@ "plugins": [ "expo-router", "./plugins/android-respect-rotation-lock.js", + "./plugins/android-inspectable-release.js", [ "expo-splash-screen", { diff --git a/mobile/packages/expo-mobile-web-shell/android/build.gradle b/mobile/packages/expo-mobile-web-shell/android/build.gradle index 4c179ee19ba..c60cf3dba5f 100644 --- a/mobile/packages/expo-mobile-web-shell/android/build.gradle +++ b/mobile/packages/expo-mobile-web-shell/android/build.gradle @@ -12,6 +12,12 @@ useDefaultAndroidSdkVersions() android { namespace 'expo.modules.mobilewebshell' + + defaultConfig { + // Mirrors the app's -PorcaInspectableRelease so a dogfood release can opt its WebViews into DevTools. + buildConfigField 'boolean', 'ORCA_INSPECTABLE_RELEASE', + (findProperty('orcaInspectableRelease') ?: 'false').toBoolean().toString() + } } dependencies { diff --git a/mobile/packages/expo-mobile-web-shell/android/src/main/java/expo/modules/mobilewebshell/MobileWebDebugIsolationProbe.kt b/mobile/packages/expo-mobile-web-shell/android/src/main/java/expo/modules/mobilewebshell/MobileWebDebugIsolationProbe.kt index 60a143dc15e..f3501c6237f 100644 --- a/mobile/packages/expo-mobile-web-shell/android/src/main/java/expo/modules/mobilewebshell/MobileWebDebugIsolationProbe.kt +++ b/mobile/packages/expo-mobile-web-shell/android/src/main/java/expo/modules/mobilewebshell/MobileWebDebugIsolationProbe.kt @@ -16,11 +16,11 @@ internal fun installMobileWebDebugIsolationProbe( appContext: AppContext, allowedOrigin: String ): ScriptHandler? { - val isDebuggable = - webView.context.applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE != 0 - val debuggingEnabled = BuildConfig.DEBUG && isDebuggable - WebView.setWebContentsDebuggingEnabled(debuggingEnabled) - if (!debuggingEnabled) return null + val applicationFlags = webView.context.applicationInfo.flags + val isDebuggable = applicationFlags and ApplicationInfo.FLAG_DEBUGGABLE != 0 + WebView.setWebContentsDebuggingEnabled(isMobileWebInspectionEnabled(applicationFlags)) + // The loopback security probe stays DEBUG-only; an inspectable release gets DevTools without it. + if (!BuildConfig.DEBUG || !isDebuggable) return null val intent = appContext.currentActivity?.intent ?: return null val script = createMobileWebDebugIsolationProbeScript( intent.getStringExtra(NETWORK_PROBE_PORT_EXTRA), diff --git a/mobile/packages/expo-mobile-web-shell/android/src/main/java/expo/modules/mobilewebshell/MobileWebInspectionPolicy.kt b/mobile/packages/expo-mobile-web-shell/android/src/main/java/expo/modules/mobilewebshell/MobileWebInspectionPolicy.kt new file mode 100644 index 00000000000..9455c41ead9 --- /dev/null +++ b/mobile/packages/expo-mobile-web-shell/android/src/main/java/expo/modules/mobilewebshell/MobileWebInspectionPolicy.kt @@ -0,0 +1,15 @@ +package expo.modules.mobilewebshell + +import android.content.pm.ApplicationInfo + +/** + * DevTools also needs the OS debuggable flag, so a shipped production APK can never be opted in by Gradle alone. + */ +internal fun isMobileWebInspectionEnabled( + applicationFlags: Int, + isDebugBuild: Boolean = BuildConfig.DEBUG, + isInspectableRelease: Boolean = BuildConfig.ORCA_INSPECTABLE_RELEASE +): Boolean { + val isDebuggable = applicationFlags and ApplicationInfo.FLAG_DEBUGGABLE != 0 + return isDebuggable && (isDebugBuild || isInspectableRelease) +} diff --git a/mobile/packages/expo-mobile-web-shell/android/src/test/java/expo/modules/mobilewebshell/MobileWebInspectionPolicyTest.kt b/mobile/packages/expo-mobile-web-shell/android/src/test/java/expo/modules/mobilewebshell/MobileWebInspectionPolicyTest.kt new file mode 100644 index 00000000000..eda48ebb693 --- /dev/null +++ b/mobile/packages/expo-mobile-web-shell/android/src/test/java/expo/modules/mobilewebshell/MobileWebInspectionPolicyTest.kt @@ -0,0 +1,57 @@ +package expo.modules.mobilewebshell + +import android.content.pm.ApplicationInfo +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +private const val NON_DEBUGGABLE_FLAGS = ApplicationInfo.FLAG_ALLOW_BACKUP +private const val DEBUGGABLE_FLAGS = + ApplicationInfo.FLAG_ALLOW_BACKUP or ApplicationInfo.FLAG_DEBUGGABLE + +class MobileWebInspectionPolicyTest { + @Test + fun `keeps a production release uninspectable however it was built`() { + for (isInspectableRelease in listOf(false, true)) { + assertFalse( + isMobileWebInspectionEnabled( + NON_DEBUGGABLE_FLAGS, + isDebugBuild = false, + isInspectableRelease = isInspectableRelease + ) + ) + } + assertFalse( + isMobileWebInspectionEnabled( + NON_DEBUGGABLE_FLAGS, + isDebugBuild = true, + isInspectableRelease = false + ) + ) + } + + @Test + fun `enables devtools only for a debuggable debug or opted-in release build`() { + assertFalse( + isMobileWebInspectionEnabled( + DEBUGGABLE_FLAGS, + isDebugBuild = false, + isInspectableRelease = false + ) + ) + assertTrue( + isMobileWebInspectionEnabled( + DEBUGGABLE_FLAGS, + isDebugBuild = true, + isInspectableRelease = false + ) + ) + assertTrue( + isMobileWebInspectionEnabled( + DEBUGGABLE_FLAGS, + isDebugBuild = false, + isInspectableRelease = true + ) + ) + } +} diff --git a/mobile/plugins/android-inspectable-release.js b/mobile/plugins/android-inspectable-release.js new file mode 100644 index 00000000000..70b58b3f336 --- /dev/null +++ b/mobile/plugins/android-inspectable-release.js @@ -0,0 +1,26 @@ +const { withAppBuildGradle } = require('expo/config-plugins') + +// Why: android/ is generated and gitignored, so the opt-in has to be reapplied on every prebuild. +const RELEASE_BLOCK = ' release {\n' +const DEBUGGABLE_LINE = + ' // Dogfood opt-in: -PorcaInspectableRelease=true keeps the release bundle but marks the APK debuggable.\n' + + " debuggable = (findProperty('orcaInspectableRelease') ?: 'false').toBoolean()\n" + +function addInspectableReleaseOptIn(contents) { + if (contents.includes('orcaInspectableRelease')) { + return contents + } + if (!contents.includes(RELEASE_BLOCK)) { + throw new Error('android app/build.gradle has no release buildType block to make inspectable') + } + return contents.replace(RELEASE_BLOCK, RELEASE_BLOCK + DEBUGGABLE_LINE) +} + +module.exports = function withAndroidInspectableRelease(config) { + return withAppBuildGradle(config, (cfg) => { + cfg.modResults.contents = addInspectableReleaseOptIn(cfg.modResults.contents) + return cfg + }) +} + +module.exports.addInspectableReleaseOptIn = addInspectableReleaseOptIn diff --git a/mobile/src/mobile-web/hosted-android-inspectable-release.test.ts b/mobile/src/mobile-web/hosted-android-inspectable-release.test.ts new file mode 100644 index 00000000000..5ce95da44a7 --- /dev/null +++ b/mobile/src/mobile-web/hosted-android-inspectable-release.test.ts @@ -0,0 +1,80 @@ +import { readFileSync } from 'node:fs' +import { describe, expect, it } from 'vitest' +import { createRequire } from 'node:module' + +const { addInspectableReleaseOptIn } = createRequire(import.meta.url)( + '../../plugins/android-inspectable-release.js' +) + +const inspectionPolicySource = readFileSync( + new URL( + '../../packages/expo-mobile-web-shell/android/src/main/java/expo/modules/mobilewebshell/MobileWebInspectionPolicy.kt', + import.meta.url + ), + 'utf8' +) +const probeSource = readFileSync( + new URL( + '../../packages/expo-mobile-web-shell/android/src/main/java/expo/modules/mobilewebshell/MobileWebDebugIsolationProbe.kt', + import.meta.url + ), + 'utf8' +) +const shellGradleSource = readFileSync( + new URL('../../packages/expo-mobile-web-shell/android/build.gradle', import.meta.url), + 'utf8' +) + +const releaseGradle = [ + ' buildTypes {', + ' debug {', + ' signingConfig signingConfigs.debug', + ' }', + ' release {', + ' signingConfig signingConfigs.debug', + ' }', + ' }', + '' +].join('\n') + +describe('Android inspectable-release opt-in', () => { + it('still requires the OS debuggable flag before enabling DevTools', () => { + expect(inspectionPolicySource).toContain( + 'val isDebuggable = applicationFlags and ApplicationInfo.FLAG_DEBUGGABLE != 0' + ) + expect(inspectionPolicySource).toContain( + 'return isDebuggable && (isDebugBuild || isInspectableRelease)' + ) + expect(inspectionPolicySource).toContain('isDebugBuild: Boolean = BuildConfig.DEBUG') + expect(inspectionPolicySource).toContain( + 'isInspectableRelease: Boolean = BuildConfig.ORCA_INSPECTABLE_RELEASE' + ) + }) + + it('defaults the shell build config field to false', () => { + expect(shellGradleSource).toContain("buildConfigField 'boolean', 'ORCA_INSPECTABLE_RELEASE'") + expect(shellGradleSource).toContain( + "(findProperty('orcaInspectableRelease') ?: 'false').toBoolean().toString()" + ) + }) + + it('makes only the release variant debuggable, and only when the property is set', () => { + const patched = addInspectableReleaseOptIn(releaseGradle) + const releaseBlock = patched.slice(patched.indexOf(' release {')) + + expect(releaseBlock).toContain( + "debuggable = (findProperty('orcaInspectableRelease') ?: 'false').toBoolean()" + ) + expect(patched.slice(0, patched.indexOf(' release {'))).not.toContain('debuggable') + expect(addInspectableReleaseOptIn(patched)).toBe(patched) + }) + + it('fails loudly when prebuild stops emitting a release buildType', () => { + expect(() => addInspectableReleaseOptIn('android {\n}\n')).toThrow('no release buildType block') + }) + + it('keeps the loopback security probe out of an inspectable release', () => { + expect(probeSource).toContain('if (!BuildConfig.DEBUG || !isDebuggable) return null') + expect(probeSource).not.toContain('isMobileWebInspectionEnabled(applicationFlags)) return') + }) +}) diff --git a/mobile/src/mobile-web/hosted-webview-cdp-session.test.ts b/mobile/src/mobile-web/hosted-webview-cdp-session.test.ts index eb8f34deccb..463f91507fc 100644 --- a/mobile/src/mobile-web/hosted-webview-cdp-session.test.ts +++ b/mobile/src/mobile-web/hosted-webview-cdp-session.test.ts @@ -647,9 +647,10 @@ describe('hosted WebView CDP target selection', () => { it('keeps the Android probe debuggable-only and installs it at document start', () => { expect(androidProbeSource).toContain('BuildConfig.DEBUG') expect(androidProbeSource).toContain('ApplicationInfo.FLAG_DEBUGGABLE') - expect(androidProbeSource).toContain('val debuggingEnabled = BuildConfig.DEBUG && isDebuggable') - expect(androidProbeSource).toContain('WebView.setWebContentsDebuggingEnabled(debuggingEnabled)') - expect(androidProbeSource).toContain('if (!debuggingEnabled) return') + expect(androidProbeSource).toContain( + 'WebView.setWebContentsDebuggingEnabled(isMobileWebInspectionEnabled(applicationFlags))' + ) + expect(androidProbeSource).toContain('if (!BuildConfig.DEBUG || !isDebuggable) return null') expect(androidProbeSource).toContain('ORCA_E2E_MOBILE_WEB_NETWORK_PROBE_PORT') expect(androidProbeSource).toContain('ORCA_E2E_MOBILE_WEB_NETWORK_PROBE_TOKEN') expect(androidProbeSource).toContain('http://127.0.0.1:')