ReviewOS

also looking at this

craft-native/craft

fix(android): resolve share() with whether the person picked an app

#210
Merged glennmichael123 wants to merge fix/android-share-outcome into main
12 files +399 -56

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.

.github/workflows/README.mdmodified+8-2
Changes to .github/workflows/README.md
@@ -49,10 +49,16 @@ bun run test:mobile-e2e:android
4949```
5050
5151```
52ok android-shim — 4/4 cases, no zig
53ok android-runtime — 4/4 cases, zig served registered:103, declines:0
52ok android-shim — 6/6 cases, no zig
53ok android-runtime — 6/6 cases, zig served registered:103, declines:0
5454```
5555
56One Android case needs a person: `share.dismissed.resolvesFalse` opens the
57real share menu and expects `false` once it is dismissed. The harness plays
58that part. It waits until `dumpsys window` shows the chooser holding input
59focus, saves `share-menu.png`, presses Back, and fails the run if the page asked
60for a dismissal and no menu ever appeared.
61
5662The runtime leg fails on any line where a Zig native gave up — "fell through
5763to the shim", "failed with no fallback" or "could not reach the page" — because
5864Kotlin answering in its place is invisible to the page, and a runtime that has
scripts/mobile-e2e/android.tsmodified+30-1
Changes to scripts/mobile-e2e/android.ts
@@ -2,7 +2,7 @@ import type { LegOutcome, RunnerOptions } from './types'
22import { copyFileSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
33import { join } from 'node:path'
44import { init } from '../../packages/android/src/index'
5import { androidDeclines, evaluateRun, hasTerminated } from './protocol'
5import { androidDeclines, awaitedNeeds, DISMISS_SHARE_MENU, evaluateRun, hasTerminated, shareMenuInFront } from './protocol'
66import { command, driverPage, waitForFile } from './support'
77
88/**
@@ -149,6 +149,24 @@ async function runLeg(leg: Leg, options: RunnerOptions): Promise<LegOutcome> {
149149 await adb(['shell', 'am', 'force-stop', PACKAGE], { serial, allowFailure: true })
150150 await adb(['shell', 'am', 'start', '-n', `${PACKAGE}/.MainActivity`], { serial, logPath: join(evidence, 'adb.log') })
151151
152 // The share case needs its menu dismissed, and nothing on the device will
153 // do that: the harness plays the person. It acts only once the page has
154 // asked and the menu is really in front, and it keeps a screenshot of the
155 // menu, which is the evidence that there was one to dismiss.
156 let shareMenu: 'not asked' | 'asked' | 'dismissed' = 'not asked'
157
158 async function dismissShareMenuWhenAsked(log: string): Promise<void> {
159 if (shareMenu === 'dismissed' || !awaitedNeeds(log).includes(DISMISS_SHARE_MENU)) return
160 shareMenu = 'asked'
161
162 const windows = await command(['adb', '-s', serial, 'shell', 'dumpsys', 'window'], { allowFailure: true })
163 if (!shareMenuInFront(windows.stdout)) return
164
165 await command(['adb', '-s', serial, 'exec-out', 'screencap', '-p'], { allowFailure: true, outPath: join(evidence, 'share-menu.png') })
166 await adb(['shell', 'input', 'keyevent', 'KEYCODE_BACK'], { serial, logPath: join(evidence, 'adb.log') })
167 shareMenu = 'dismissed'
168 }
169
152170 // logcat -d dumps and exits, so poll it into the evidence file rather than
153171 // streaming: a stream would have to be killed at exactly the right moment,
154172 // and the dump is cheap.
@@ -156,6 +174,7 @@ async function runLeg(leg: Leg, options: RunnerOptions): Promise<LegOutcome> {
156174 const finished = await waitForFile(logPath, options.timeoutMs, hasTerminated, async () => {
157175 const dumped = await command(['adb', '-s', serial, 'logcat', '-d', ...LOGCAT_FILTER], { allowFailure: true })
158176 writeFileSync(logPath, dumped.stdout)
177 await dismissShareMenuWhenAsked(dumped.stdout)
159178 })
160179
161180 await command(['adb', '-s', serial, 'exec-out', 'screencap', '-p'], { allowFailure: true, outPath: join(evidence, 'screen.png') })
@@ -170,6 +189,16 @@ async function runLeg(leg: Leg, options: RunnerOptions): Promise<LegOutcome> {
170189 const verdict = evaluateRun('android', logText, nonce)
171190 failures.push(...verdict.failures)
172191
192 // The page's case only sees that share resolved false. That is also what a
193 // share that never opened a menu at all would resolve, so the harness says
194 // whether there was a menu to dismiss.
195 if (verdict.planned.includes('share.dismissed.resolvesFalse')) {
196 if (shareMenu === 'not asked')
197 failures.push('the page never asked for the share menu to be dismissed, so the dismissal case did not reach the menu')
198 else if (shareMenu === 'asked')
199 failures.push('the page asked for the share menu to be dismissed, and no share menu ever came to the front')
200 }
201
173202 // The page reported the SDK level the bridge told it; the host asked the
174203 // device directly. Agreement means the value crossed the bridge rather than
175204 // being invented in JavaScript - this leg's substitute for the pasteboard
scripts/mobile-e2e/driver.htmlmodified+39-2
Changes to scripts/mobile-e2e/driver.html
@@ -64,14 +64,14 @@
6464 return { code: String(error.code || ''), message: String(error.message || error) }
6565 }
6666
67 function withTimeout(run) {
67 function withTimeout(run, timeoutMs) {
6868 return new Promise(function (resolve) {
6969 var settled = false
7070 var timer = setTimeout(function () {
7171 if (settled) return
7272 settled = true
7373 resolve({ state: 'timeout' })
74 }, CASE_TIMEOUT_MS)
74 }, timeoutMs || CASE_TIMEOUT_MS)
7575
7676 function finish(value) {
7777 if (settled) return
@@ -198,6 +198,43 @@
198198 })
199199 }
200200 },
201 {
202 // Android does not gate sharing on a flag, so its refusal is the one
203 // both platforms share: an empty string is nothing to share. It is
204 // refused before any menu opens, and used to be refused by nobody -
205 // the call returned undefined whatever happened (#203).
206 name: 'share.empty.rejects',
207 platforms: ['android'],
208 run: function () {
209 return withTimeout(function () { return window.craft.share('') }).then(function (outcome) {
210 if (outcome.state === 'timeout') return 'share never settled'
211 if (outcome.state === 'resolved') return 'share resolved with ' + JSON.stringify(outcome.value) + ' for an empty string'
212 if (outcome.error.message.indexOf('Nothing to share') === -1)
213 return 'message is ' + JSON.stringify(outcome.error.message)
214 })
215 }
216 },
217 {
218 // The cancellation path, and the one case that needs a person. The
219 // page announces that it is about to open the share menu, the harness
220 // waits until the menu is really in front, screenshots it and presses
221 // Back, and the promise has to resolve false. False, not a rejection:
222 // dismissing the menu is a choice the person made, not an error.
223 //
224 // A share that resolved false on its own would pass this without a
225 // menu ever appearing, which is why the harness also fails the run
226 // when it was asked to dismiss a menu and never saw one.
227 name: 'share.dismissed.resolvesFalse',
228 platforms: ['android'],
229 run: function () {
230 emit({ event: 'awaiting', name: 'share.dismissed.resolvesFalse', need: 'dismiss-share-menu' })
231 return withTimeout(function () { return window.craft.share('craft e2e ' + NONCE, 'Craft E2E') }, 60000).then(function (outcome) {
232 if (outcome.state === 'timeout') return 'share never settled after the menu was dismissed'
233 if (outcome.state === 'rejected') return 'share rejected: ' + outcome.error.message
234 if (outcome.value !== false) return 'share resolved with ' + JSON.stringify(outcome.value) + ', expected false for a dismissed menu'
235 })
236 }
237 },
201238 {
202239 // Android's equivalent gate. With push disabled the generator emits a
203240 // literal reject call rather than a Firebase block, so the rejection
scripts/mobile-e2e/protocol.test.tsmodified+37-1
Changes to scripts/mobile-e2e/protocol.test.ts
@@ -1,7 +1,7 @@
11import { describe, expect, it } from 'bun:test'
22import { readFileSync } from 'node:fs'
33import { join } from 'node:path'
4import { ANDROID_DECLINE_PHRASES, androidDeclines, evaluateRun, hasTerminated, parseDriverOutput, REQUIRED_CASES, requiredCaseProblems, ZIG_REFUSED_ACTIONS, ZIG_TESTED_ACTIONS, zigDispatchedActions, zigRefusals } from './protocol'
4import { ANDROID_DECLINE_PHRASES, androidDeclines, awaitedNeeds, DISMISS_SHARE_MENU, evaluateRun, hasTerminated, parseDriverOutput, REQUIRED_CASES, requiredCaseProblems, shareMenuInFront, ZIG_REFUSED_ACTIONS, ZIG_TESTED_ACTIONS, zigDispatchedActions, zigRefusals } from './protocol'
55
66const ESC = String.fromCharCode(27)
77
@@ -283,6 +283,42 @@ describe('zig attribution', () => {
283283 })
284284})
285285
286describe('share menu', () => {
287 it('reads what the page is waiting for, once, from either log channel', () => {
288 const awaiting = JSON.stringify({ event: 'awaiting', name: 'share.dismissed.resolvesFalse', need: DISMISS_SHARE_MENU })
289 const text = [
290 `09-16 11:29:25.000 2611 2611 I CraftBridge: CRAFT-E2E ${awaiting}`,
291 `09-16 11:29:25.001 2611 2611 I chromium: [INFO:CONSOLE(1)] "CRAFT-E2E ${awaiting}", source: https://appassets.androidplatform.net/ (1)`,
292 ].join('\n')
293
294 expect(awaitedNeeds(text)).toEqual([DISMISS_SHARE_MENU])
295 expect(awaitedNeeds('CRAFT-E2E {"event":"case","name":"a","status":"pass"}')).toEqual([])
296 })
297
298 // Both spellings the chooser has had, as `dumpsys window` prints them.
299 it('sees the menu holding focus on Android 14 and on 13', () => {
300 expect(shareMenuInFront([
301 ' mCurrentFocus=Window{56c630c u0 com.android.intentresolver/com.android.intentresolver.ChooserActivity}',
302 ' mFocusedApp=ActivityRecord{144007559 u0 com.android.intentresolver/.ChooserActivity t7}',
303 ].join('\n'))).toBe(true)
304
305 expect(shareMenuInFront(
306 ' mCurrentFocus=Window{2b7a1f u0 android/com.android.internal.app.ChooserActivity}',
307 )).toBe(true)
308 })
309
310 it('does not see a menu that is resumed but not yet focused', () => {
311 // The gap the first CI run fell into. The activity manager already calls
312 // the chooser the focused app while the probe's window still has input
313 // focus, so Back here would close the app rather than the menu.
314 const dumpsys = [
315 ' mCurrentFocus=Window{77aa u0 dev.craft.e2e.probe/dev.craft.e2e.probe.MainActivity}',
316 ' mFocusedApp=ActivityRecord{144007559 u0 com.android.intentresolver/.ChooserActivity t7}',
317 ].join('\n')
318 expect(shareMenuInFront(dumpsys)).toBe(false)
319 })
320})
321
286322describe('android declines', () => {
287323 // The lines the first Android runtime run actually produced, plus the two
288324 // decline kinds it did not hit, so all three are read the way they are
scripts/mobile-e2e/protocol.tsmodified+51-1
Changes to scripts/mobile-e2e/protocol.ts
@@ -43,7 +43,19 @@ export interface ObservedEvent {
4343 value: unknown
4444}
4545
46export type DriverEvent = PlanEvent | CaseEvent | DoneEvent | FatalEvent | ObservedEvent
46/**
47 * The page asking the harness to do what a person would. Emitted just before
48 * the call that needs it, because the page cannot tap the device itself.
49 */
50export interface AwaitingEvent {
51 event: 'awaiting'
52 /** The case that is waiting. */
53 name: string
54 /** What it is waiting for, one of the needs the harness knows how to meet. */
55 need: string
56}
57
58export type DriverEvent = PlanEvent | CaseEvent | DoneEvent | FatalEvent | ObservedEvent | AwaitingEvent
4759
4860export type MobilePlatform = 'ios' | 'android'
4961
@@ -74,6 +86,8 @@ export const REQUIRED_CASES: Record<MobilePlatform, string[]> = {
7486 'deviceInfo.isEmulator',
7587 'clipboard.roundTrip',
7688 'push.disabled.rejects',
89 'share.empty.rejects',
90 'share.dismissed.resolvesFalse',
7791 ],
7892}
7993
@@ -303,6 +317,42 @@ export function evaluateRun(platform: MobilePlatform, text: string, expectedRun?
303317 return { ok: failures.length === 0, failures, planned, passed, failed }
304318}
305319
320/** The need the Android share case announces before it opens the menu. */
321export const DISMISS_SHARE_MENU = 'dismiss-share-menu'
322
323/** The needs the page has announced so far, in order, without repeats. */
324export function awaitedNeeds(text: string): string[] {
325 const needs: string[] = []
326 for (const event of parseDriverOutput(text).events) {
327 if (event.event === 'awaiting' && !needs.includes(event.need))
328 needs.push(event.need)
329 }
330 return needs
331}
332
333/**
334 * Whether Android's share menu holds input focus, read from
335 * `adb shell dumpsys window`.
336 *
337 * The harness presses Back only once this is true, because Back goes to the
338 * focused window. Focus rather than the activity being *resumed*, which is
339 * what this first checked: the activity manager marks the chooser resumed
340 * before its process has even created it. On the first CI run the chooser was
341 * resumed at 50.37s and took focus at 52.63s. Back pressed in that gap goes to
342 * the app, which closes, and the page never reports anything. That run passed
343 * only because the harness happened to poll late.
344 *
345 * Matched by class name rather than by package, because the chooser moved:
346 * `android/com.android.internal.app.ChooserActivity` up to Android 13, and
347 * `com.android.intentresolver/com.android.intentresolver.ChooserActivity` from
348 * 14.
349 */
350export function shareMenuInFront(dumpsysWindow: string): boolean {
351 return dumpsysWindow
352 .split('\n')
353 .some(line => /mCurrentFocus=/.test(line) && /ChooserActivity/.test(line))
354}
355
306356/**
307357 * The actions the Zig dispatcher saw, by name.
308358 *