ReviewOS

also looking at this

craft-native/craft

fix(mobile): preserve native editing, navigation, and durable media requests

#369
Merged chris wants to merge codex/native-vertical-scroll-tab-swipes into main
36 files +864 -185

Review threads live on the whole diff, not on one commit, so none are shown here - a thread's line means something in the branch's final form, and painting it into an intermediate step would put it on code it is not about.

packages/android/src/emulator.tsmodified+7-0
Changes to packages/android/src/emulator.ts
@@ -36,6 +36,7 @@ export async function createAndroidEmulator(options: AndroidEmulatorOptions): Pr
3636 if (options.acceleration === 'software' && process.arch !== 'x64')
3737 throw new Error('Android software emulation requires an x86_64 host and system image')
3838 const bootTimeoutMs = options.bootTimeoutMs ?? 600_000
39 const requiresPlayStore = options.image.split(';')[2] === 'google_apis_playstore'
3940 const maximumBootTimeoutMs = options.acceleration === 'software' ? 1_200_000 : 600_000
4041 if (!Number.isSafeInteger(bootTimeoutMs) || bootTimeoutMs < 1000 || bootTimeoutMs > maximumBootTimeoutMs)
4142 throw new Error(`Android bootTimeoutMs must be an integer between 1000 and ${maximumBootTimeoutMs} milliseconds`)
@@ -159,6 +160,11 @@ export async function createAndroidEmulator(options: AndroidEmulatorOptions): Pr
159160 // boot_completed can precede SettingsProvider and System UI readiness.
160161 const settings = await run([adb, '-s', serial, 'shell', 'settings', 'get', 'global', 'animator_duration_scale'], undefined, probeTimeout(), true)
161162 const packages = await run([adb, '-s', serial, 'shell', 'pm', 'path', 'android'], undefined, probeTimeout(), true)
163 // A stock Play image can expose the framework package and HOME while
164 // first-boot provisioning is still registering its bundled apps.
165 const playStore = requiresPlayStore
166 ? await run([adb, '-s', serial, 'shell', 'pm', 'path', 'com.android.vending'], undefined, probeTimeout(), true)
167 : undefined
162168 const systemUi = await run([adb, '-s', serial, 'shell', 'pidof', 'com.android.systemui'], undefined, probeTimeout(), true)
163169 // Native acceptance must be able to observe platform crashes before
164170 // launching any app. boot_completed/System UI can precede logd's ADB
@@ -176,6 +182,7 @@ export async function createAndroidEmulator(options: AndroidEmulatorOptions): Pr
176182 || /^android\/.*(?:Resolver|Chooser)Activity$/.test(homeActivity)
177183 booted = settings.code === 0 && packages.code === 0 && systemUi.code === 0 && events.code === 0
178184 && /^package:\/\S+/m.test(packages.stdout) && /^\d+(?:\s+\d+)*$/.test(systemUi.stdout.trim())
185 && (!requiresPlayStore || (playStore?.code === 0 && /^package:\/\S+/m.test(playStore.stdout)))
179186 && home.code === 0 && /^[\w.]+\/[\w.$]+$/.test(homeActivity) && !temporaryHome
180187 }
181188 }
packages/typescript/src/__tests__/android-emulator.test.tsmodified+50-1
Changes to packages/typescript/src/__tests__/android-emulator.test.ts
@@ -5,7 +5,7 @@ import { join } from 'node:path'
55import { createAndroidEmulator } from '../../../android/src/emulator'
66import type { AndroidEmulatorOptions } from '../../../android/src/emulator'
77
8function fixture(hardware: boolean, delayedSettings = false, delayedEvents = false, delayedHome = false, connection: 'normal' | 'stale' | 'offline' = 'normal', bootstrapHome = 'com.android.sdksetup/.DefaultActivity'): { root: string, options: AndroidEmulatorOptions } {
8function fixture(hardware: boolean, delayedSettings = false, delayedEvents = false, delayedHome = false, connection: 'normal' | 'stale' | 'offline' = 'normal', bootstrapHome = 'com.android.sdksetup/.DefaultActivity', playStore: 'ready' | 'delayed' | 'missing' = 'ready'): { root: string, options: AndroidEmulatorOptions } {
99 const root = mkdtempSync(join(tmpdir(), 'craft emulator fixture '))
1010 const sdk = join(root, 'managed sdk')
1111 for (const directory of ['bin', 'platform-tools', 'emulator']) mkdirSync(join(sdk, directory), { recursive: true })
@@ -41,6 +41,13 @@ else
4141 fi
4242 printf '1\\n' ;;
4343 *'pm path android') printf 'package:/system/framework/framework-res.apk\\n' ;;
44 *'pm path com.android.vending')
45 if [ '${playStore}' = missing ]; then exit 1; fi
46 if [ '${playStore}' = delayed ] && [ ! -f "$ANDROID_HOME/play-ready" ]; then
47 touch "$ANDROID_HOME/play-ready"
48 exit 1
49 fi
50 printf 'package:/product/priv-app/Phonesky/Phonesky.apk\\n' ;;
4451 *'pidof com.android.systemui') printf '1234\\n' ;;
4552 *'cmd package resolve-activity --brief -a android.intent.action.MAIN -c android.intent.category.HOME')
4653 if [ '${delayedHome ? 'yes' : 'no'}' = yes ] && [ ! -f "$ANDROID_HOME/home-ready" ]; then
@@ -170,6 +177,48 @@ test('boot and System UI do not admit a device before its event log is readable'
170177 finally { await session?.dispose(); rmSync(root, { recursive: true, force: true }) }
171178}, 10_000)
172179
180test('a Play image waits for its bundled store after framework and HOME readiness', async () => {
181 const { root, options } = fixture(true, false, false, false, 'normal', 'com.android.sdksetup/.DefaultActivity', 'delayed')
182 let session: Awaited<ReturnType<typeof createAndroidEmulator>> | undefined
183 try {
184 session = await createAndroidEmulator(options)
185 const trace = readFileSync(join(options.artifactsDirectory, 'device-commands.jsonl'), 'utf8').trim().split('\n').map(line => JSON.parse(line))
186 const store = trace.filter(entry => entry.args.includes('com.android.vending'))
187 expect(store.map(entry => entry.code)).toEqual([1, 0])
188 const identity = JSON.parse(readFileSync(join(options.artifactsDirectory, 'android-emulator.json'), 'utf8'))
189 expect(Date.parse(store[1].finishedAt)).toBeLessThanOrEqual(Date.parse(identity.bootedAt))
190 expect(identity.bootTimeoutMs).toBe(600_000)
191 }
192 finally { await session?.dispose(); rmSync(root, { recursive: true, force: true }) }
193}, 10_000)
194
195test('a missing Play package fails the existing boot deadline and disposes its owned device', async () => {
196 const { root, options } = fixture(true, false, false, false, 'normal', 'com.android.sdksetup/.DefaultActivity', 'missing')
197 options.bootTimeoutMs = 5000
198 let session: Awaited<ReturnType<typeof createAndroidEmulator>> | undefined
199 try {
200 await expect((async () => { session = await createAndroidEmulator(options) })()).rejects.toThrow('did not boot within 5000ms')
201 const trace = readFileSync(join(options.artifactsDirectory, 'device-commands.jsonl'), 'utf8').trim().split('\n').map(line => JSON.parse(line))
202 expect(trace.filter(entry => entry.args.includes('com.android.vending')).length).toBeGreaterThan(0)
203 const identity = JSON.parse(readFileSync(join(options.artifactsDirectory, 'android-emulator.json'), 'utf8'))
204 expect(identity.status).toBe('failed')
205 expect(existsSync(identity.avdDirectory)).toBe(false)
206 expect(() => process.kill(identity.emulatorPid, 0)).toThrow()
207 }
208 finally { await session?.dispose(); rmSync(root, { recursive: true, force: true }) }
209}, 10_000)
210
211test('a non-Play image does not require an unrelated store package', async () => {
212 const { root, options } = fixture(true, false, false, false, 'normal', 'com.android.sdksetup/.DefaultActivity', 'missing')
213 options.image = options.image.replace(';google_apis_playstore;', ';google_apis;')
214 let session: Awaited<ReturnType<typeof createAndroidEmulator>> | undefined
215 try {
216 session = await createAndroidEmulator(options)
217 expect(readFileSync(join(options.sdkRoot, 'calls'), 'utf8')).not.toContain('com.android.vending')
218 }
219 finally { await session?.dispose(); rmSync(root, { recursive: true, force: true }) }
220}, 10_000)
221
173222test('boot completion waits for the automatic SDK setup activity to release HOME', async () => {
174223 const { root, options } = fixture(true, false, false, true)
175224 let session: Awaited<ReturnType<typeof createAndroidEmulator>> | undefined
scripts/verify-android-emulator.tsmodified+4-1
Changes to scripts/verify-android-emulator.ts
@@ -49,11 +49,14 @@ try {
4949 env: { JAVA_HOME: javaHome },
5050 })
5151 async function adb(args: string[]): Promise<string> {
52 const startedAt = new Date().toISOString()
5253 const child = Bun.spawn([session!.adb, '-s', session!.serial, ...args], {
5354 env: session!.env, stdout: 'pipe', stderr: 'pipe', signal: AbortSignal.timeout(30_000),
5455 })
5556 const [code, stdout, stderr] = await Promise.all([child.exited, new Response(child.stdout).text(), new Response(child.stderr).text()])
56 if (code !== 0) throw new Error(`ADB verification failed: ${args.join(' ')}: ${stderr}`)
57 const commands = (proof.adbCommands ??= []) as unknown[]
58 commands.push({ args, code, stdout, stderr, startedAt, finishedAt: new Date().toISOString() })
59 if (code !== 0) throw new Error(`ADB verification failed (exit ${code}): ${args.join(' ')}: stdout=${JSON.stringify(stdout)} stderr=${JSON.stringify(stderr)}`)
5760 return stdout.trim()
5861 }
5962 const api = await adb(['shell', 'getprop', 'ro.build.version.sdk'])