also looking at this
fix(mobile): preserve native editing, navigation, and durable media requests
#369
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.
| @@ -36,6 +36,7 @@ export async function createAndroidEmulator(options: AndroidEmulatorOptions): Pr | ||
| 36 | 36 | if (options.acceleration === 'software' && process.arch !== 'x64') |
| 37 | 37 | throw new Error('Android software emulation requires an x86_64 host and system image') |
| 38 | 38 | const bootTimeoutMs = options.bootTimeoutMs ?? 600_000 |
| 39 | const requiresPlayStore = options.image.split(';')[2] === 'google_apis_playstore' | |
| 39 | 40 | const maximumBootTimeoutMs = options.acceleration === 'software' ? 1_200_000 : 600_000 |
| 40 | 41 | if (!Number.isSafeInteger(bootTimeoutMs) || bootTimeoutMs < 1000 || bootTimeoutMs > maximumBootTimeoutMs) |
| 41 | 42 | 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 | ||
| 159 | 160 | // boot_completed can precede SettingsProvider and System UI readiness. |
| 160 | 161 | const settings = await run([adb, '-s', serial, 'shell', 'settings', 'get', 'global', 'animator_duration_scale'], undefined, probeTimeout(), true) |
| 161 | 162 | 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 | |
| 162 | 168 | const systemUi = await run([adb, '-s', serial, 'shell', 'pidof', 'com.android.systemui'], undefined, probeTimeout(), true) |
| 163 | 169 | // Native acceptance must be able to observe platform crashes before |
| 164 | 170 | // launching any app. boot_completed/System UI can precede logd's ADB |
| @@ -176,6 +182,7 @@ export async function createAndroidEmulator(options: AndroidEmulatorOptions): Pr | ||
| 176 | 182 | || /^android\/.*(?:Resolver|Chooser)Activity$/.test(homeActivity) |
| 177 | 183 | booted = settings.code === 0 && packages.code === 0 && systemUi.code === 0 && events.code === 0 |
| 178 | 184 | && /^package:\/\S+/m.test(packages.stdout) && /^\d+(?:\s+\d+)*$/.test(systemUi.stdout.trim()) |
| 185 | && (!requiresPlayStore || (playStore?.code === 0 && /^package:\/\S+/m.test(playStore.stdout))) | |
| 179 | 186 | && home.code === 0 && /^[\w.]+\/[\w.$]+$/.test(homeActivity) && !temporaryHome |
| 180 | 187 | } |
| 181 | 188 | } |
| @@ -5,7 +5,7 @@ import { join } from 'node:path' | ||
| 5 | 5 | import { createAndroidEmulator } from '../../../android/src/emulator' |
| 6 | 6 | import type { AndroidEmulatorOptions } from '../../../android/src/emulator' |
| 7 | 7 | |
| 8 | function fixture(hardware: boolean, delayedSettings = false, delayedEvents = false, delayedHome = false, connection: 'normal' | 'stale' | 'offline' = 'normal', bootstrapHome = 'com.android.sdksetup/.DefaultActivity'): { root: string, options: AndroidEmulatorOptions } { | |
| 8 | function 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 } { | |
| 9 | 9 | const root = mkdtempSync(join(tmpdir(), 'craft emulator fixture ')) |
| 10 | 10 | const sdk = join(root, 'managed sdk') |
| 11 | 11 | for (const directory of ['bin', 'platform-tools', 'emulator']) mkdirSync(join(sdk, directory), { recursive: true }) |
| @@ -41,6 +41,13 @@ else | ||
| 41 | 41 | fi |
| 42 | 42 | printf '1\\n' ;; |
| 43 | 43 | *'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' ;; | |
| 44 | 51 | *'pidof com.android.systemui') printf '1234\\n' ;; |
| 45 | 52 | *'cmd package resolve-activity --brief -a android.intent.action.MAIN -c android.intent.category.HOME') |
| 46 | 53 | 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' | ||
| 170 | 177 | finally { await session?.dispose(); rmSync(root, { recursive: true, force: true }) } |
| 171 | 178 | }, 10_000) |
| 172 | 179 | |
| 180 | test('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 | ||
| 195 | test('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 | ||
| 211 | test('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 | ||
| 173 | 222 | test('boot completion waits for the automatic SDK setup activity to release HOME', async () => { |
| 174 | 223 | const { root, options } = fixture(true, false, false, true) |
| 175 | 224 | let session: Awaited<ReturnType<typeof createAndroidEmulator>> | undefined |
| @@ -49,11 +49,14 @@ try { | ||
| 49 | 49 | env: { JAVA_HOME: javaHome }, |
| 50 | 50 | }) |
| 51 | 51 | async function adb(args: string[]): Promise<string> { |
| 52 | const startedAt = new Date().toISOString() | |
| 52 | 53 | const child = Bun.spawn([session!.adb, '-s', session!.serial, ...args], { |
| 53 | 54 | env: session!.env, stdout: 'pipe', stderr: 'pipe', signal: AbortSignal.timeout(30_000), |
| 54 | 55 | }) |
| 55 | 56 | 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)}`) | |
| 57 | 60 | return stdout.trim() |
| 58 | 61 | } |
| 59 | 62 | const api = await adb(['shell', 'getprop', 'ro.build.version.sdk']) |