mirror of
https://github.com/stablyai/orca.git
synced 2026-09-29 08:03:20 +00:00
feat(mobile): opt-in inspectable Android release build for hosted WebView devtools
`-PorcaInspectableRelease=true` marks the release variant debuggable through an Expo config plugin and flips a build config field the shell's inspection policy reads. The OS debuggable flag stays a hard requirement, so a shipped production APK can never be inspected regardless of the Gradle property. Default builds are byte-for-byte unchanged. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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_<pid>` 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 "^<serial>\s+device$"; do sleep 1; done
|
||||
adb -s <serial> install -r mobile/android/app/build/outputs/apk/release/app-release.apk
|
||||
adb -s <serial> shell monkey -p com.stably.orca.mobile.ota 1
|
||||
```
|
||||
|
||||
Verify the OS agrees the package is debuggable:
|
||||
|
||||
```bash
|
||||
adb -s <serial> 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_<pid>` entry for Orca.
|
||||
|
||||
Android names the DevTools socket after the process id, so read it rather than
|
||||
guessing:
|
||||
|
||||
```bash
|
||||
adb -s <serial> shell cat /proc/net/unix | rg devtools_remote
|
||||
adb -s <serial> forward tcp:<port> localabstract:webview_devtools_remote_<pid>
|
||||
curl -s http://127.0.0.1:<port>/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://<session>.orca-mobile-web.invalid/#<session>`. Its `webSocketDebuggerUrl`
|
||||
is the CDP endpoint.
|
||||
|
||||
Attach either way:
|
||||
|
||||
- **Chrome**: open `chrome://inspect`, add `127.0.0.1:<port>` under *Discover network
|
||||
targets*, then click *inspect* on the hosted target.
|
||||
- **playwright-cli**: `playwright-cli attach --cdp http://127.0.0.1:<port>`.
|
||||
|
||||
Tear the forward down with `adb -s <serial> forward --remove tcp:<port>`.
|
||||
|
||||
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.
|
||||
@@ -80,6 +80,7 @@
|
||||
"plugins": [
|
||||
"expo-router",
|
||||
"./plugins/android-respect-rotation-lock.js",
|
||||
"./plugins/android-inspectable-release.js",
|
||||
[
|
||||
"expo-splash-screen",
|
||||
{
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+5
-5
@@ -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),
|
||||
|
||||
+15
@@ -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)
|
||||
}
|
||||
+57
@@ -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
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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:')
|
||||
|
||||
Reference in New Issue
Block a user