From 491eb35b4dc264456ce3a8f390169147e05e4485 Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Mon, 21 Sep 2026 14:18:57 -0400 Subject: [PATCH] feat(mobile): delete the page's wake-lock seam (OTA phase C, ruling 36) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The page never names the screen now. `native.wakelock.set` is gone with its schemas, its shell server, its grant rows and its harness entry; so are the page's keep-awake owner, the Android foreground re-acquire, and the `DictationKeepAwakeDevice` the capture contract carried. One module holds the screen — the device calls the microphone's capture makes — and both device-side captures share its one tag, because there is one microphone. Deleted: native-wakelock.ts (120), native-wakelock.test.ts (140), mobile-dictation-keep-awake.ts (248), mobile-dictation-keep-awake.test.ts (440), mobile-dictation-foreground-keep-awake.ts (78). With the tag pools gone, the desktop-start flow has one stale check instead of two, no startup budget to wait out and nothing to release. A source-scanning census pins it: no module under mobile/src or mobile/app but the one owner imports expo-keep-awake, and nothing anywhere names the retired verb. It reports the file and line, and checks the owner does import the package so the absence is the rule holding and not the match missing. KNOWN RED, reported and not recorded over: 25 golden cases in the speech.* families fail. The recorder adapter had to drop its keep-awake owner, which moves `adapterSha256` for every golden that mounts it, and the deleted owner's id minting shifts the deterministic random sequence, so the recorded `dictationId` values move too. No speech.dictation.* param, reply or operation changed. Awaiting the lead's call on a scoped re-record. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb --- ...web-app-session-dictation-capture.test.mjs | 39 +- ...-web-app-session-terminal-closure.test.mjs | 18 +- .../mobile-dictation-desktop-start.test.ts | 113 +---- .../hooks/mobile-dictation-desktop-start.ts | 63 +-- .../mobile-dictation-foreground-keep-awake.ts | 78 ---- .../hooks/mobile-dictation-keep-awake.test.ts | 440 ------------------ .../src/hooks/mobile-dictation-keep-awake.ts | 248 ---------- .../hooks/mobile-dictation-session-state.ts | 4 - .../hooks/mobile-dictation-stop-tail.test.tsx | 3 +- .../hooks/use-mobile-dictation-source.test.ts | 182 +------- mobile/src/hooks/use-mobile-dictation.ts | 57 +-- .../mobile-web-shell/bridge-host-init.test.ts | 3 +- .../bridge/bridge-audio-verbs.test.ts | 193 +------- .../bridge/bridge-audio-verbs.ts | 28 +- .../bridge/bridge-native-verbs.test.ts | 3 +- .../bridge/bridge-native-verbs.ts | 14 +- .../bridge/bridge-port-pair-test-harness.ts | 4 +- .../bridge/use-native-verbs.test.tsx | 1 - .../bridge/use-native-verbs.ts | 8 +- .../page-route-policy.test.ts | 3 +- .../dictation-capture-bridge-budget.test.tsx | 3 +- .../platform/dictation-capture-contract.ts | 39 +- mobile/src/platform/dictation-capture.ts | 16 +- .../platform/dictation-capture.web.test.tsx | 47 +- mobile/src/platform/dictation-capture.web.ts | 10 +- .../microphone-screen-lock-census.test.ts | 104 +++++ mobile/src/platform/native-audio-device.ts | 27 +- mobile/src/platform/native-audio.ts | 10 +- mobile/src/platform/native-wakelock.test.ts | 140 ------ mobile/src/platform/native-wakelock.ts | 120 ----- .../platform/use-native-device-verbs.test.tsx | 34 +- .../src/platform/use-native-device-verbs.ts | 16 +- .../mobile-dictation-mic-control.web.test.tsx | 19 +- .../adapters/dictation-mount-adapters.ts | 24 +- mobile/web-entry/web-overrides.json | 2 +- 35 files changed, 299 insertions(+), 1814 deletions(-) delete mode 100644 mobile/src/hooks/mobile-dictation-foreground-keep-awake.ts delete mode 100644 mobile/src/hooks/mobile-dictation-keep-awake.test.ts delete mode 100644 mobile/src/hooks/mobile-dictation-keep-awake.ts create mode 100644 mobile/src/platform/microphone-screen-lock-census.test.ts delete mode 100644 mobile/src/platform/native-wakelock.test.ts delete mode 100644 mobile/src/platform/native-wakelock.ts diff --git a/config/scripts/mobile-web-app-session-dictation-capture.test.mjs b/config/scripts/mobile-web-app-session-dictation-capture.test.mjs index cd6fb84870f..d3124e9a2e2 100644 --- a/config/scripts/mobile-web-app-session-dictation-capture.test.mjs +++ b/config/scripts/mobile-web-app-session-dictation-capture.test.mjs @@ -1,6 +1,6 @@ /** * Which route closures reach dictation's capture seam, and therefore which routes must be granted - * the four audio verbs. + * the three audio verbs. * * A census rather than a hand list, because a grant row written by hand is a row that stops * agreeing with the closure the moment a screen moves: the rule below reads what each registered @@ -10,16 +10,15 @@ * shows the rule failing without the four names. * * The closure also says what the seam took out of the page. Without its web half the bundler - * resolves the native one, and the vendored `@orca/expo-two-way-audio` web stub lands in the - * closure along with `expo-keep-awake` — which is what dictation on the page used to be: a module - * answering denied microphone permission, and a wake lock that did nothing. + * resolves the native one, and with it the device module that owns the microphone: the vendored + * `@orca/expo-two-way-audio` web stub lands in the closure along with `expo-keep-awake` — which is + * what dictation on the page used to be: a module answering denied microphone permission, and a + * wake lock that did nothing. * - * Measured on this tree by moving `dictation-capture.web.ts` aside and walking the closure again: - * `modules` 4,319 to 4,326 and `local` 977 to 976. Eight vendored modules re-enter — five from - * `@orca/expo-two-way-audio` and three from `expo-keep-awake` — less the one local file that left, - * which is the +7. The eight is the number below; the absolute counts are provenance and are not - * asserted, because every merge of main moves them and a census that pinned them would fail for - * reasons that are nobody's. + * Eight vendored modules re-enter that way, five from `@orca/expo-two-way-audio` and three from + * `expo-keep-awake`. That eight is the number below; absolute module counts are not asserted, + * because every merge of main moves them and a census that pinned them would fail for reasons that + * are nobody's. * * So "absent" here is a fact about the seam and not about the census failing to look, and the * precondition is checked rather than assumed: both package names are resolved from the install, so @@ -46,15 +45,10 @@ const NATIVE_SEAM = 'src/platform/dictation-capture.ts' /** Every verb the seam calls. Named here so the rule below is the census's own answer and not a * second list to keep true; `bridge-audio-verbs.test.ts` pins them against the verb table. */ -const DICTATION_GRANTS = [ - 'native.audio.start', - 'native.audio.read', - 'native.audio.stop', - 'native.wakelock.set' -] +const DICTATION_GRANTS = ['native.audio.start', 'native.audio.read', 'native.audio.stop'] /** Native modules the seam exists to keep out: importing either reaches a JSI binding, and their - * web builds are a denied microphone and a no-op wake lock. */ + * web builds are a denied microphone and a no-op screen lock. */ const NATIVE_AUDIO_MODULES = ['@orca/expo-two-way-audio', 'expo-keep-awake'] /** @@ -99,9 +93,8 @@ function routeModule(pathname) { return last === '[hostId]' ? `app/${withoutRoot}/index.tsx` : `app/${withoutRoot}.tsx` } -/** The grants a closure needs of the seam: all four, or none. A route granted three would record - * with the screen free to lock, and a lock mid-processing suspends the app and loses the - * transcript. */ +/** The grants a closure needs of the seam: all three, or none. A route granted two would open a + * microphone it could not drain or could not stop. */ function dictationGrantsNeeded(closure) { return closure.local.includes(SEAM) ? DICTATION_GRANTS : [] } @@ -190,7 +183,7 @@ describeClosure( // The hook above the seam is still in the closure, so the absences above are the seam's work // and not dictation having left the page. expect(closure.local).toContain('src/hooks/use-mobile-dictation.ts') - expect(closure.local).toContain('src/hooks/mobile-dictation-keep-awake.ts') + expect(closure.local).toContain('src/hooks/mobile-dictation-audio-chunk.ts') }) it('is big enough that finding nothing would mean something', async () => { @@ -216,7 +209,7 @@ describe('the census rule itself', () => { ]) }) - it('asks for all four grants or none, never a subset', () => { + it('asks for all three grants or none, never a subset', () => { expect(dictationGrantsNeeded({ local: [SEAM] })).toEqual(DICTATION_GRANTS) expect(dictationGrantsNeeded({ local: ['src/platform/media-picker.web.ts'] })).toEqual([]) }) @@ -244,7 +237,7 @@ describe('the census rule itself', () => { // holds routes to, none of which the shell has a row for. const { BRIDGE_NATIVE_VERB_NAMES } = await import('../../mobile/src/mobile-web-shell/bridge/bridge-native-verbs.ts') - expect(new Set(DICTATION_GRANTS).size).toBe(4) + expect(new Set(DICTATION_GRANTS).size).toBe(3) for (const grant of DICTATION_GRANTS) { expect(BRIDGE_NATIVE_VERB_NAMES, grant).toContain(grant) expect( diff --git a/config/scripts/mobile-web-app-session-terminal-closure.test.mjs b/config/scripts/mobile-web-app-session-terminal-closure.test.mjs index c64aecce9ba..50a343ccc00 100644 --- a/config/scripts/mobile-web-app-session-terminal-closure.test.mjs +++ b/config/scripts/mobile-web-app-session-terminal-closure.test.mjs @@ -177,14 +177,24 @@ const MERMAID_PACKAGE = 'node_modules/mermaid/' * `src/mobile-web-shell/bridge/bridge-audio-verbs.ts` — and eight vendored ones leave, because the * capture seam is what stops the page importing a microphone it does not have. Five are * `@orca/expo-two-way-audio` (its web module, `core`, `events`, `hooks` and the index) and three - * are `expo-keep-awake`; the page asks the shell for both over `native.audio.start|read|stop` and - * `native.wakelock.set` instead. The native halves of the seam resolve out of this closure - * entirely, which is the -8 + 3. + * are `expo-keep-awake`; the page asks the shell for the microphone over + * `native.audio.start|read|stop` instead, and never asks about the screen at all — an open mic + * holds it on the device side. The native halves of the seam resolve out of this closure entirely, + * which is the -8 + 3. * * Measured, not derived: `mobile-web-app-session-dictation-capture.test.mjs` moves the web file * aside and walks the closure again, which puts those eight back. + * + * Then ruling 36 gave the screen to the microphone, and two more local modules left: + * + * modules 4323 -> 4321 (-2) + * + * `src/hooks/mobile-dictation-keep-awake.ts` and + * `src/hooks/mobile-dictation-foreground-keep-awake.ts` were the page's wake-tag owner and its + * Android foreground re-acquire. Both are deleted, not moved: the device module that opens the + * microphone takes the screen and gives it back, so the page has nothing left to own. */ -const SESSION_ROUTE_MODULES = 4323 +const SESSION_ROUTE_MODULES = 4321 const artifactModules = (inputs) => inputs.filter((input) => input.includes(MERMAID_PAGE_ENGINE)) const packageModules = (inputs) => inputs.filter((input) => input.includes(MERMAID_PACKAGE)) diff --git a/mobile/src/hooks/mobile-dictation-desktop-start.test.ts b/mobile/src/hooks/mobile-dictation-desktop-start.test.ts index bdd0b69d963..ecdbe0844c4 100644 --- a/mobile/src/hooks/mobile-dictation-desktop-start.test.ts +++ b/mobile/src/hooks/mobile-dictation-desktop-start.test.ts @@ -1,14 +1,16 @@ +/** + * The desktop half of a dictation start: open the session, check the start is still the current + * one, commit recording. The screen is not here — an open microphone holds it on the device side, + * which is what left this flow with one stale check instead of two and nothing to release. + */ import { describe, expect, it, vi } from 'vitest' -import { MOBILE_DICTATION_KEEP_AWAKE_STARTUP_BUDGET_MS } from './mobile-dictation-session-state' import { startMobileDictationDesktopSession } from './mobile-dictation-desktop-start' -import type { MobileDictationKeepAwakeOwner } from './mobile-dictation-keep-awake' import type { RpcClient } from '../transport/rpc-client' const OK_RESPONSE = { ok: true, result: {} } as const type StartHarnessOptions = { sendRequest?: (method: string) => Promise - acquire?: () => Promise commitRecordingStart?: () => boolean } @@ -17,17 +19,12 @@ function createStartHarness(options: StartHarnessOptions = {}) { let enabled = true let activeId: string | null = 'dictation-a' const setIdle = vi.fn() - const release = vi.fn().mockResolvedValue(undefined) const commitRecordingStart = vi.fn(options.commitRecordingStart ?? (() => true)) const rollbackRecordingStart = vi.fn() const sendRequest = vi.fn( options.sendRequest ?? (async () => OK_RESPONSE) ) as unknown as RpcClient['sendRequest'] const client = { sendRequest } as RpcClient - const keepAwakeOwner = { - acquire: vi.fn(options.acquire ?? (async () => undefined)), - release - } as unknown as MobileDictationKeepAwakeOwner return { options: { @@ -43,7 +40,6 @@ function createStartHarness(options: StartHarnessOptions = {}) { } }, setIdle, - keepAwakeOwner, commitRecordingStart, rollbackRecordingStart }, @@ -56,7 +52,6 @@ function createStartHarness(options: StartHarnessOptions = {}) { }, getActiveId: () => activeId, setIdle, - release, sendRequest, commitRecordingStart, rollbackRecordingStart @@ -64,55 +59,38 @@ function createStartHarness(options: StartHarnessOptions = {}) { } describe('startMobileDictationDesktopSession', () => { - it('does not reset UI state when a newer start supersedes keep-awake acquisition', async () => { + it('cancels a start a newer one superseded while the desktop session opened', async () => { let setNewerStart = () => undefined const harness = createStartHarness({ - acquire: async () => setNewerStart() + sendRequest: async (method) => { + if (method === 'speech.dictation.start') { + setNewerStart() + } + return OK_RESPONSE + } }) setNewerStart = harness.setNewerStart await expect(startMobileDictationDesktopSession(harness.options)).resolves.toBe(false) + // The replacement owns the screen now, and this one must not reset the UI out from under it. expect(harness.setIdle).not.toHaveBeenCalled() expect(harness.getActiveId()).toBe('dictation-b') - expect(harness.release).toHaveBeenCalledWith('dictation-a') expect(harness.commitRecordingStart).not.toHaveBeenCalled() + expect(harness.sendRequest).toHaveBeenCalledWith('speech.dictation.cancel', { + dictationId: 'dictation-a' + }) }) - it('sends desktop cancellation without waiting for a hung keep-awake release', async () => { - vi.useFakeTimers() - try { - let goStale = () => undefined - const harness = createStartHarness({ - acquire: () => { - goStale() - return new Promise(() => undefined) - } - }) - goStale = harness.setNewerStart - // Release queues behind the still-running acquisition, so it never settles. - harness.release.mockReturnValue(new Promise(() => undefined)) - - const startPromise = startMobileDictationDesktopSession(harness.options) - await vi.advanceTimersByTimeAsync(MOBILE_DICTATION_KEEP_AWAKE_STARTUP_BUDGET_MS) - - // Cancellation is dispatched even though release is still pending, so the - // native session tears down without waiting out its own timeout. - expect(harness.release).toHaveBeenCalledWith('dictation-a') - expect(harness.sendRequest).toHaveBeenCalledWith('speech.dictation.cancel', { - dictationId: 'dictation-a' - }) - - void startPromise - } finally { - vi.useRealTimers() - } - }) - - it('returns to idle when disable makes keep-awake acquisition stale', async () => { + it('returns to idle when a disable makes the start stale', async () => { let setDisabled = () => undefined const harness = createStartHarness({ - acquire: async () => setDisabled() + sendRequest: async (method) => { + if (method === 'speech.dictation.start') { + setDisabled() + } + return OK_RESPONSE + } }) setDisabled = harness.setDisabled @@ -120,50 +98,9 @@ describe('startMobileDictationDesktopSession', () => { expect(harness.setIdle).toHaveBeenCalledOnce() expect(harness.getActiveId()).toBeNull() - expect(harness.release).toHaveBeenCalledWith('dictation-a') expect(harness.commitRecordingStart).not.toHaveBeenCalled() }) - it('does not hold recording start on a hung keep-awake acquisition', async () => { - vi.useFakeTimers() - try { - const harness = createStartHarness({ - acquire: () => new Promise(() => undefined) - }) - - const startPromise = startMobileDictationDesktopSession(harness.options) - await vi.advanceTimersByTimeAsync(MOBILE_DICTATION_KEEP_AWAKE_STARTUP_BUDGET_MS) - - await expect(startPromise).resolves.toBe(true) - expect(harness.commitRecordingStart).toHaveBeenCalledOnce() - expect(harness.release).not.toHaveBeenCalled() - } finally { - vi.useRealTimers() - } - }) - - it('continues dictation when keep-awake acquisition fails', async () => { - const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined) - const harness = createStartHarness({ - acquire: async () => { - throw new Error('Unable to activate keep awake') - } - }) - - await expect(startMobileDictationDesktopSession(harness.options)).resolves.toBe(true) - - expect(consoleError).toHaveBeenCalledOnce() - consoleError.mockRestore() - - expect(harness.commitRecordingStart).toHaveBeenCalledOnce() - expect(harness.setIdle).not.toHaveBeenCalled() - expect(harness.getActiveId()).toBe('dictation-a') - expect(harness.release).not.toHaveBeenCalled() - expect(harness.sendRequest).not.toHaveBeenCalledWith('speech.dictation.cancel', { - dictationId: 'dictation-a' - }) - }) - it('does not surface a desktop-start failure after the start became stale', async () => { let setNewerStart = () => undefined const harness = createStartHarness({ @@ -193,7 +130,7 @@ describe('startMobileDictationDesktopSession', () => { expect(harness.rollbackRecordingStart).not.toHaveBeenCalled() }) - it('cleans up the keep-awake tag and desktop session when native recording throws', async () => { + it('cleans up the desktop session when native recording throws', async () => { const harness = createStartHarness({ commitRecordingStart: () => { throw new Error('Audio focus request failed') @@ -204,7 +141,6 @@ describe('startMobileDictationDesktopSession', () => { 'Audio focus request failed' ) - expect(harness.release).toHaveBeenCalledWith('dictation-a') expect(harness.sendRequest).toHaveBeenCalledWith('speech.dictation.cancel', { dictationId: 'dictation-a' }) @@ -220,7 +156,6 @@ describe('startMobileDictationDesktopSession', () => { 'Failed to start microphone recording' ) - expect(harness.release).toHaveBeenCalledWith('dictation-a') expect(harness.sendRequest).toHaveBeenCalledWith('speech.dictation.cancel', { dictationId: 'dictation-a' }) diff --git a/mobile/src/hooks/mobile-dictation-desktop-start.ts b/mobile/src/hooks/mobile-dictation-desktop-start.ts index c0ce4c948ac..d289fc150ac 100644 --- a/mobile/src/hooks/mobile-dictation-desktop-start.ts +++ b/mobile/src/hooks/mobile-dictation-desktop-start.ts @@ -1,12 +1,8 @@ -import { - MOBILE_DICTATION_KEEP_AWAKE_STARTUP_BUDGET_MS, - isCurrentMobileDictationStart -} from './mobile-dictation-session-state' +import { isCurrentMobileDictationStart } from './mobile-dictation-session-state' import { dictationSessionCancel, dictationSessionStart } from '../dictation/mobile-dictation-operations' -import type { MobileDictationKeepAwakeOwner } from './mobile-dictation-keep-awake' import type { RpcClient } from '../transport/rpc-client' type StartMobileDictationDesktopSessionOptions = { @@ -18,7 +14,6 @@ type StartMobileDictationDesktopSessionOptions = { getActiveId: () => string | null clearActiveId: (dictationId: string) => void setIdle: () => void - keepAwakeOwner: MobileDictationKeepAwakeOwner commitRecordingStart: () => boolean rollbackRecordingStart: () => void } @@ -43,28 +38,18 @@ function setIdleIfGenerationCurrent(options: StartMobileDictationDesktopSessionO } } -// Cancel a start that went stale mid-startup. The wake-lock release and remote -// cancel are independent, so run them concurrently: awaiting release first can -// queue behind a still-running acquisition and delay cancel for the remainder -// of the native timeout. -async function cancelStaleStart( - options: StartMobileDictationDesktopSessionOptions, - { releaseKeepAwake }: { releaseKeepAwake: boolean } -): Promise { - const { client, dictationId, keepAwakeOwner } = options +/** Cancel a start that went stale mid-startup: the desktop session is the only thing it holds. */ +async function cancelStaleStart(options: StartMobileDictationDesktopSessionOptions): Promise { + const { client, dictationId } = options options.clearActiveId(dictationId) setIdleIfGenerationCurrent(options) - const cleanups: Promise[] = [dictationSessionCancel.request(client, { dictationId })] - if (releaseKeepAwake) { - cleanups.push(keepAwakeOwner.release(dictationId)) - } - await Promise.allSettled(cleanups) + await dictationSessionCancel.request(client, { dictationId }).catch(() => undefined) } export async function startMobileDictationDesktopSession( options: StartMobileDictationDesktopSessionOptions ): Promise { - const { client, dictationId, keepAwakeOwner } = options + const { client, dictationId } = options try { const reply = await dictationSessionStart.request(client, { dictationId }) @@ -83,37 +68,16 @@ export async function startMobileDictationDesktopSession( throw err } + // One check, in the same continuation as the commit below: nothing awaits between them now that + // the screen is the microphone's, so a second one would re-read state nothing could have moved. if (!isCurrentStart(options)) { - await cancelStaleStart(options, { releaseKeepAwake: false }) - return false - } - - // Keep-awake is acquired only after the desktop session exists, so stale - // mobile starts can be canceled without holding a screen-lock tag. It is - // best-effort: Android throws with no current Activity, and a screen-lock - // nicety must not abort an otherwise viable dictation — nor delay recording - // past a small budget when native calls hang. A late acquisition finishes in - // the background; the serialized keep-awake queue orders any later release - // after it. - await new Promise((resolve) => { - const budgetTimer = setTimeout(resolve, MOBILE_DICTATION_KEEP_AWAKE_STARTUP_BUDGET_MS) - keepAwakeOwner - .acquire(dictationId) - .catch((err: unknown) => console.error('Keep-awake activation failed', err)) - .finally(() => { - clearTimeout(budgetTimer) - resolve() - }) - }) - - if (!isCurrentStart(options)) { - await cancelStaleStart(options, { releaseKeepAwake: true }) + await cancelStaleStart(options) return false } try { - // Commit in the same continuation as the final stale check; returning first - // would let a queued cancel resurrect microphone recording after cleanup. + // Committed here rather than after a return, which would let a queued cancel resurrect + // microphone recording after cleanup. if (!options.commitRecordingStart()) { throw new Error('Failed to start microphone recording') } @@ -127,10 +91,7 @@ export async function startMobileDictationDesktopSession( // Continue releasing independently owned resources after native audio failure. } options.clearActiveId(dictationId) - await Promise.allSettled([ - keepAwakeOwner.release(dictationId), - dictationSessionCancel.request(client, { dictationId }) - ]) + await dictationSessionCancel.request(client, { dictationId }).catch(() => undefined) const shouldReport = wasCurrent && canReportStartFailure(options) setIdleIfGenerationCurrent(options) if (!shouldReport) { diff --git a/mobile/src/hooks/mobile-dictation-foreground-keep-awake.ts b/mobile/src/hooks/mobile-dictation-foreground-keep-awake.ts deleted file mode 100644 index e5571e0379d..00000000000 --- a/mobile/src/hooks/mobile-dictation-foreground-keep-awake.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { useEffect } from 'react' -import { AppState, Platform } from 'react-native' -import { drainMobileDictationKeepAwakeCleanup } from './mobile-dictation-keep-awake' -import type { RefObject } from 'react' -import type { DictationKeepAwakeDevice } from '../platform/dictation-capture-contract' -import type { MobileDictationKeepAwakeOwner } from './mobile-dictation-keep-awake' - -// A transient Activity gap can fail a foreground refresh; retry briefly while -// the same dictation is live instead of waiting for the next foreground. -const REACQUIRE_RETRY_DELAYS_MS = [1_000, 5_000] - -let globalStaleTagDrainInstalled = false -// The drain outlives every owner, so it reads the newest device rather than capturing one: on the -// page that device is built from a bridge client the screen can replace, and a captured one would -// deactivate through a port nothing is listening on. -let latestKeepAwakeDevice: DictationKeepAwakeDevice | null = null - -// Failed final deactivations must be retried even after every session screen -// unmounts, or a stale native tag keeps the screen awake until app restart. -// Installed once for the app's lifetime; the drain spares still-wanted tags -// and fast-paths to a no-op when nothing is pending. -function installGlobalStaleTagForegroundDrain(device: DictationKeepAwakeDevice): void { - latestKeepAwakeDevice = device - if (globalStaleTagDrainInstalled) { - return - } - globalStaleTagDrainInstalled = true - AppState.addEventListener('change', (state) => { - const current = latestKeepAwakeDevice - if (state === 'active' && current !== null) { - void drainMobileDictationKeepAwakeCleanup(current).catch(() => undefined) - } - }) -} - -export function useMobileDictationForegroundKeepAwake( - keepAwakeOwner: MobileDictationKeepAwakeOwner, - activeIdRef: RefObject, - keepAwakeDevice: DictationKeepAwakeDevice -): void { - useEffect(() => { - installGlobalStaleTagForegroundDrain(keepAwakeDevice) - // Android keeps FLAG_KEEP_SCREEN_ON on the Activity window, so Activity - // recreation silently drops it mid-dictation; refresh on return to - // active. iOS re-applies natively on foreground. - if (Platform.OS !== 'android') { - return - } - // A retry from an earlier foreground event can outlive a newer reacquire and - // deactivate the recovered tag; a run token invalidated on each AppState - // change and on unmount drops superseded retry chains. - let reacquireRun = 0 - const reacquireWithRetry = (dictationId: string, attempt: number, run: number): void => { - void keepAwakeOwner.reacquire(dictationId).catch(() => { - const delay = REACQUIRE_RETRY_DELAYS_MS[attempt] - if (delay === undefined) { - return - } - setTimeout(() => { - if (reacquireRun === run && activeIdRef.current === dictationId) { - reacquireWithRetry(dictationId, attempt + 1, run) - } - }, delay) - }) - } - const sub = AppState.addEventListener('change', (state) => { - const run = ++reacquireRun - const dictationId = activeIdRef.current - if (state === 'active' && dictationId) { - reacquireWithRetry(dictationId, 0, run) - } - }) - return () => { - reacquireRun += 1 - sub.remove() - } - }, [keepAwakeOwner, activeIdRef, keepAwakeDevice]) -} diff --git a/mobile/src/hooks/mobile-dictation-keep-awake.test.ts b/mobile/src/hooks/mobile-dictation-keep-awake.test.ts deleted file mode 100644 index 52e35947903..00000000000 --- a/mobile/src/hooks/mobile-dictation-keep-awake.test.ts +++ /dev/null @@ -1,440 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' -import { - MOBILE_DICTATION_KEEP_AWAKE_NATIVE_TIMEOUT_MS, - MobileDictationKeepAwakeOwner, - drainMobileDictationKeepAwakeCleanup -} from './mobile-dictation-keep-awake' - -/** The two calls the owner makes, which on a device are `expo-keep-awake` and on the page are - * `native.wakelock.set`. Everything under test here is what the owner does around them. */ -const keepAwake = { - activate: vi.fn<(tag: string) => Promise>(), - deactivate: vi.fn<(tag: string) => Promise>() -} - -const device = { activate: keepAwake.activate, deactivate: keepAwake.deactivate } - -function deferred(): { - promise: Promise - resolve: () => void - reject: (error: Error) => void -} { - let resolvePromise: (() => void) | undefined - let rejectPromise: ((error: Error) => void) | undefined - const promise = new Promise((resolve, reject) => { - resolvePromise = resolve - rejectPromise = reject - }) - return { - promise, - resolve: () => resolvePromise?.(), - reject: (error) => rejectPromise?.(error) - } -} - -describe('MobileDictationKeepAwakeOwner', () => { - beforeEach(() => { - keepAwake.activate.mockReset().mockResolvedValue(undefined) - keepAwake.deactivate.mockReset().mockResolvedValue(undefined) - }) - - it('retries a failed native deactivation after the hook owner is replaced', async () => { - const firstOwner = new MobileDictationKeepAwakeOwner(device) - - await firstOwner.acquire('first') - const firstTag = keepAwake.activate.mock.calls[0]?.[0] - expect(firstTag).toContain(':first') - - keepAwake.deactivate.mockRejectedValueOnce(new Error('Activity unavailable')) - await expect(firstOwner.release('first')).rejects.toThrow('Activity unavailable') - - const replacementOwner = new MobileDictationKeepAwakeOwner(device) - await replacementOwner.acquire('second') - const secondTag = keepAwake.activate.mock.calls[1]?.[0] - expect(secondTag).toContain(':second') - expect(keepAwake.deactivate.mock.calls.slice(0, 2)).toEqual([[firstTag], [firstTag]]) - expect(keepAwake.deactivate.mock.invocationCallOrder[1]).toBeLessThan( - keepAwake.activate.mock.invocationCallOrder[1] ?? 0 - ) - - await replacementOwner.release('second') - }) - - it('serializes cancel and restart without letting a stale release deactivate the restart', async () => { - const firstActivation = deferred() - keepAwake.activate.mockImplementationOnce(() => firstActivation.promise) - const owner = new MobileDictationKeepAwakeOwner(device) - - const acquireFirst = owner.acquire('first') - const releaseFirst = owner.release('first') - const acquireSecond = owner.acquire('second') - firstActivation.resolve() - await Promise.all([acquireFirst, releaseFirst, acquireSecond]) - - const secondTag = keepAwake.activate.mock.calls[1]?.[0] - await owner.release('first') - expect(keepAwake.deactivate).toHaveBeenCalledTimes(1) - - await owner.release('second') - expect(keepAwake.deactivate).toHaveBeenLastCalledWith(secondTag) - }) - - it('waits for an in-flight failed release before a replacement owner activates', async () => { - const deactivation = deferred() - const firstOwner = new MobileDictationKeepAwakeOwner(device) - await firstOwner.acquire('first') - keepAwake.deactivate.mockImplementationOnce(() => deactivation.promise) - - const releaseFirst = firstOwner.release('first') - await new Promise((resolve) => setTimeout(resolve, 0)) - expect(keepAwake.deactivate).toHaveBeenCalledOnce() - - const replacementOwner = new MobileDictationKeepAwakeOwner(device) - const acquireReplacement = replacementOwner.acquire('replacement') - expect(keepAwake.activate).toHaveBeenCalledOnce() - - deactivation.reject(new Error('Activity unavailable')) - await expect(releaseFirst).rejects.toThrow('Activity unavailable') - await acquireReplacement - - expect(keepAwake.deactivate).toHaveBeenCalledTimes(2) - expect(keepAwake.activate).toHaveBeenCalledTimes(2) - expect(keepAwake.deactivate.mock.invocationCallOrder[1]).toBeLessThan( - keepAwake.activate.mock.invocationCallOrder[1] ?? 0 - ) - await replacementOwner.release('replacement') - }) - - it('does not fail a fresh acquire when stale-tag cleanup keeps failing', async () => { - const firstOwner = new MobileDictationKeepAwakeOwner(device) - await firstOwner.acquire('first') - - // Both the release deactivate and its trailing drain retry fail. - keepAwake.deactivate - .mockRejectedValueOnce(new Error('Activity unavailable')) - .mockRejectedValueOnce(new Error('Activity unavailable')) - await expect(firstOwner.release('first')).rejects.toThrow('Activity unavailable') - - keepAwake.deactivate.mockRejectedValueOnce(new Error('Activity unavailable')) - const replacementOwner = new MobileDictationKeepAwakeOwner(device) - await expect(replacementOwner.acquire('second')).resolves.toBeUndefined() - expect(keepAwake.activate).toHaveBeenCalledTimes(2) - - // The still-pending first tag drains once a deactivation finally succeeds. - await replacementOwner.release('second') - expect(keepAwake.deactivate.mock.calls.filter(([tag]) => tag.includes(':first'))).toHaveLength( - 4 - ) - }) - - it('times out a never-settling native call instead of wedging the queue', async () => { - vi.useFakeTimers() - try { - keepAwake.activate.mockImplementationOnce(() => new Promise(() => undefined)) - const hungOwner = new MobileDictationKeepAwakeOwner(device) - const hungAcquire = hungOwner.acquire('hung') - // Drain microtasks to quiescence so the timeout timer is registered. - await vi.advanceTimersByTimeAsync(0) - - await vi.advanceTimersByTimeAsync(MOBILE_DICTATION_KEEP_AWAKE_NATIVE_TIMEOUT_MS) - await expect(hungAcquire).rejects.toThrow('Keep-awake native call timed out') - - // The queue must advance, and another owner's drain must spare the - // still-wanted maybe-late activation. - const nextOwner = new MobileDictationKeepAwakeOwner(device) - await nextOwner.acquire('next') - expect(keepAwake.deactivate).not.toHaveBeenCalled() - expect(keepAwake.activate.mock.calls[1]?.[0]).toContain(':next') - - // Once its own dictation ends, the orphan gets cleaned. - await hungOwner.release('hung') - expect(keepAwake.deactivate.mock.calls.filter(([tag]) => tag.includes(':hung'))).toHaveLength( - 1 - ) - await nextOwner.release('next') - } finally { - vi.useRealTimers() - } - }) - - it('adopts a timed-out activation that lands late while the dictation is live', async () => { - vi.useFakeTimers() - try { - const lateActivation = deferred() - keepAwake.activate.mockImplementationOnce(() => lateActivation.promise) - const owner = new MobileDictationKeepAwakeOwner(device) - const acquire = owner.acquire('late') - await vi.advanceTimersByTimeAsync(0) - - await vi.advanceTimersByTimeAsync(MOBILE_DICTATION_KEEP_AWAKE_NATIVE_TIMEOUT_MS) - await expect(acquire).rejects.toThrow('Keep-awake native call timed out') - - lateActivation.resolve() - await vi.advanceTimersByTimeAsync(0) - // Adopted, not deactivated: protection stays on for the live dictation. - expect(keepAwake.deactivate).not.toHaveBeenCalled() - - await owner.release('late') - expect(keepAwake.deactivate.mock.calls.filter(([tag]) => tag.includes(':late'))).toHaveLength( - 1 - ) - } finally { - vi.useRealTimers() - } - }) - - it('does not let another owner drain a still-wanted timed-out activation', async () => { - vi.useFakeTimers() - try { - const lateActivation = deferred() - keepAwake.activate.mockImplementationOnce(() => lateActivation.promise) - const ownerA = new MobileDictationKeepAwakeOwner(device) - const acquireA = ownerA.acquire('wanted') - await vi.advanceTimersByTimeAsync(0) - await vi.advanceTimersByTimeAsync(MOBILE_DICTATION_KEEP_AWAKE_NATIVE_TIMEOUT_MS) - await expect(acquireA).rejects.toThrow('Keep-awake native call timed out') - - const ownerB = new MobileDictationKeepAwakeOwner(device) - await ownerB.acquire('other') - expect(keepAwake.deactivate).not.toHaveBeenCalled() - - lateActivation.resolve() - await vi.advanceTimersByTimeAsync(0) - // Adopted for owner A; released like a normal activation afterwards. - await ownerA.release('wanted') - expect( - keepAwake.deactivate.mock.calls.filter(([tag]) => tag.includes(':wanted')) - ).toHaveLength(1) - await ownerB.release('other') - } finally { - vi.useRealTimers() - } - }) - - it('deactivates a late-landing activation once its dictation has ended', async () => { - vi.useFakeTimers() - try { - const lateActivation = deferred() - keepAwake.activate.mockImplementationOnce(() => lateActivation.promise) - const owner = new MobileDictationKeepAwakeOwner(device) - const acquire = owner.acquire('ended') - await vi.advanceTimersByTimeAsync(0) - await vi.advanceTimersByTimeAsync(MOBILE_DICTATION_KEEP_AWAKE_NATIVE_TIMEOUT_MS) - await expect(acquire).rejects.toThrow('Keep-awake native call timed out') - - await owner.release('ended') - lateActivation.resolve() - await vi.waitFor(() => expect(keepAwake.deactivate).toHaveBeenCalledTimes(2)) - expect(keepAwake.deactivate.mock.calls.every(([tag]) => tag.includes(':ended'))).toBe(true) - } finally { - vi.useRealTimers() - } - }) - - it('retries a timed-out final deactivation via the foreground drain', async () => { - vi.useFakeTimers() - try { - const owner = new MobileDictationKeepAwakeOwner(device) - await owner.acquire('final') - const tag = keepAwake.activate.mock.calls[0]?.[0] - // The release deactivate times out and its trailing drain retry fails. - keepAwake.deactivate - .mockImplementationOnce(() => new Promise(() => undefined)) - .mockRejectedValueOnce(new Error('Activity unavailable')) - const release = owner.release('final') - await vi.advanceTimersByTimeAsync(0) - await vi.advanceTimersByTimeAsync(MOBILE_DICTATION_KEEP_AWAKE_NATIVE_TIMEOUT_MS) - await expect(release).rejects.toThrow('Keep-awake native call timed out') - expect(keepAwake.deactivate).toHaveBeenCalledTimes(2) - - await drainMobileDictationKeepAwakeCleanup(device) - expect(keepAwake.deactivate).toHaveBeenCalledTimes(3) - expect(keepAwake.deactivate).toHaveBeenLastCalledWith(tag) - } finally { - vi.useRealTimers() - } - }) - - it('drains orphaned tags on release, not only on the next acquire', async () => { - vi.useFakeTimers() - try { - keepAwake.activate.mockImplementationOnce(() => new Promise(() => undefined)) - const owner = new MobileDictationKeepAwakeOwner(device) - const acquire = owner.acquire('orphan') - await vi.advanceTimersByTimeAsync(0) - await vi.advanceTimersByTimeAsync(MOBILE_DICTATION_KEEP_AWAKE_NATIVE_TIMEOUT_MS) - await expect(acquire).rejects.toThrow('Keep-awake native call timed out') - - // The dictation ends without another acquire; release must still clean. - await owner.release('orphan') - expect( - keepAwake.deactivate.mock.calls.filter(([tag]) => tag.includes(':orphan')) - ).toHaveLength(1) - } finally { - vi.useRealTimers() - } - }) - - it('recovers on foreground reacquire after a failed initial acquisition', async () => { - keepAwake.activate.mockRejectedValueOnce(new Error('Unable to activate keep awake')) - const owner = new MobileDictationKeepAwakeOwner(device) - await expect(owner.acquire('current')).rejects.toThrow('Unable to activate keep awake') - expect(keepAwake.deactivate).not.toHaveBeenCalled() - - await owner.reacquire('current') - const tag = keepAwake.activate.mock.calls[1]?.[0] - expect(keepAwake.activate).toHaveBeenCalledTimes(2) - expect(tag).toContain(':current') - // A definite rejection activated nothing, so recovery must not deactivate. - expect(keepAwake.deactivate).not.toHaveBeenCalled() - - await owner.release('current') - expect(keepAwake.deactivate).toHaveBeenLastCalledWith(tag) - }) - - it('recovers keep-awake on a later reacquire after a failed refresh', async () => { - const owner = new MobileDictationKeepAwakeOwner(device) - await owner.acquire('current') - const tag = keepAwake.activate.mock.calls[0]?.[0] - - // Refresh loses the activation: deactivate succeeds, activate rejects. - keepAwake.activate.mockRejectedValueOnce(new Error('Unable to activate keep awake')) - await expect(owner.reacquire('current')).rejects.toThrow('Unable to activate keep awake') - - await owner.reacquire('current') - expect(keepAwake.activate).toHaveBeenCalledTimes(3) - expect(keepAwake.activate.mock.calls[2]?.[0]).toBe(tag) - // Only the pre-refresh deactivate ran; recovery must not deactivate again. - expect(keepAwake.deactivate).toHaveBeenCalledTimes(1) - - await owner.release('current') - expect(keepAwake.deactivate).toHaveBeenLastCalledWith(tag) - }) - - it('records new-dictation intent even when previous-tag cleanup fails', async () => { - const owner = new MobileDictationKeepAwakeOwner(device) - await owner.acquire('first') - - // Release and its trailing drain both fail; the owner keeps stale intent. - keepAwake.deactivate - .mockRejectedValueOnce(new Error('Activity unavailable')) - .mockRejectedValueOnce(new Error('Activity unavailable')) - await expect(owner.release('first')).rejects.toThrow('Activity unavailable') - - // The next acquire's drain and previous-tag cleanup fail too, and the new - // activation itself fails — intent must still be recorded for the heal. - keepAwake.deactivate - .mockRejectedValueOnce(new Error('Activity unavailable')) - .mockRejectedValueOnce(new Error('Activity unavailable')) - keepAwake.activate.mockRejectedValueOnce(new Error('Unable to activate keep awake')) - await expect(owner.acquire('second')).rejects.toThrow('Unable to activate keep awake') - - await owner.reacquire('second') - expect(keepAwake.activate).toHaveBeenCalledTimes(3) - expect(keepAwake.activate.mock.calls.at(-1)?.[0]).toContain(':second') - - await owner.release('second') - }) - - it('keeps tracking a newer timed-out activation when an older one settles late', async () => { - vi.useFakeTimers() - try { - const first = deferred() - keepAwake.activate.mockImplementationOnce(() => first.promise) - const owner = new MobileDictationKeepAwakeOwner(device) - const acquire = owner.acquire('stacked') - await vi.advanceTimersByTimeAsync(0) - await vi.advanceTimersByTimeAsync(MOBILE_DICTATION_KEEP_AWAKE_NATIVE_TIMEOUT_MS) - await expect(acquire).rejects.toThrow('Keep-awake native call timed out') - - const second = deferred() - keepAwake.activate.mockImplementationOnce(() => second.promise) - const reacquire = owner.reacquire('stacked') - await vi.advanceTimersByTimeAsync(0) - await vi.advanceTimersByTimeAsync(MOBILE_DICTATION_KEEP_AWAKE_NATIVE_TIMEOUT_MS) - await expect(reacquire).rejects.toThrow('Keep-awake native call timed out') - - // The older activation settles late with a rejection; the newer - // activation's tracking must survive it. - first.reject(new Error('Activity unavailable')) - await vi.advanceTimersByTimeAsync(0) - - await owner.release('stacked') - // Exactly two: the reacquire's deactivate-first pass, plus the release - // drain cleaning the newer entry the stale settle must not have deleted. - expect( - keepAwake.deactivate.mock.calls.filter(([tag]) => tag.includes(':stacked')) - ).toHaveLength(2) - } finally { - vi.useRealTimers() - } - }) - - it('reacquires a maybe-active timed-out activation by deactivating first', async () => { - vi.useFakeTimers() - try { - keepAwake.activate.mockImplementationOnce(() => new Promise(() => undefined)) - const owner = new MobileDictationKeepAwakeOwner(device) - const acquire = owner.acquire('maybe') - await vi.advanceTimersByTimeAsync(0) - await vi.advanceTimersByTimeAsync(MOBILE_DICTATION_KEEP_AWAKE_NATIVE_TIMEOUT_MS) - await expect(acquire).rejects.toThrow('Keep-awake native call timed out') - - await owner.reacquire('maybe') - // The ambiguous activation may be natively live; it must be deactivated - // before the fresh activate so Android re-applies the window flag. - const tag = keepAwake.activate.mock.calls[0]?.[0] - expect(keepAwake.deactivate).toHaveBeenCalledWith(tag) - expect(keepAwake.deactivate.mock.invocationCallOrder[0]).toBeLessThan( - keepAwake.activate.mock.invocationCallOrder[1] ?? 0 - ) - - await owner.release('maybe') - } finally { - vi.useRealTimers() - } - }) - - it('keeps a live tag out of the orphan pool when a refresh deactivation fails', async () => { - const ownerA = new MobileDictationKeepAwakeOwner(device) - await ownerA.acquire('live') - const liveTag = keepAwake.activate.mock.calls[0]?.[0] - - // Foreground refresh: the deactivate leg fails; the tag stays native-on - // and the failure surfaces so the caller's bounded retry can kick in. - keepAwake.deactivate.mockRejectedValueOnce(new Error('Activity unavailable')) - await expect(ownerA.reacquire('live')).rejects.toThrow('Activity unavailable') - expect(keepAwake.deactivate.mock.calls.filter(([tag]) => tag === liveTag)).toHaveLength(1) - - // Another owner's drain must spare the still-wanted live tag. - const ownerB = new MobileDictationKeepAwakeOwner(device) - await ownerB.acquire('other') - expect(keepAwake.deactivate.mock.calls.filter(([tag]) => tag === liveTag)).toHaveLength(1) - - // The next foreground retries the full deactivate-then-activate refresh. - await ownerA.reacquire('live') - expect(keepAwake.activate.mock.calls.filter(([tag]) => tag === liveTag)).toHaveLength(2) - - await ownerA.release('live') - await ownerB.release('other') - }) - - it('reacquires by deactivating before activating so Android re-applies the window flag', async () => { - const owner = new MobileDictationKeepAwakeOwner(device) - await owner.acquire('current') - const tag = keepAwake.activate.mock.calls[0]?.[0] - - await owner.reacquire('current') - - expect(keepAwake.deactivate).toHaveBeenCalledWith(tag) - expect(keepAwake.activate.mock.calls).toEqual([[tag], [tag]]) - expect(keepAwake.deactivate.mock.invocationCallOrder[0]).toBeLessThan( - keepAwake.activate.mock.invocationCallOrder[1] ?? 0 - ) - - await owner.reacquire('other') - expect(keepAwake.activate).toHaveBeenCalledTimes(2) - - await owner.release('current') - }) -}) diff --git a/mobile/src/hooks/mobile-dictation-keep-awake.ts b/mobile/src/hooks/mobile-dictation-keep-awake.ts deleted file mode 100644 index f99578b9ba3..00000000000 --- a/mobile/src/hooks/mobile-dictation-keep-awake.ts +++ /dev/null @@ -1,248 +0,0 @@ -import type { DictationKeepAwakeDevice } from '../platform/dictation-capture-contract' - -const MOBILE_DICTATION_KEEP_AWAKE_TAG_PREFIX = 'orca-mobile-dictation' - -// Native keep-awake promises can be lost during Activity teardown; a bounded -// wait keeps the serialized queue below from wedging dictation until restart. -export const MOBILE_DICTATION_KEEP_AWAKE_NATIVE_TIMEOUT_MS = 10_000 - -let nextOwnerId = 0 -let keepAwakeOperation: Promise = Promise.resolve() -const activeTags = new Set() -const pendingCleanupTags = new Set() -// Timed-out activations that may still land natively, keyed to a predicate -// saying whether their dictation still wants the tag. -const pendingActivations = new Map boolean>() - -function createOwnerId(): string { - nextOwnerId += 1 - return `${Date.now()}-${nextOwnerId}-${Math.random().toString(36).slice(2)}` -} - -function enqueueKeepAwakeOperation(action: () => Promise): Promise { - const operation = keepAwakeOperation.then(action) - keepAwakeOperation = operation.catch(() => undefined) - return operation -} - -const KEEP_AWAKE_TIMEOUT_ERROR_NAME = 'KeepAwakeNativeTimeoutError' - -function isNativeCallTimeout(err: unknown): boolean { - return err instanceof Error && err.name === KEEP_AWAKE_TIMEOUT_ERROR_NAME -} - -function withNativeCallTimeout(nativeCall: Promise): Promise { - return new Promise((resolve, reject) => { - const timer = setTimeout(() => { - const timeoutError = new Error('Keep-awake native call timed out') - timeoutError.name = KEEP_AWAKE_TIMEOUT_ERROR_NAME - reject(timeoutError) - }, MOBILE_DICTATION_KEEP_AWAKE_NATIVE_TIMEOUT_MS) - nativeCall.then( - () => { - clearTimeout(timer) - resolve() - }, - (err: unknown) => { - clearTimeout(timer) - reject(err instanceof Error ? err : new Error(String(err))) - } - ) - }) -} - -async function activateTrackedTag( - device: DictationKeepAwakeDevice, - tag: string, - isStillWanted: () => boolean -): Promise { - const nativeActivation = device.activate(tag) - try { - await withNativeCallTimeout(nativeActivation) - } catch (err) { - // Only a timeout is ambiguous: the activation can still take effect late. - // A definite native rejection activated nothing and needs no cleanup. - if (isNativeCallTimeout(err)) { - // Tracked apart from deactivation retries so drains (including another - // owner's) never turn off an activation its dictation still wants. - pendingActivations.set(tag, isStillWanted) - nativeActivation.then( - () => - void enqueueKeepAwakeOperation(async () => { - // Delete only this activation's own entry — a newer timed-out - // activation of the same tag may have replaced it. - if (pendingActivations.get(tag) === isStillWanted) { - pendingActivations.delete(tag) - } - if (activeTags.has(tag)) { - return - } - if (isStillWanted()) { - // The activation landed late but its dictation is still live; - // adopt it rather than turning off screen-lock protection. - activeTags.add(tag) - return - } - // No owner wants it anymore — the screen must not stay awake. - await deactivateTrackedTag(device, tag).catch(() => undefined) - }), - () => { - // A late definite rejection means nothing activated after all, but - // spare a newer activation's entry keyed to the same tag. - if (pendingActivations.get(tag) === isStillWanted) { - pendingActivations.delete(tag) - } - } - ) - } - throw err - } - activeTags.add(tag) - pendingCleanupTags.delete(tag) - pendingActivations.delete(tag) -} - -async function deactivateTrackedTag(device: DictationKeepAwakeDevice, tag: string): Promise { - try { - await withNativeCallTimeout(device.deactivate(tag)) - } catch (err) { - // A replacement hook must be able to retry cleanup after Android replaces - // an Activity and the owner that acquired this tag has unmounted. - pendingCleanupTags.add(tag) - throw err - } - activeTags.delete(tag) - pendingCleanupTags.delete(tag) - pendingActivations.delete(tag) -} - -async function cleanupPendingTags(device: DictationKeepAwakeDevice): Promise { - const staleTags = new Set(pendingCleanupTags) - for (const [tag, isStillWanted] of pendingActivations) { - // A still-wanted timed-out activation is not an orphan: deactivating it - // would turn off screen-lock protection for a live dictation. - if (!isStillWanted()) { - pendingActivations.delete(tag) - staleTags.add(tag) - } - } - if (staleTags.size === 0) { - return - } - // Retry concurrently so N stale tags cost one timeout window, not N, and - // swallow failures: a stale tag that still cannot be deactivated must not - // fail the fresh acquire that triggered this retry; it stays queued. - await Promise.allSettled( - Array.from(staleTags, (tag) => deactivateTrackedTag(device, tag).catch(() => undefined)) - ) -} - -export class MobileDictationKeepAwakeOwner { - private readonly ownerId = createOwnerId() - private acquiredTag: string | null = null - - /** The two calls that differ between the hosts, and the only part of this file that does: the - * tag pools, the serialized queue, the timeouts and the retries are the same either way. */ - constructor(private readonly device: DictationKeepAwakeDevice) {} - - acquire(dictationId: string): Promise { - const tag = this.createTag(dictationId) - return enqueueKeepAwakeOperation(async () => { - await cleanupPendingTags(this.device) - if (this.acquiredTag && !activeTags.has(this.acquiredTag)) { - this.acquiredTag = null - } - if (this.acquiredTag === tag) { - return - } - if (this.acquiredTag) { - const previousTag = this.acquiredTag - this.acquiredTag = null - // Best-effort: a failed previous-tag cleanup is queued for retry and - // must not block recording intent for the new dictation below. - await deactivateTrackedTag(this.device, previousTag).catch(() => undefined) - } - // Record ownership before the native call: acquiredTag is intent while - // activeTags is native state, so a failed initial activation can still - // be healed by a later foreground reacquire. - this.acquiredTag = tag - await activateTrackedTag(this.device, tag, () => this.acquiredTag === tag) - }) - } - - // Android keeps FLAG_KEEP_SCREEN_ON on the Activity window, so a recreated - // Activity silently loses it while native tags persist — and native activate - // skips re-applying the flag while any tag remains, so deactivate first. - reacquire(dictationId: string): Promise { - const tag = this.createTag(dictationId) - return enqueueKeepAwakeOperation(async () => { - await cleanupPendingTags(this.device) - if (this.acquiredTag !== tag) { - return - } - // A timed-out activation may be natively active too, and Android only - // re-applies the window flag from an empty tag set — deactivate both. - if (activeTags.has(tag) || pendingActivations.has(tag)) { - try { - await deactivateTrackedTag(this.device, tag) - } catch (err) { - // A still-live tag must not sit in the orphan pool where another - // owner's drain would turn it off without reactivating; keep it in - // the wanted pool and surface the failure so the caller can retry - // before the next foreground event. - pendingCleanupTags.delete(tag) - pendingActivations.set(tag, () => this.acquiredTag === tag) - throw err - } - } - // Also recovers an activation lost to an earlier native failure, so a - // later foreground event can restore keep-awake instead of no-oping. - // Known gap: if another expo-keep-awake owner exists (e.g. dev-build - // dev tools), the native module never empties its tag set, so the - // deactivate/activate cycle cannot re-apply the Android window flag. - await activateTrackedTag(this.device, tag, () => this.acquiredTag === tag) - }) - } - - release(dictationId?: string): Promise { - const targetTag = dictationId ? this.createTag(dictationId) : null - return enqueueKeepAwakeOperation(async () => { - try { - const tag = this.acquiredTag - if (!tag || (targetTag && tag !== targetTag)) { - return - } - if (!activeTags.has(tag)) { - this.acquiredTag = null - return - } - await deactivateTrackedTag(this.device, tag) - this.acquiredTag = null - } finally { - // Drain after the owner-local unset so this owner's own timed-out - // activation is no longer wanted and gets cleaned here — an acquire - // may never happen again this session. Still-wanted tags of other - // live dictations are spared by the drain itself. - await cleanupPendingTags(this.device) - } - }) - } - - private createTag(dictationId: string): string { - return `${MOBILE_DICTATION_KEEP_AWAKE_TAG_PREFIX}:${this.ownerId}:${dictationId}` - } -} - -export function createMobileDictationKeepAwakeOwner( - device: DictationKeepAwakeDevice -): MobileDictationKeepAwakeOwner { - return new MobileDictationKeepAwakeOwner(device) -} - -// Foreground is the retry point for wake tags whose final deactivation timed -// out after a dictation ended — otherwise nothing runs until the next one. -export function drainMobileDictationKeepAwakeCleanup( - device: DictationKeepAwakeDevice -): Promise { - return enqueueKeepAwakeOperation(() => cleanupPendingTags(device)) -} diff --git a/mobile/src/hooks/mobile-dictation-session-state.ts b/mobile/src/hooks/mobile-dictation-session-state.ts index 578f1721342..9d283e54101 100644 --- a/mobile/src/hooks/mobile-dictation-session-state.ts +++ b/mobile/src/hooks/mobile-dictation-session-state.ts @@ -26,10 +26,6 @@ export type UseMobileDictationResult = { export const DICTATION_FINISH_TIMEOUT_MS = 75_000 -// Recording start waits at most this long for the best-effort wake tag; a -// slow or hung native keep-awake module must not hold the mic in 'starting'. -export const MOBILE_DICTATION_KEEP_AWAKE_STARTUP_BUDGET_MS = 500 - export function bytesToBase64(bytes: Uint8Array): string { return Buffer.from(bytes).toString('base64') } diff --git a/mobile/src/hooks/mobile-dictation-stop-tail.test.tsx b/mobile/src/hooks/mobile-dictation-stop-tail.test.tsx index c46a4b412c3..93ef7c24db5 100644 --- a/mobile/src/hooks/mobile-dictation-stop-tail.test.tsx +++ b/mobile/src/hooks/mobile-dictation-stop-tail.test.tsx @@ -64,8 +64,7 @@ vi.mock('../platform/dictation-capture', () => { } } }, - onInterruption: () => ({ remove: () => {} }), - keepAwake: { activate: async () => {}, deactivate: async () => {} } + onInterruption: () => ({ remove: () => {} }) } return { useDictationCapture: () => capture } }) diff --git a/mobile/src/hooks/use-mobile-dictation-source.test.ts b/mobile/src/hooks/use-mobile-dictation-source.test.ts index 7354c2019bf..ab44072d7f5 100644 --- a/mobile/src/hooks/use-mobile-dictation-source.test.ts +++ b/mobile/src/hooks/use-mobile-dictation-source.test.ts @@ -6,22 +6,6 @@ const audioChunkSource = readFileSync( new URL('./mobile-dictation-audio-chunk.ts', import.meta.url), 'utf8' ) -const keepAwakeSource = readFileSync( - new URL('./mobile-dictation-keep-awake.ts', import.meta.url), - 'utf8' -) -const desktopStartSource = readFileSync( - new URL('./mobile-dictation-desktop-start.ts', import.meta.url), - 'utf8' -) -const sessionStateSource = readFileSync( - new URL('./mobile-dictation-session-state.ts', import.meta.url), - 'utf8' -) -const foregroundKeepAwakeSource = readFileSync( - new URL('./mobile-dictation-foreground-keep-awake.ts', import.meta.url), - 'utf8' -) const nativeCaptureSource = readFileSync( new URL('../platform/dictation-capture.ts', import.meta.url), 'utf8' @@ -39,10 +23,6 @@ function sliceBetween(startPattern: string, endPattern: string): string { return sliceSource(source, startPattern, endPattern) } -function sliceDesktopStartBetween(startPattern: string, endPattern: string): string { - return sliceSource(desktopStartSource, startPattern, endPattern) -} - describe('useMobileDictation source invariants', () => { it('publishes live option refs from committed renders before passive Effects flush', () => { const refDeclarations = sliceBetween( @@ -105,161 +85,26 @@ describe('useMobileDictation source invariants', () => { expect(pendingAudioResets).toHaveLength(pendingChunkClears.length) }) - it('keeps mobile dictation keep-awake ownership beside the hook', () => { - expect(source).toMatch( - /import \{[^}]*createMobileDictationKeepAwakeOwner[^}]*\} from '\.\/mobile-dictation-keep-awake'/ - ) - expect(source).toContain('createMobileDictationKeepAwakeOwner(capture.keepAwake)') - // The tag bookkeeping is host-independent and holds no device of its own: the two calls that - // differ come in through the seam, which is `expo-keep-awake` natively and the shell's wake - // verb on the page. - expect(keepAwakeSource).not.toMatch(/from '(expo-keep-awake|.*two-way-audio)'/) - expect(keepAwakeSource).toContain('device.activate(tag)') - expect(keepAwakeSource).toContain('device.deactivate(tag)') - expect(nativeCaptureSource).toContain('activateKeepAwakeAsync') - expect(nativeCaptureSource).toContain('deactivateKeepAwake') - expect(nativeCaptureSource).not.toMatch(/\bactivateKeepAwake\s*\(/) - }) - - it('acquires keep-awake only after desktop start and stale-start guards', () => { - const hookStartBody = sliceBetween('const start = useCallback(async () => {', 'const stop =') - const startBody = sliceDesktopStartBetween( - 'export async function startMobileDictationDesktopSession', - ' return true' - ) - const desktopStartIndex = startBody.indexOf( - 'dictationSessionStart.request(client, { dictationId })' - ) - const acquireIndex = startBody.indexOf('.acquire(dictationId)') - const desktopSessionIndex = hookStartBody.indexOf('await startMobileDictationDesktopSession') - const toggleRecordingIndex = hookStartBody.indexOf('capture.begin()') - - expect(desktopStartIndex).toBeGreaterThanOrEqual(0) - expect(acquireIndex).toBeGreaterThan(desktopStartIndex) - expect(desktopSessionIndex).toBeGreaterThanOrEqual(0) - expect(toggleRecordingIndex).toBeGreaterThan(desktopSessionIndex) - expect(hookStartBody).toContain('commitRecordingStart: () => {') - expect(startBody).toContain('options.commitRecordingStart()') - - const beforeAcquire = startBody.slice(desktopStartIndex, acquireIndex) - expect(beforeAcquire).toContain('isCurrentStart(options)') - expect(desktopStartSource).toContain('options.getCurrentGeneration()') - expect(desktopStartSource).toContain('options.getEnabled()') - expect(desktopStartSource).toContain('options.getActiveId()') - expect(sessionStateSource).toContain( - 'currentGeneration === generation && enabled && activeId === dictationId' - ) - }) - - it('re-checks stale-start guards after awaited keep-awake acquisition', () => { - const startBody = sliceDesktopStartBetween( - 'export async function startMobileDictationDesktopSession', - ' return true' - ) - const acquireIndex = startBody.indexOf('.acquire(dictationId)') - const returnStartedIndex = startBody.indexOf('return true') - const afterAcquire = startBody.slice(acquireIndex, returnStartedIndex) - - expect(afterAcquire).toContain('isCurrentStart(options)') - expect(desktopStartSource).toContain('options.getCurrentGeneration()') - expect(desktopStartSource).toContain('options.getEnabled()') - expect(desktopStartSource).toContain('options.getActiveId()') - expect(afterAcquire).toContain('await cancelStaleStart(options, { releaseKeepAwake: true })') - - // The stale-start cleanup must release the wake tag and cancel the desktop - // session (concurrently, so a hung acquisition can't delay the cancel). - const cancelStaleStartBody = sliceDesktopStartBetween( - 'async function cancelStaleStart', - 'export async function startMobileDictationDesktopSession' - ) - expect(cancelStaleStartBody).toContain( - 'dictationSessionCancel.request(client, { dictationId })' - ) - expect(cancelStaleStartBody).toContain('cleanups.push(keepAwakeOwner.release(dictationId))') - expect(cancelStaleStartBody).toContain('await Promise.allSettled(cleanups)') - }) - - it('treats keep-awake acquisition as best-effort for the desktop session', () => { - const startBody = sliceDesktopStartBetween( - 'export async function startMobileDictationDesktopSession', - ' return true' - ) - const acquireIndex = startBody.indexOf('.acquire(dictationId)') - expect(acquireIndex).toBeGreaterThanOrEqual(0) - const staleCheckIndex = startBody.indexOf('isCurrentStart(options)', acquireIndex) - expect(staleCheckIndex).toBeGreaterThan(acquireIndex) - - // An acquisition failure must not cancel the dictation or surface an error. - const acquireChain = startBody.slice(acquireIndex, staleCheckIndex) - expect(acquireChain).toContain('.catch(') - expect(acquireChain).not.toContain('throw') - }) - - it('releases keep-awake on all dictation cleanup paths without delaying recording shutdown', () => { + it('closes the capture on every path that ends a dictation', () => { const closeAudio = sliceBetween( 'const closeDictationAudio = useCallback(', 'const failActiveDictation =' ) - expect(closeAudio.indexOf('capture.end()')).toBeLessThan( - closeAudio.indexOf('void keepAwakeOwner.release') - ) - expect(closeAudio).toContain('.catch(() => undefined)') - + expect(closeAudio).toContain('capture.end()') const cleanupSlices = [ sliceBetween('const failActiveDictation = useCallback(', 'useEffect(() => {'), sliceBetween('const cancel = useCallback(async () => {', 'useEffect(() => {\n const sub'), sliceBetween('return () => {\n const dictationId = activeIdRef.current', ' return {') ] - for (const cleanupSlice of cleanupSlices) { - expect(cleanupSlice).toContain('closeDictationAudio(dictationId)') + expect(cleanupSlice).toContain('closeDictationAudio()') } - + // The capture hands over its tail before the pending sends are taken, or the finish overtakes + // the last chunk. `mobile-dictation-stop-tail.test.tsx` drives the order this pins. const stopBody = sliceBetween('const stop = useCallback(async () => {', 'const cancel =') expect(stopBody.indexOf('capture.end()')).toBeLessThan( stopBody.indexOf('await Promise.allSettled') ) - // The wake tag must be held through chunk drain and the finish RPC so a - // screen lock cannot suspend the app before the transcript arrives. - expect(stopBody.indexOf('speech.dictation.finish')).toBeLessThan( - stopBody.indexOf('void keepAwakeOwner.release') - ) - expect(stopBody.indexOf('} finally {')).toBeLessThan( - stopBody.indexOf('void keepAwakeOwner.release') - ) - }) - - it('reacquires the wake tag when Android returns to the foreground mid-dictation', () => { - expect(source).toContain( - 'useMobileDictationForegroundKeepAwake(keepAwakeOwner, activeIdRef, capture.keepAwake)' - ) - expect(foregroundKeepAwakeSource).toContain("Platform.OS !== 'android'") - expect(foregroundKeepAwakeSource).toContain('keepAwakeOwner.reacquire(dictationId)') - // A transiently failing refresh retries while the dictation is live. - expect(foregroundKeepAwakeSource).toContain('REACQUIRE_RETRY_DELAYS_MS[attempt]') - expect(foregroundKeepAwakeSource).toContain('activeIdRef.current === dictationId') - // Stale-tag retries survive hook unmount via a module-level listener. - expect(foregroundKeepAwakeSource).toContain( - 'installGlobalStaleTagForegroundDrain(keepAwakeDevice)' - ) - expect(foregroundKeepAwakeSource).toContain('drainMobileDictationKeepAwakeCleanup(current)') - - // Native activate skips re-applying the window flag while any tag remains, - // so reacquire must deactivate before activating. - const reacquireBody = sliceSource( - keepAwakeSource, - 'reacquire(dictationId: string)', - 'release(dictationId?: string)' - ) - expect( - reacquireBody.indexOf('await activateTrackedTag(this.device, tag,') - ).toBeGreaterThanOrEqual(0) - expect(reacquireBody.indexOf('deactivateTrackedTag(this.device, tag)')).toBeGreaterThanOrEqual( - 0 - ) - expect(reacquireBody.indexOf('deactivateTrackedTag(this.device, tag)')).toBeLessThan( - reacquireBody.indexOf('await activateTrackedTag(this.device, tag,') - ) }) it('keeps cleanup going when native recording shutdown throws', () => { @@ -269,10 +114,8 @@ describe('useMobileDictation source invariants', () => { ) const toggleIndex = closeAudio.indexOf('capture.end()') const catchIndex = closeAudio.indexOf('} catch', toggleIndex) - const releaseIndex = closeAudio.indexOf('void keepAwakeOwner.release') expect(toggleIndex).toBeGreaterThanOrEqual(0) expect(catchIndex).toBeGreaterThan(toggleIndex) - expect(catchIndex).toBeLessThan(releaseIndex) // The try above is not what makes this true, and this case used to claim it was. `end` is // async, so a throwing binding rejects rather than throwing, and a synchronous `catch` around @@ -302,19 +145,4 @@ describe('useMobileDictation source invariants', () => { // on a kind the device ignores. `dictation-capture.test.ts` drives the rule itself. expect(nativeCaptureSource).toContain('bridgeAudioInterruptionEndsCapture(event.data)') }) - - it('uses per-owner dictation keep-awake tags and serializes async ownership changes', () => { - expect(keepAwakeSource).toContain('private readonly ownerId = createOwnerId()') - expect(keepAwakeSource).toContain( - '`${MOBILE_DICTATION_KEEP_AWAKE_TAG_PREFIX}:${this.ownerId}:${dictationId}`' - ) - expect(keepAwakeSource).toContain('let keepAwakeOperation: Promise = Promise.resolve()') - expect(keepAwakeSource).toContain('const pendingCleanupTags = new Set()') - expect(keepAwakeSource.match(/enqueueKeepAwakeOperation/g)?.length).toBeGreaterThanOrEqual(3) - expect(keepAwakeSource).toContain( - 'const targetTag = dictationId ? this.createTag(dictationId) : null' - ) - expect(keepAwakeSource).toContain('if (!tag || (targetTag && tag !== targetTag))') - expect(keepAwakeSource).toContain('await cleanupPendingTags(this.device)') - }) }) diff --git a/mobile/src/hooks/use-mobile-dictation.ts b/mobile/src/hooks/use-mobile-dictation.ts index 5e3e52e6297..9e04a74543b 100644 --- a/mobile/src/hooks/use-mobile-dictation.ts +++ b/mobile/src/hooks/use-mobile-dictation.ts @@ -1,12 +1,10 @@ -import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' +import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react' import { useDictationCapture } from '../platform/dictation-capture' import { MOBILE_DICTATION_CONNECTION_SLOW_ERROR_MESSAGE, MobileDictationPendingAudioBudget } from './mobile-dictation-pending-audio-budget' import { enqueueMobileDictationAudioChunk } from './mobile-dictation-audio-chunk' -import { createMobileDictationKeepAwakeOwner } from './mobile-dictation-keep-awake' -import { useMobileDictationForegroundKeepAwake } from './mobile-dictation-foreground-keep-awake' import { DICTATION_FINISH_TIMEOUT_MS, createMobileDictationId, @@ -28,13 +26,10 @@ export type { UseMobileDictationResult } from './mobile-dictation-session-state' export function useMobileDictation(options: UseMobileDictationOptions): UseMobileDictationResult { const { client, enabled, onTranscript, onError } = options - // One seam, two hosts: natively the microphone and `expo-keep-awake`, on the page the shell's - // four verbs. Everything below this line is the same flow either way. + // One seam, two hosts: natively the microphone, on the page the shell's three audio verbs. + // Everything below this line is the same flow either way, the screen included — an open + // microphone holds it on the device side, under both halves. const capture = useDictationCapture() - const keepAwakeOwner = useMemo( - () => createMobileDictationKeepAwakeOwner(capture.keepAwake), - [capture] - ) const [status, setStatus] = useState('idle') const [error, setError] = useState(null) const activeIdRef = useRef(null) @@ -63,22 +58,17 @@ export function useMobileDictation(options: UseMobileDictationOptions): UseMobil onErrorRef.current?.(normalized) }, []) - const closeDictationAudio = useCallback( - (dictationId?: string | null) => { - acceptingChunksRef.current = false - pendingChunksRef.current.clear() - pendingAudioBudgetRef.current.reset() - try { - void capture.end() - } catch (err) { - // Cleanup must keep going when native recording shutdown throws, or - // the wake tag and dictation state would leak. - console.error('Failed to stop microphone recording', err) - } - void keepAwakeOwner.release(dictationId ?? undefined).catch(() => undefined) - }, - [capture, keepAwakeOwner] - ) + const closeDictationAudio = useCallback(() => { + acceptingChunksRef.current = false + pendingChunksRef.current.clear() + pendingAudioBudgetRef.current.reset() + try { + void capture.end() + } catch (err) { + // Cleanup must keep going when a synchronous seam throws, or the dictation state would leak. + console.error('Failed to stop microphone recording', err) + } + }, [capture]) const failActiveDictation = useCallback( (dictationId: string, err: unknown) => { @@ -87,7 +77,7 @@ export function useMobileDictation(options: UseMobileDictationOptions): UseMobil return } activeIdRef.current = null - closeDictationAudio(dictationId) + closeDictationAudio() if (client && dictationId) { void dictationSessionCancel.request(client, { dictationId }).catch(() => undefined) } @@ -175,7 +165,6 @@ export function useMobileDictation(options: UseMobileDictationOptions): UseMobil } }, setIdle: () => setStatus('idle'), - keepAwakeOwner, commitRecordingStart: () => { acceptingChunksRef.current = true pendingChunksRef.current.clear() @@ -193,7 +182,7 @@ export function useMobileDictation(options: UseMobileDictationOptions): UseMobil void capture.end() } }) - }, [capture, keepAwakeOwner]) + }, [capture]) const stop = useCallback(async () => { const client = clientRef.current @@ -258,19 +247,15 @@ export function useMobileDictation(options: UseMobileDictationOptions): UseMobil } } catch (err) { failActiveDictation(dictationId, err) - } finally { - // Hold the wake tag through chunk drain and the finish RPC: a screen - // lock mid-processing suspends the app and loses the transcript. - void keepAwakeOwner.release(dictationId).catch(() => undefined) } - }, [capture, failActiveDictation, keepAwakeOwner]) + }, [capture, failActiveDictation]) const cancel = useCallback(async () => { const client = clientRef.current const dictationId = activeIdRef.current generationRef.current += 1 activeIdRef.current = null - closeDictationAudio(dictationId) + closeDictationAudio() if (client && dictationId) { await dictationSessionCancel.request(client, { dictationId }).catch(() => undefined) } @@ -278,8 +263,6 @@ export function useMobileDictation(options: UseMobileDictationOptions): UseMobil setError(null) }, [closeDictationAudio]) - useMobileDictationForegroundKeepAwake(keepAwakeOwner, activeIdRef, capture.keepAwake) - useEffect(() => { const sub = capture.onInterruption(() => { void cancel() @@ -298,7 +281,7 @@ export function useMobileDictation(options: UseMobileDictationOptions): UseMobil const dictationId = activeIdRef.current generationRef.current += 1 activeIdRef.current = null - closeDictationAudio(dictationId) + closeDictationAudio() capture.release() if (clientRef.current && dictationId) { void dictationSessionCancel diff --git a/mobile/src/mobile-web-shell/bridge-host-init.test.ts b/mobile/src/mobile-web-shell/bridge-host-init.test.ts index d46ce232614..d588f518c59 100644 --- a/mobile/src/mobile-web-shell/bridge-host-init.test.ts +++ b/mobile/src/mobile-web-shell/bridge-host-init.test.ts @@ -51,8 +51,7 @@ describe('init and state', () => { 'native.media.release', 'native.audio.start', 'native.audio.read', - 'native.audio.stop', - 'native.wakelock.set' + 'native.audio.stop' ] }, route: ROUTE, diff --git a/mobile/src/mobile-web-shell/bridge/bridge-audio-verbs.test.ts b/mobile/src/mobile-web-shell/bridge/bridge-audio-verbs.test.ts index 3986fa83b06..ab58c1a72dc 100644 --- a/mobile/src/mobile-web-shell/bridge/bridge-audio-verbs.test.ts +++ b/mobile/src/mobile-web-shell/bridge/bridge-audio-verbs.test.ts @@ -11,7 +11,6 @@ import { MobileWebBundleRouteSchema } from '../../../../src/shared/mobile-web-bu import { MOBILE_DICTATION_MAX_PENDING_AUDIO_BYTES } from '../../hooks/mobile-dictation-pending-audio-budget' import { BridgeNativeVerbRefusedError } from '../bridge-host-errors' import { createNativeAudioCapture, type NativeAudioEngine } from '../../platform/native-audio' -import { createNativeWakelockServer } from '../../platform/native-wakelock' import { BRIDGE_AUDIO_READ_MAX_BASE64_CHARS, BRIDGE_AUDIO_RING_MAX_BYTES, @@ -19,17 +18,11 @@ import { audioReadResultSchema, audioStartParamsSchema, audioStopParamsSchema, - audioStopResultSchema, - wakelockSetParamsSchema + audioStopResultSchema } from './bridge-audio-verbs' import { BRIDGE_NATIVE_VERB_NAMES, BRIDGE_NATIVE_VERBS } from './bridge-native-verbs' -const AUDIO_VERBS = [ - 'native.audio.start', - 'native.audio.read', - 'native.audio.stop', - 'native.wakelock.set' -] as const +const AUDIO_VERBS = ['native.audio.start', 'native.audio.read', 'native.audio.stop'] as const /** An engine whose every call is a value a case can set, and whose events a case can fire. */ function createTestEngine( @@ -42,10 +35,9 @@ function createTestEngine( const microphone: ((bytes: Uint8Array) => void)[] = [] const interruptions: ((kind: 'began' | 'ended' | 'blocked') => void)[] = [] const log: string[] = [] - /** The screen lock's calls, apart from the engine's own: they are queued behind a microtask, so - * interleaving them with the synchronous ones would pin an order nothing depends on. */ + /** The screen lock's calls, apart from the engine's own: whether the capture is holding is a + * different question from what the engine was asked to do. */ const screen: string[] = [] - const screenTags = new Set() const engine: NativeAudioEngine = { requestPermission: overrides.permission ?? (async () => 'granted'), open: overrides.open ?? (async (sampleRate) => ({ opened: true, sampleRate })), @@ -54,16 +46,8 @@ function createTestEngine( log.push('end') }, screenLock: { - activate: (tag) => { - screen.push('+') - screenTags.add(tag) - return Promise.resolve() - }, - deactivate: (tag) => { - screen.push('-') - screenTags.add(tag) - return Promise.resolve() - } + hold: () => screen.push('+'), + release: () => screen.push('-') }, onMicrophoneData: (handler) => { microphone.push(handler) @@ -87,7 +71,6 @@ function createTestEngine( engine, log, screen, - screenTags, /** How many handlers the engine is still calling. One per live capture, or a leak. */ liveListeners: () => ({ microphone: microphone.length, interruptions: interruptions.length }), emit: (bytes: Uint8Array) => { @@ -121,7 +104,7 @@ function decode(base64: string): Uint8Array { } describe('the audio verbs in the table', () => { - it('lists all four, each under a name a manifest grant may carry', () => { + it('lists all three, each under a name a manifest grant may carry', () => { for (const verb of AUDIO_VERBS) { expect(BRIDGE_NATIVE_VERB_NAMES, verb).toContain(verb) expect(BRIDGE_NATIVE_VERBS[verb], verb).toBeDefined() @@ -166,15 +149,9 @@ describe('what the audio schemas refuse', () => { ) }) - it('refuses a stop carrying anything and a wakelock with no tag', () => { + it('refuses a stop carrying anything', () => { expect(audioStopParamsSchema.safeParse({ why: 'done' }).success).toBe(false) expect(audioStopParamsSchema.safeParse({}).success).toBe(true) - expect(wakelockSetParamsSchema.safeParse({ active: true }).success).toBe(false) - expect(wakelockSetParamsSchema.safeParse({ active: true, tag: '' }).success).toBe(false) - expect(wakelockSetParamsSchema.safeParse({ active: true, tag: 'x'.repeat(161) }).success).toBe( - false - ) - expect(wakelockSetParamsSchema.safeParse({ active: true, tag: 'orca' }).success).toBe(true) }) it('declares a base64 field a full drain still fits in', () => { @@ -508,161 +485,14 @@ describe('the shell capture', () => { }) }) -describe('the wake lock', () => { - it('holds a tag, answers what the device did, and gives it back', async () => { - const held: string[] = [] - const { serve } = createNativeWakelockServer({ - activate: async (tag) => { - held.push(`+${tag}`) - }, - deactivate: async (tag) => { - held.push(`-${tag}`) - } - }) - await expect(serve({ active: true, tag: 'orca-a' })).resolves.toEqual({ active: true }) - await expect(serve({ active: false, tag: 'orca-a' })).resolves.toEqual({ active: false }) - expect(held).toEqual(['+orca-a', '-orca-a']) - }) - - it('does not ask the device to drop a tag it never took', async () => { - const held: string[] = [] - const { serve } = createNativeWakelockServer({ - activate: async (tag) => { - held.push(`+${tag}`) - }, - deactivate: async (tag) => { - held.push(`-${tag}`) - } - }) - await expect(serve({ active: false, tag: 'orca-b' })).resolves.toEqual({ active: false }) - expect(held).toEqual([]) - }) - - it('reports a tag the device refused as not held', async () => { - const { serve } = createNativeWakelockServer({ - activate: async () => { - throw new Error('no keep-awake on this device') - }, - deactivate: async () => undefined - }) - await expect(serve({ active: true, tag: 'orca-c' })).rejects.toBeInstanceOf(Error) - }) - - it('keeps a tag recorded when the device refused to drop it, so a retry reaches the device', async () => { - // The page's owner queues a failed deactivation and retries it (`pendingCleanupTags` in - // `mobile-dictation-keep-awake.ts`). That retry arrives here as another `active: false`, and it - // has to reach the device: a shell that had already forgotten the tag answers "not held" - // without calling anything, and the native tag stays on for the life of the app. - const calls: string[] = [] - let refuse = true - const { serve } = createNativeWakelockServer({ - activate: async (tag) => { - calls.push(`+${tag}`) - }, - deactivate: async (tag) => { - calls.push(`-${tag}`) - if (refuse) { - throw new Error('the device would not drop the tag') - } - } - }) - await serve({ active: true, tag: 'orca-f' }) - // The refusal crosses, so the page's owner knows to queue a retry rather than believing it. - await expect(serve({ active: false, tag: 'orca-f' })).rejects.toBeInstanceOf(Error) - refuse = false - await expect(serve({ active: false, tag: 'orca-f' })).resolves.toEqual({ active: false }) - expect(calls).toEqual(['+orca-f', '-orca-f', '-orca-f']) - // And once it is really gone, a third release asks the device nothing. - await expect(serve({ active: false, tag: 'orca-f' })).resolves.toEqual({ active: false }) - expect(calls).toEqual(['+orca-f', '-orca-f', '-orca-f']) - }) - - it('keeps a tag a dispose could not drop, rather than forgetting it', async () => { - const calls: string[] = [] - const { serve, dispose } = createNativeWakelockServer({ - activate: async (tag) => { - calls.push(`+${tag}`) - }, - deactivate: async (tag) => { - calls.push(`-${tag}`) - throw new Error('the device would not drop the tag') - } - }) - await serve({ active: true, tag: 'orca-g' }) - dispose() - await Promise.resolve() - await Promise.resolve() - // Still recorded, so the owner's retry is still able to reach the device through this server. - await expect(serve({ active: false, tag: 'orca-g' })).rejects.toBeInstanceOf(Error) - expect(calls).toEqual(['+orca-g', '-orca-g', '-orca-g']) - }) - - it('gives back a tag whose activation landed after the session ended', async () => { - // The page is a document that can be swiped away mid-dictation, so a dispose can fall between - // the activate call and its reply. A tag recorded after that dispose is held by nobody and - // keeps the screen awake for the app's lifetime. - const held: string[] = [] - const gate: { release: () => void } = { release: () => {} } - const activated = new Promise((resolve) => { - gate.release = resolve - }) - const { serve, dispose } = createNativeWakelockServer({ - activate: async (tag) => { - await activated - held.push(`+${tag}`) - }, - deactivate: async (tag) => { - held.push(`-${tag}`) - } - }) - const pending = serve({ active: true, tag: 'orca-late' }) - dispose() - gate.release() - // Answered as not held, because by the time the device had it nobody wanted it. - await expect(pending).resolves.toEqual({ active: false }) - await Promise.resolve() - expect(held).toEqual(['+orca-late', '-orca-late']) - }) - - it('gives back every tag it still holds when the session ends', async () => { - const held: string[] = [] - const { serve, dispose } = createNativeWakelockServer({ - activate: async (tag) => { - held.push(`+${tag}`) - }, - deactivate: async (tag) => { - held.push(`-${tag}`) - } - }) - await serve({ active: true, tag: 'orca-d' }) - await serve({ active: true, tag: 'orca-e' }) - await serve({ active: false, tag: 'orca-d' }) - dispose() - await Promise.resolve() - // Only what was still held: a tag the page already gave back is not deactivated twice. - expect(held).toEqual(['+orca-d', '+orca-e', '-orca-d', '-orca-e']) - // And nothing is held afterwards, so a second dispose asks the device nothing. - dispose() - await Promise.resolve() - expect(held).toEqual(['+orca-d', '+orca-e', '-orca-d', '-orca-e']) - }) -}) - describe('the screen the shell holds awake while it is capturing', () => { - /** The lock's device calls are queued behind a microtask; a case reads them after they have run. */ - const flushScreen = () => new Promise((resolve) => setTimeout(resolve, 0)) - it('takes the screen on a start and gives it back on a stop', async () => { - const { engine, screen, screenTags } = createTestEngine() + const { engine, screen } = createTestEngine() const capture = createNativeAudioCapture(engine) await capture.serve('native.audio.start', { sampleRate: 16_000 }) - await flushScreen() expect(screen).toEqual(['+']) await capture.serve('native.audio.stop', {}) - await flushScreen() expect(screen).toEqual(['+', '-']) - // One tag, and the module's own: the page has no say in it and never names it. - expect(screenTags.size).toBe(1) }) it('gives it back when the page session ends with a capture still open', async () => { @@ -670,7 +500,6 @@ describe('the screen the shell holds awake while it is capturing', () => { const capture = createNativeAudioCapture(engine) await capture.serve('native.audio.start', { sampleRate: 16_000 }) capture.dispose() - await flushScreen() expect(screen).toEqual(['+', '-']) }) @@ -678,7 +507,6 @@ describe('the screen the shell holds awake while it is capturing', () => { const { engine, screen } = createTestEngine({ permission: async () => 'denied' }) const capture = createNativeAudioCapture(engine) await capture.serve('native.audio.start', { sampleRate: 16_000 }) - await flushScreen() expect(screen).toEqual([]) }) @@ -692,7 +520,6 @@ describe('the screen the shell holds awake while it is capturing', () => { await expect(capture.serve('native.audio.start', { sampleRate: 16_000 })).rejects.toThrow( 'would not start' ) - await flushScreen() // The throw leaves no capture behind, so it leaves no screen held either. expect(screen).toEqual(['+', '-']) }) @@ -702,12 +529,10 @@ describe('the screen the shell holds awake while it is capturing', () => { const capture = createNativeAudioCapture(engine) await capture.serve('native.audio.start', { sampleRate: 16_000 }) await capture.serve('native.audio.start', { sampleRate: 16_000 }) - await flushScreen() // The replacement ends the first capture and opens its own: one tag out at a time, never two // activations the second of which nothing will ever give back. expect(screen).toEqual(['+', '-', '+']) capture.dispose() - await flushScreen() expect(screen).toEqual(['+', '-', '+', '-']) }) }) diff --git a/mobile/src/mobile-web-shell/bridge/bridge-audio-verbs.ts b/mobile/src/mobile-web-shell/bridge/bridge-audio-verbs.ts index 8733b9df0c3..5a8bd94c078 100644 --- a/mobile/src/mobile-web-shell/bridge/bridge-audio-verbs.ts +++ b/mobile/src/mobile-web-shell/bridge/bridge-audio-verbs.ts @@ -2,16 +2,15 @@ import { z } from 'zod' import { MOBILE_DICTATION_MAX_PENDING_AUDIO_BYTES } from '../../hooks/mobile-dictation-pending-audio-budget' /** - * The wire shapes of `native.audio.start`, `native.audio.read`, `native.audio.stop` and - * `native.wakelock.set`. + * The wire shapes of `native.audio.start`, `native.audio.read` and `native.audio.stop`. * * Dictation is the page's, and the microphone is the shell's. The page holds the state machine the * composer renders and speaks `speech.dictation.*` to the desktop, so the only thing that has to - * cross is the capability: raw PCM, and the wake tag that keeps the screen alive while it is - * captured. That is why audio is pulled rather than pushed. The `request`/`reply` table is the only - * page-facing seam the shell has, the one shell-to-page push there is belongs to an RPC - * `subscribe`, and a push lane for bytes the page immediately hands back would be a new frame kind - * for no gain. + * cross is the capability: raw PCM. The screen the microphone holds awake does not cross at all — + * it is a property of the capture, taken and given back on the device side. That is why audio is + * pulled rather than pushed. The `request`/`reply` table is the only page-facing seam the shell + * has, the one shell-to-page push there is belongs to an RPC `subscribe`, and a push lane for bytes + * the page immediately hands back would be a new frame kind for no gain. * * So the shell rings what the microphone produces and the page drains it. The ring is exactly the * page's own pending-audio budget: the page already refuses to hold more unsent audio than that, @@ -87,11 +86,6 @@ export function bridgeAudioInterruptionEndsCapture(kind: string): boolean { return kind === 'began' || kind === 'blocked' } -/** The longest wake tag the shell will hold. The dictation tag is the owner id and the dictation id - * joined, both minted from a clock and a random suffix, so this is roughly twice the longest one - * this build can produce and short enough that a page cannot park text in the shell's tag set. */ -export const BRIDGE_WAKELOCK_TAG_MAX_CHARS = 160 - const BASE64_PATTERN = /^[A-Za-z0-9+/]*={0,2}$/ const sampleRateSchema = z @@ -168,13 +162,3 @@ export const audioStopResultSchema = z.strictObject({ base64: z.string().max(BRIDGE_AUDIO_READ_MAX_BASE64_CHARS).regex(BASE64_PATTERN).default(''), droppedBytes: z.number().int().nonnegative().default(0) }) - -export const wakelockSetParamsSchema = z.strictObject({ - active: z.boolean(), - tag: z.string().min(1).max(BRIDGE_WAKELOCK_TAG_MAX_CHARS) -}) - -/** Whether the tag is held after the call, which is what was asked for unless the device refused. - * Answered rather than assumed so the page's tag bookkeeping tracks the device and not its own - * intent — the same thing `activateKeepAwakeAsync` resolving tells the native owner. */ -export const wakelockSetResultSchema = z.strictObject({ active: z.boolean() }) diff --git a/mobile/src/mobile-web-shell/bridge/bridge-native-verbs.test.ts b/mobile/src/mobile-web-shell/bridge/bridge-native-verbs.test.ts index 54070832f31..b25016775f8 100644 --- a/mobile/src/mobile-web-shell/bridge/bridge-native-verbs.test.ts +++ b/mobile/src/mobile-web-shell/bridge/bridge-native-verbs.test.ts @@ -108,8 +108,7 @@ describe('the media verbs on the same seam', () => { 'native.media.release', 'native.audio.start', 'native.audio.read', - 'native.audio.stop', - 'native.wakelock.set' + 'native.audio.stop' ]) }) diff --git a/mobile/src/mobile-web-shell/bridge/bridge-native-verbs.ts b/mobile/src/mobile-web-shell/bridge/bridge-native-verbs.ts index 654055b779f..636dc401a9b 100644 --- a/mobile/src/mobile-web-shell/bridge/bridge-native-verbs.ts +++ b/mobile/src/mobile-web-shell/bridge/bridge-native-verbs.ts @@ -5,9 +5,7 @@ import { audioStartParamsSchema, audioStartResultSchema, audioStopParamsSchema, - audioStopResultSchema, - wakelockSetParamsSchema, - wakelockSetResultSchema + audioStopResultSchema } from './bridge-audio-verbs' import { mediaPickParamsSchema, @@ -44,8 +42,7 @@ export const BRIDGE_NATIVE_VERB_NAMES = [ 'native.media.release', 'native.audio.start', 'native.audio.read', - 'native.audio.stop', - 'native.wakelock.set' + 'native.audio.stop' ] as const export type BridgeNativeVerb = (typeof BRIDGE_NATIVE_VERB_NAMES)[number] @@ -115,8 +112,7 @@ export const BRIDGE_NATIVE_VERBS: Readonly { 'readClipboardText', 'readMedia', 'releaseMedia', - 'setWakelock', 'startAudio', 'stopAudio', 'writeClipboardText' diff --git a/mobile/src/mobile-web-shell/bridge/use-native-verbs.ts b/mobile/src/mobile-web-shell/bridge/use-native-verbs.ts index 25ce19ca75b..d37af544a1d 100644 --- a/mobile/src/mobile-web-shell/bridge/use-native-verbs.ts +++ b/mobile/src/mobile-web-shell/bridge/use-native-verbs.ts @@ -13,7 +13,6 @@ import { audioReadResultSchema, audioStartResultSchema, audioStopResultSchema, - wakelockSetResultSchema, type BridgeAudioChunk } from './bridge-audio-verbs' import { @@ -83,9 +82,6 @@ export type NativeVerbs = { * utterance no drain came back for. `stopped` is false for a session that was not capturing, * which is not a fault. */ stopAudio: () => Promise> - /** Whether the tag is held after the call. The shell asks the device nothing for a tag it never - * took, so releasing one twice is not a fault either. */ - setWakelock: (active: boolean, tag: string) => Promise } /** @@ -225,9 +221,7 @@ export function useNativeVerbs(): NativeVerbs { startAudio: (sampleRate) => call('native.audio.start', { sampleRate }, audioStartResultSchema), readAudio: (maxBytes) => call('native.audio.read', { maxBytes }, audioReadResultSchema), - stopAudio: () => call('native.audio.stop', {}, audioStopResultSchema), - setWakelock: async (active, tag) => - (await call('native.wakelock.set', { active, tag }, wakelockSetResultSchema)).active + stopAudio: () => call('native.audio.stop', {}, audioStopResultSchema) } }, [client]) } diff --git a/mobile/src/mobile-web-shell/page-route-policy.test.ts b/mobile/src/mobile-web-shell/page-route-policy.test.ts index 3b39f18f9f1..1300ec87e14 100644 --- a/mobile/src/mobile-web-shell/page-route-policy.test.ts +++ b/mobile/src/mobile-web-shell/page-route-policy.test.ts @@ -98,8 +98,7 @@ describe('the grants this app implements', () => { 'native.media.release', 'native.audio.start', 'native.audio.read', - 'native.audio.stop', - 'native.wakelock.set' + 'native.audio.stop' ]) }) diff --git a/mobile/src/platform/dictation-capture-bridge-budget.test.tsx b/mobile/src/platform/dictation-capture-bridge-budget.test.tsx index 23c583c9bee..1c6bdb4c56f 100644 --- a/mobile/src/platform/dictation-capture-bridge-budget.test.tsx +++ b/mobile/src/platform/dictation-capture-bridge-budget.test.tsx @@ -77,7 +77,8 @@ function createAudioShell() { } } }, - onInterruption: () => ({ remove: () => {} }) + onInterruption: () => ({ remove: () => {} }), + screenLock: { hold: () => {}, release: () => {} } } const capture = createNativeAudioCapture(engine) return { diff --git a/mobile/src/platform/dictation-capture-contract.ts b/mobile/src/platform/dictation-capture-contract.ts index 2ea31080c57..356b5aa6c2f 100644 --- a/mobile/src/platform/dictation-capture-contract.ts +++ b/mobile/src/platform/dictation-capture-contract.ts @@ -1,16 +1,17 @@ /** * Where dictation's audio comes from, as the hook that drives it sees it. * - * One seam, two hosts. Natively it is `@orca/expo-two-way-audio` and `expo-keep-awake` called - * directly; on the page it is `native.audio.start|read|stop` and `native.wakelock.set` over the - * bridge. Everything above it — the five composer states, the generation guards, the pending-audio - * budget, where a transcript is routed — is the same code on both, because the part that differs - * is the capability and the part that does not is the product. + * One seam, two hosts. Natively it is `@orca/expo-two-way-audio` called directly; on the page it is + * `native.audio.start|read|stop` over the bridge. Everything above it — the five composer states, + * the generation guards, the pending-audio budget, where a transcript is routed — is the same code + * on both, because the part that differs is the capability and the part that does not is the + * product. * - * The shape is the native one: a permission and an open, a start and a stop, two event lanes and a - * wake tag. That is deliberate. The page's pull is what `dictation-capture.web.ts` turns into these - * events, so the flow above the seam cannot tell which host it is on, and the native half is the - * calls it always made in the order it always made them. + * The shape is the native one: a permission and an open, a start and a stop, and two event lanes. + * That is deliberate. The page's pull is what `dictation-capture.web.ts` turns into these events, + * so the flow above the seam cannot tell which host it is on, and the native half is the calls it + * always made in the order it always made them. The screen is not here at all: an open microphone + * holds it on the device side, under both halves. */ /** @@ -43,13 +44,6 @@ export type DictationCaptureOpen = export type DictationCaptureSubscription = { readonly remove: () => void } -/** The two calls that keep the screen alive while a dictation runs, and nothing else: the tag - * bookkeeping, its retries and its timeouts are host-independent and stay above this. */ -export type DictationKeepAwakeDevice = { - readonly activate: (tag: string) => Promise - readonly deactivate: (tag: string) => Promise -} - export type DictationCapture = { /** Runs the OS permission prompt if there is one and brings the engine up. */ readonly open: () => Promise @@ -58,10 +52,10 @@ export type DictationCapture = { /** * Stops producing chunks, after handing over everything the capture still holds. * - * Asynchronous because of the page, where the audio lives in the shell's ring and the last one - * of them has to be fetched: up to one drain interval of the utterance's tail is sitting there - * when the user lifts the button, and no timer is coming for it. Natively that audio already - * reached the hook as it was produced, so there the promise is already resolved. + * Asynchronous because of the page, where the audio lives in the shell's ring and comes back on + * the stop's own reply: up to one drain interval of the utterance's tail is sitting there when + * the user lifts the button, and no timer is coming for it. Natively that audio already reached + * the hook as it was produced, so there the promise is already resolved. * * Never rejects. It runs on every exit including a throw, where a rejection would replace what * brought us here with a complaint about cleaning up after it. @@ -75,13 +69,12 @@ export type DictationCapture = { /** * The capture was taken away — a call, another app, a shell that no longer has one. * - * No argument, because what the flow does about any of them is the same: cancel, release the - * tag, tell the desktop. Natively this is `onAudioInterruption`'s `began` and `blocked`; on the + * No argument, because what the flow does about any of them is the same: cancel and tell the + * desktop. Natively this is `onAudioInterruption`'s `began` and `blocked`; on the * page it is the same two riding a `read` reply, plus a read the shell refused, which is a * capture that is gone by another name. */ readonly onInterruption: (handler: () => void) => DictationCaptureSubscription - readonly keepAwake: DictationKeepAwakeDevice } /** diff --git a/mobile/src/platform/dictation-capture.ts b/mobile/src/platform/dictation-capture.ts index c6ecda37f05..0ed8bb4b482 100644 --- a/mobile/src/platform/dictation-capture.ts +++ b/mobile/src/platform/dictation-capture.ts @@ -5,9 +5,8 @@ import { tearDown, toggleRecording } from '@orca/expo-two-way-audio' -import { activateKeepAwakeAsync, deactivateKeepAwake } from 'expo-keep-awake' import { bridgeAudioInterruptionEndsCapture } from '../mobile-web-shell/bridge/bridge-audio-verbs' -import { createMicrophoneScreenLock } from './microphone-screen-lock' +import { nativeMicrophoneScreenLock } from './native-audio-device' import type { DictationCapture } from './dictation-capture-contract' /** @@ -19,14 +18,8 @@ import type { DictationCapture } from './dictation-capture-contract' * the page can answer the same shape without the flow above knowing which it holds. */ -/** One tag for the one microphone this process has, minted here rather than asked for: the screen - * is a property of the capture, and nothing above this seam names it. */ -const NATIVE_DICTATION_SCREEN_LOCK_TAG = 'orca-native-microphone' - -const screen = createMicrophoneScreenLock( - { activate: activateKeepAwakeAsync, deactivate: deactivateKeepAwake }, - NATIVE_DICTATION_SCREEN_LOCK_TAG -) +/** The same lock the shell's capture holds: one microphone, one screen, one tag. */ +const screen = nativeMicrophoneScreenLock const nativeDictationCapture: DictationCapture = { open: async () => { @@ -85,8 +78,7 @@ const nativeDictationCapture: DictationCapture = { if (bridgeAudioInterruptionEndsCapture(event.data)) { handler() } - }), - keepAwake: { activate: activateKeepAwakeAsync, deactivate: deactivateKeepAwake } + }) } export function useDictationCapture(): DictationCapture { diff --git a/mobile/src/platform/dictation-capture.web.test.tsx b/mobile/src/platform/dictation-capture.web.test.tsx index 8f09b0da751..83e4556e745 100644 --- a/mobile/src/platform/dictation-capture.web.test.tsx +++ b/mobile/src/platform/dictation-capture.web.test.tsx @@ -31,13 +31,12 @@ import { import { NativeVerbError } from '../mobile-web-shell/bridge/use-native-verbs' import { MOBILE_DICTATION_PCM_SAMPLE_RATE } from '../hooks/mobile-dictation-pending-audio-budget' import { createNativeAudioCapture, type NativeAudioEngine } from './native-audio' -import { createNativeWakelockServer } from './native-wakelock' import { useDictationCapture } from './dictation-capture.web' import { DICTATION_CAPTURE_DRAIN_INTERVAL_MS } from './dictation-capture-contract' import type { BridgeNativeVerb } from '../mobile-web-shell/bridge/bridge-native-verbs' import type { DictationCapture, DictationCaptureChunk } from './dictation-capture-contract' -/** The four verbs, served by the real shell handlers over an engine a case drives. */ +/** The three verbs, served by the real shell handlers over an engine a case drives. */ function createAudioShell( options: { permission?: 'granted' | 'denied' | 'undetermined' @@ -67,13 +66,10 @@ function createAudioShell( interrupt = null } } - } + }, + screenLock: { hold: () => {}, release: () => {} } } const capture = createNativeAudioCapture(engine) - const { serve: wakelock } = createNativeWakelockServer({ - activate: async () => undefined, - deactivate: async () => undefined - }) const calls: string[] = [] return { calls, @@ -85,7 +81,7 @@ function createAudioShell( if (refusal !== null) { return Promise.reject(refusal) } - return verb === 'native.wakelock.set' ? wakelock(params) : capture.serve(verb, params) + return capture.serve(verb, params) } } } @@ -550,41 +546,6 @@ describe('a capture the page loses', () => { }) }) -describe('the wake tag on the page', () => { - it('takes and gives back a tag through the shell', async () => { - const shell = createAudioShell() - const pair = createFakeBridgePortPair({ serveNativeVerb: shell.serveNativeVerb }) - const capture = await mount(pair) - await expect(capture.keepAwake.activate('orca-mobile-dictation:1')).resolves.toBeUndefined() - await expect(capture.keepAwake.deactivate('orca-mobile-dictation:1')).resolves.toBeUndefined() - expect(shell.calls).toEqual(['native.wakelock.set', 'native.wakelock.set']) - }) - - it('rejects when the shell refuses the tag, so the owner can retry rather than believe it', async () => { - const shell = createAudioShell({ - refuse: (verb) => - verb === 'native.wakelock.set' - ? new BridgeNativeVerbRefusedError('native_verb_failed', 'no wake lock on this device') - : null - }) - const pair = createFakeBridgePortPair({ serveNativeVerb: shell.serveNativeVerb }) - const capture = await mount(pair) - await expect(capture.keepAwake.activate('orca-a')).rejects.toBeInstanceOf(NativeVerbError) - }) - - it('rejects when the route was never granted the wake lock', async () => { - const shell = createAudioShell() - const pair = createFakeBridgePortPair({ - serveNativeVerb: shell.serveNativeVerb, - routeGrants: ['navigate', 'native.audio.start', 'native.audio.read', 'native.audio.stop'] - }) - const capture = await mount(pair) - await expect(capture.keepAwake.activate('orca-a')).rejects.toSatisfy( - (error: unknown) => error instanceof NativeVerbError && error.reason === 'ungranted' - ) - }) -}) - describe('the rate the page asks for', () => { it('is the one the desktop transcribes at', async () => { const shell = createAudioShell() diff --git a/mobile/src/platform/dictation-capture.web.ts b/mobile/src/platform/dictation-capture.web.ts index d0ec498270c..2546bf6c8cd 100644 --- a/mobile/src/platform/dictation-capture.web.ts +++ b/mobile/src/platform/dictation-capture.web.ts @@ -174,15 +174,7 @@ export function createPageDictationCapture( void verbs.stopAudio().catch(() => undefined) }, onChunk: (handler) => subscribe(chunkHandlers, handler), - onInterruption: (handler) => subscribe(interruptionHandlers, handler), - keepAwake: { - activate: async (tag) => { - await verbs.setWakelock(true, tag) - }, - deactivate: async (tag) => { - await verbs.setWakelock(false, tag) - } - } + onInterruption: (handler) => subscribe(interruptionHandlers, handler) } } diff --git a/mobile/src/platform/microphone-screen-lock-census.test.ts b/mobile/src/platform/microphone-screen-lock-census.test.ts new file mode 100644 index 00000000000..992918930e4 --- /dev/null +++ b/mobile/src/platform/microphone-screen-lock-census.test.ts @@ -0,0 +1,104 @@ +/** + * Who may name the screen: one module, and nothing on the page. + * + * The screen a dictation holds awake is a property of the microphone, so the device module that + * opens the mic takes it and gives it back. Everything above that — the composer's five states, the + * pending-audio budget, where a transcript is routed — has no business naming it, and the page has + * no way to: the verb it used to ask through is gone, along with the tag pools, the timeouts and + * the foreground re-acquire that existed to keep the page's idea of the screen and the device's + * agreeing. + * + * A census rather than a rule in a reviewer's head, because the seam grew back twice from exactly + * the shape this forbids: a page that can ask for a wake tag acquires the bookkeeping to track what + * it asked for. Existence, not shape — any import of the package, and any mention of the verb's + * name at all, from any file but the one owner. + */ +import { readFileSync, readdirSync } from 'node:fs' +import { relative, join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { describe, expect, it } from 'vitest' + +const MOBILE_DIR = fileURLToPath(new URL('../../', import.meta.url)) + +/** The one module that may hold the screen: the device calls the microphone's capture makes. */ +const SCREEN_LOCK_OWNER = 'src/platform/native-audio-device.ts' + +/** This file names both things it forbids, so it reads every file but itself. */ +const CENSUS = 'src/platform/microphone-screen-lock-census.test.ts' + +const KEEP_AWAKE_PACKAGE = 'expo-keep-awake' + +/** Built rather than written, so the census does not contain the word it bans. */ +const RETIRED_VERB_WORD = `wake${'lock'}` + +function sourceFiles(): string[] { + const found: string[] = [] + const walk = (dir: string): void => { + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const full = join(dir, entry.name) + if (entry.isDirectory()) { + walk(full) + continue + } + if (entry.name.endsWith('.ts') || entry.name.endsWith('.tsx')) { + found.push(relative(MOBILE_DIR, full)) + } + } + } + for (const root of ['src', 'app']) { + walk(join(MOBILE_DIR, root)) + } + return found.sort() +} + +/** The line, not just the file: a census that answers "somewhere in 1,400 files" is one a reader + * has to re-run by hand before they can act on it. */ +function hits(match: (line: string) => boolean, skip: readonly string[]): string[] { + const found: string[] = [] + for (const file of sourceFiles()) { + if (skip.includes(file)) { + continue + } + const lines = readFileSync(join(MOBILE_DIR, file), 'utf8').split('\n') + for (const [index, line] of lines.entries()) { + if (match(line)) { + found.push(`${file}:${index + 1}: ${line.trim()}`) + } + } + } + return found +} + +/** An import of the package, which is a module that can hold the screen. A test's `vi.mock` of it + * is not one: the mock exists because the graph reaches the owner, which is the rule holding. */ +const importsKeepAwake = (line: string): boolean => + line.includes(`from '${KEEP_AWAKE_PACKAGE}'`) || line.includes(`require('${KEEP_AWAKE_PACKAGE}')`) + +const namesRetiredVerb = (line: string): boolean => + line.toLowerCase().includes(RETIRED_VERB_WORD.toLowerCase()) + +describe('the one module that may hold the screen awake', () => { + it('is the only one that imports the keep-awake package', () => { + expect(hits(importsKeepAwake, [SCREEN_LOCK_OWNER, CENSUS])).toEqual([]) + }) + + it('does import it, so the absence above is the rule holding and not the match missing', () => { + // The presence precondition every absence needs: the matcher finds the one file that should + // match, so a typo in the package name fails here rather than passing everywhere. + expect(hits(importsKeepAwake, [CENSUS]).map((hit) => hit.split(':')[0])).toEqual([ + SCREEN_LOCK_OWNER + ]) + }) + + it('leaves no page-facing name for the screen anywhere in the app', () => { + // The retired verb, in any casing: a grant row, a schema, a server, a tag, a mock or a comment + // pointing at a seam that no longer exists. + expect(hits(namesRetiredVerb, [CENSUS])).toEqual([]) + }) + + it('reads a tree big enough for those absences to mean something', () => { + // A walk that found nothing would pass all three rules above by having nothing to judge. + expect(sourceFiles().length).toBeGreaterThan(900) + expect(sourceFiles()).toContain(SCREEN_LOCK_OWNER) + }) +}) diff --git a/mobile/src/platform/native-audio-device.ts b/mobile/src/platform/native-audio-device.ts index 68ee2409201..966ec39af30 100644 --- a/mobile/src/platform/native-audio-device.ts +++ b/mobile/src/platform/native-audio-device.ts @@ -7,11 +7,11 @@ import { } from '@orca/expo-two-way-audio' import { activateKeepAwakeAsync, deactivateKeepAwake } from 'expo-keep-awake' import { BRIDGE_AUDIO_INTERRUPTIONS } from '../mobile-web-shell/bridge/bridge-audio-verbs' +import { createMicrophoneScreenLock } from './microphone-screen-lock' import type { NativeAudioEngine } from './native-audio' -import type { WakelockDevice } from './native-wakelock' /** - * The device calls the audio and wake-lock verbs actually make. + * The device calls the audio verbs actually make, and the screen an open microphone holds. * * Separated from the servers for the media device's reason: importing `@orca/expo-two-way-audio` * reaches a JSI binding that only exists in a device build, so a module naming it cannot be driven @@ -29,6 +29,22 @@ import type { WakelockDevice } from './native-wakelock' */ export const NATIVE_AUDIO_DEVICE_SAMPLE_RATE = 16_000 +/** One tag for the one microphone this process has. Module-private: the lock is taken by whichever + * device-side capture opened the mic, and no caller of either ever names it. */ +const MICROPHONE_SCREEN_LOCK_TAG = 'orca-microphone' + +/** + * The screen lock both device-side captures share, and the app's only reach for `expo-keep-awake`. + * + * One lock because there is one microphone: the native seam and the shell's `native.audio.*` + * handler are two doors to the same device, never open at once, and two tags would let one of them + * give back a screen the other still wants. + */ +export const nativeMicrophoneScreenLock = createMicrophoneScreenLock( + { activate: activateKeepAwakeAsync, deactivate: deactivateKeepAwake }, + MICROPHONE_SCREEN_LOCK_TAG +) + function readInterruption(data: string): (typeof BRIDGE_AUDIO_INTERRUPTIONS)[number] | null { return BRIDGE_AUDIO_INTERRUPTIONS.find((kind) => kind === data) ?? null } @@ -50,7 +66,7 @@ export const nativeAudioDeviceEngine: NativeAudioEngine = { toggleRecording(false) tearDown() }, - screenLock: { activate: activateKeepAwakeAsync, deactivate: deactivateKeepAwake }, + screenLock: nativeMicrophoneScreenLock, onMicrophoneData: (handler) => addExpoTwoWayAudioEventListener('onMicrophoneData', (event) => { const raw = event.data @@ -66,8 +82,3 @@ export const nativeAudioDeviceEngine: NativeAudioEngine = { } }) } - -export const nativeWakelockDevice: WakelockDevice = { - activate: (tag) => activateKeepAwakeAsync(tag), - deactivate: (tag) => deactivateKeepAwake(tag) -} diff --git a/mobile/src/platform/native-audio.ts b/mobile/src/platform/native-audio.ts index fbfbeb4f88d..ef9f2fef579 100644 --- a/mobile/src/platform/native-audio.ts +++ b/mobile/src/platform/native-audio.ts @@ -10,7 +10,7 @@ import { import type { BridgeNativeVerb } from '../mobile-web-shell/bridge/bridge-native-verbs' import { BridgeNativeVerbRefusedError } from '../mobile-web-shell/bridge-host-errors' import { bytesToBase64 } from '../hooks/mobile-dictation-session-state' -import { createMicrophoneScreenLock, type ScreenLockDevice } from './microphone-screen-lock' +import type { MicrophoneScreenLock } from './microphone-screen-lock' /** * The device side of `native.audio.start`, `read` and `stop`. @@ -38,7 +38,7 @@ export type NativeAudioEngine = { readonly end: () => void /** The screen, which an open microphone holds: a lock mid-capture suspends the app and takes the * audio with it. Injectable for the engine's own reason — `expo-keep-awake` is a device call. */ - readonly screenLock: ScreenLockDevice + readonly screenLock: MicrophoneScreenLock readonly onMicrophoneData: (handler: (bytes: Uint8Array) => void) => { remove: () => void } readonly onInterruption: (handler: (kind: BridgeAudioInterruption) => void) => { remove: () => void @@ -114,12 +114,8 @@ export type NativeAudioCapture = { readonly dispose: () => void } -/** One tag for the one capture a page session can have, minted here rather than asked for: the - * page has no say in the screen and never names it. */ -const NATIVE_AUDIO_SCREEN_LOCK_TAG = 'orca-shell-microphone' - export function createNativeAudioCapture(engine: NativeAudioEngine): NativeAudioCapture { - const screen = createMicrophoneScreenLock(engine.screenLock, NATIVE_AUDIO_SCREEN_LOCK_TAG) + const screen = engine.screenLock let capture: Capture | null = null let disposed = false /** diff --git a/mobile/src/platform/native-wakelock.test.ts b/mobile/src/platform/native-wakelock.test.ts deleted file mode 100644 index 6ff03fc8139..00000000000 --- a/mobile/src/platform/native-wakelock.test.ts +++ /dev/null @@ -1,140 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { createNativeWakelockServer, type WakelockDevice } from './native-wakelock' - -/** A device whose every call is resolved by the case, so an interleaving can be built by hand. */ -function createGatedDevice(): { - device: WakelockDevice - calls: string[] - settle: (call: string) => void - refuse: (call: string, error: Error) => void -} { - const calls: string[] = [] - const gates = new Map void; reject: (error: Error) => void }>() - const gate = (call: string): Promise => { - calls.push(call) - return new Promise((resolve, reject) => { - gates.set(call, { resolve: () => resolve(), reject }) - }) - } - return { - calls, - device: { - activate: (tag) => gate(`activate:${tag}`), - deactivate: (tag) => gate(`deactivate:${tag}`) - }, - settle: (call) => { - const pending = gates.get(call) - if (!pending) { - throw new Error(`nothing is waiting on ${call}`) - } - gates.delete(call) - pending.resolve() - }, - refuse: (call, error) => { - const pending = gates.get(call) - if (!pending) { - throw new Error(`nothing is waiting on ${call}`) - } - gates.delete(call) - pending.reject(error) - } - } -} - -/** Lets the gated calls above reach the awaits inside the server. */ -const settleMicrotasks = (): Promise => new Promise((resolve) => setTimeout(resolve, 0)) - -describe('a tag that lands after the session ended', () => { - it('keeps recording a tag whose compensating release the device refused', async () => { - // The set means "the device still has this tag". After `activate` resolves the device has it, - // so a compensating `deactivate` the device refuses must leave the tag recorded: nothing else - // walks the set once `dispose` has run, and a tag recorded nowhere is a screen that stays - // awake for the life of the app. The refusal reaches the caller so its retry path can run. - const gated = createGatedDevice() - const server = createNativeWakelockServer(gated.device) - const served = server.serve({ active: true, tag: 'orca-mobile-dictation:1:a' }) - await settleMicrotasks() - server.dispose() - gated.settle('activate:orca-mobile-dictation:1:a') - await settleMicrotasks() - gated.refuse( - 'deactivate:orca-mobile-dictation:1:a', - new Error('the device would not give the tag back') - ) - await expect(served).rejects.toThrow('the device would not give the tag back') - - // The tag is still recorded, so a later release reaches the device rather than answering - // "not held" without calling anything. - const release = server.serve({ active: false, tag: 'orca-mobile-dictation:1:a' }) - await settleMicrotasks() - expect(gated.calls).toEqual([ - 'activate:orca-mobile-dictation:1:a', - 'deactivate:orca-mobile-dictation:1:a', - 'deactivate:orca-mobile-dictation:1:a' - ]) - gated.settle('deactivate:orca-mobile-dictation:1:a') - await expect(release).resolves.toEqual({ active: false }) - }) -}) - -describe('a release issued while its own activate is still in flight', () => { - it('leaves the device off, because the last request said off', async () => { - // Without a queue the release reads `held` before the activate has recorded anything, finds - // nothing, deactivates nothing and reports `active: false` — and then the activate lands and - // the device stays on, holding a tag the page has already said it does not want. - const gated = createGatedDevice() - const server = createNativeWakelockServer(gated.device) - const activated = server.serve({ active: true, tag: 'orca-mobile-dictation:1:b' }) - await settleMicrotasks() - const released = server.serve({ active: false, tag: 'orca-mobile-dictation:1:b' }) - await settleMicrotasks() - gated.settle('activate:orca-mobile-dictation:1:b') - await settleMicrotasks() - await expect(activated).resolves.toEqual({ active: true }) - gated.settle('deactivate:orca-mobile-dictation:1:b') - await expect(released).resolves.toEqual({ active: false }) - expect(gated.calls).toEqual([ - 'activate:orca-mobile-dictation:1:b', - 'deactivate:orca-mobile-dictation:1:b' - ]) - }) - - it('orders two tags independently, so one slow device call cannot hold up another', async () => { - // The precondition the case above needs: the queue is per tag, not one chain for the server. - const gated = createGatedDevice() - const server = createNativeWakelockServer(gated.device) - const first = server.serve({ active: true, tag: 'orca-mobile-dictation:1:c' }) - const second = server.serve({ active: true, tag: 'orca-mobile-dictation:1:d' }) - await settleMicrotasks() - expect(gated.calls).toEqual([ - 'activate:orca-mobile-dictation:1:c', - 'activate:orca-mobile-dictation:1:d' - ]) - gated.settle('activate:orca-mobile-dictation:1:d') - await expect(second).resolves.toEqual({ active: true }) - gated.settle('activate:orca-mobile-dictation:1:c') - await expect(first).resolves.toEqual({ active: true }) - }) - - it('does not ask the device again for a tag a queued release already gave back', async () => { - // `dispose` queues behind the tag's own operations, so by the time it runs the release ahead - // of it may have returned the tag. Deactivating an unheld tag is a native call this module - // does not make: its failure would read to the page as a lock it could not drop. - const gated = createGatedDevice() - const server = createNativeWakelockServer(gated.device) - const activated = server.serve({ active: true, tag: 'orca-mobile-dictation:1:e' }) - await settleMicrotasks() - gated.settle('activate:orca-mobile-dictation:1:e') - await expect(activated).resolves.toEqual({ active: true }) - const released = server.serve({ active: false, tag: 'orca-mobile-dictation:1:e' }) - await settleMicrotasks() - server.dispose() - gated.settle('deactivate:orca-mobile-dictation:1:e') - await expect(released).resolves.toEqual({ active: false }) - await settleMicrotasks() - expect(gated.calls).toEqual([ - 'activate:orca-mobile-dictation:1:e', - 'deactivate:orca-mobile-dictation:1:e' - ]) - }) -}) diff --git a/mobile/src/platform/native-wakelock.ts b/mobile/src/platform/native-wakelock.ts deleted file mode 100644 index 0c785df0e67..00000000000 --- a/mobile/src/platform/native-wakelock.ts +++ /dev/null @@ -1,120 +0,0 @@ -import { wakelockSetParamsSchema } from '../mobile-web-shell/bridge/bridge-audio-verbs' - -/** - * The device side of `native.wakelock.set`, on the shell where `expo-keep-awake` exists. - * - * Dictation holds the screen awake from the moment recording starts until the transcript is back, - * because a screen lock mid-processing suspends the app and loses it. On the page that tag has to - * be asked for, which is this verb; natively the same seam calls `expo-keep-awake` directly. - * - * The shell tracks what it is holding for two reasons. A page that releases a tag it never took - * asks the device nothing, because `deactivateKeepAwake` on an unheld tag is a native call whose - * failure would read to the page as a wake lock it could not drop. And a page session that ends - * with a tag still held has it given back for it — the page is a document that can navigate, fault - * or be swiped away mid-dictation, and nothing else would ever call `deactivate`, so the screen - * would stay awake for the app's lifetime. - * - * So the set means "the device still has this tag", not "the page asked for it": a deactivation the - * device refused leaves the tag recorded, because the page's owner queues exactly that failure for - * a retry and the retry has to reach the device. - */ -export type WakelockDevice = { - readonly activate: (tag: string) => Promise - readonly deactivate: (tag: string) => Promise -} - -export type NativeWakelockServer = { - readonly serve: (params: unknown) => Promise<{ active: boolean }> - /** Gives back every tag this session still holds. The page session's end and the screen's - * unmount both call it, exactly as they do for a staged media handle and a live microphone. */ - readonly dispose: () => void -} - -export function createNativeWakelockServer(device: WakelockDevice): NativeWakelockServer { - const held = new Set() - /** - * One chain per tag, because `held` is read and written across an await. - * - * A release that arrives while its own activate is still in flight would otherwise read the set - * before the activate had recorded anything, find nothing, deactivate nothing and report the tag - * off — and then the activate lands and the device holds a tag the page has already said it does - * not want. Per tag rather than one chain for the server: a device call that hangs on one - * dictation's tag must not hold up another's. - */ - const queues = new Map>() - let disposed = false - - function enqueue(tag: string, action: () => Promise): Promise { - const previous = queues.get(tag) ?? Promise.resolve() - // On both settle paths: an activate the device refused must not wedge every later release. - const run = previous.then(action, action) - const settled = run.then( - () => undefined, - () => undefined - ) - queues.set(tag, settled) - void settled.then(() => { - // Dropped once nothing is behind it, so a screen's worth of dictations does not accumulate. - if (queues.get(tag) === settled) { - queues.delete(tag) - } - }) - return run - } - - async function set(active: boolean, tag: string): Promise<{ active: boolean }> { - if (active) { - await device.activate(tag) - // Recorded the moment the device has it, before anything else here can fail. The set means - // "the device still has this tag", and the compensating release below is the one path that - // could leave a tag on with nothing recorded. - held.add(tag) - if (!disposed) { - return { active: true } - } - // The session can end between the call and its reply — the page is a document that can be - // swiped away mid-dictation — and a tag recorded after that dispose is held by nobody: - // `dispose` has already walked the set and nothing will walk it again. So it is given back - // here instead, and the page is told it is not held. A refusal rejects rather than reporting - // a tag the device still holds as free, which is what lets the caller's retry path run. - await device.deactivate(tag) - held.delete(tag) - return { active: false } - } - if (held.has(tag)) { - // Deleted only once the device has really dropped it. A refusal rejects out of here, which - // is how the page's owner learns to queue a retry — and that retry arrives as another - // `active: false`, so the tag has to still be recorded or it would answer "not held" - // without calling anything and leave the native tag on for the life of the app. - await device.deactivate(tag) - held.delete(tag) - } - return { active: false } - } - - return { - // Parsed before the queue, so a malformed request is refused rather than waiting behind a tag. - serve: async (params) => { - const { active, tag } = wakelockSetParamsSchema.parse(params) - return await enqueue(tag, () => set(active, tag)) - }, - dispose: () => { - disposed = true - for (const tag of Array.from(held)) { - // Quiet, for the reason every other dispose here is: this runs while a screen is going - // away, and a device that would not drop a tag is not something the page can be told about. - // Forgotten only on success, so one this device refused stays recorded and a later release - // still reaches it. Queued behind that tag's own operations rather than racing them, and - // re-reading the set once it runs: a release already in flight may have given it back, and - // deactivating an unheld tag is a native call this module does not make. - void enqueue(tag, async () => { - if (!held.has(tag)) { - return - } - await device.deactivate(tag) - held.delete(tag) - }).catch(() => undefined) - } - } - } -} diff --git a/mobile/src/platform/use-native-device-verbs.test.tsx b/mobile/src/platform/use-native-device-verbs.test.tsx index 43dac446e7a..13146cde105 100644 --- a/mobile/src/platform/use-native-device-verbs.test.tsx +++ b/mobile/src/platform/use-native-device-verbs.test.tsx @@ -116,36 +116,34 @@ describe('the device handler the shell hands its host', () => { }) }) -describe('the wake tag a page session takes', () => { +describe('the screen a page session leaves behind', () => { beforeEach(() => { device.wakeTags.length = 0 }) - it('is given back when the session ends, not left holding the screen awake', async () => { + it('is given back when the session ends with the microphone still open', async () => { const first = mount('session-a') - await expect( - first.serve('native.wakelock.set', { active: true, tag: 'orca-a' }) - ).resolves.toEqual({ active: true }) - // The page is a document that can navigate, fault or be swiped away mid-dictation, so a tag it - // took and never released would keep the screen awake for the app's lifetime. - first.unmount() + await first.serve('native.audio.start', { sampleRate: 16_000 }) await Promise.resolve() - expect(device.wakeTags).toEqual(['+orca-a', '-orca-a']) + expect(device.wakeTags).toEqual(['+orca-microphone']) + // The page is a document that can navigate, fault or be swiped away mid-dictation, so a screen + // its capture took and never gave back would stay awake for the app's lifetime. + first.unmount() + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(device.wakeTags).toEqual(['+orca-microphone', '-orca-microphone']) }) - it('leaves the next session nothing of the last one to release', async () => { + it('leaves the next session nothing of the last one to give back', async () => { const first = mount('session-a') - await first.serve('native.wakelock.set', { active: true, tag: 'orca-a' }) + await first.serve('native.audio.start', { sampleRate: 16_000 }) first.unmount() - await Promise.resolve() + await new Promise((resolve) => setTimeout(resolve, 0)) device.wakeTags.length = 0 const second = mount('session-b') - // A tag the previous session held is the previous session's; this one asking for it back must - // not reach the device, and must not report it as held either. - await expect( - second.serve('native.wakelock.set', { active: false, tag: 'orca-a' }) - ).resolves.toEqual({ active: false }) - expect(device.wakeTags).toEqual([]) second.unmount() + await new Promise((resolve) => setTimeout(resolve, 0)) + // A session that never opened the microphone never took the screen, so its end asks the device + // for nothing: deactivating a tag nobody holds is a native call this build does not make. + expect(device.wakeTags).toEqual([]) }) }) diff --git a/mobile/src/platform/use-native-device-verbs.ts b/mobile/src/platform/use-native-device-verbs.ts index c0774ca9529..6a9ee03bdf4 100644 --- a/mobile/src/platform/use-native-device-verbs.ts +++ b/mobile/src/platform/use-native-device-verbs.ts @@ -2,19 +2,18 @@ import { useEffect, useMemo } from 'react' import type { BridgeNativeVerb } from '../mobile-web-shell/bridge/bridge-native-verbs' import { useMediaHandleRegistry } from '../mobile-web-shell/use-media-handle-registry' import { createNativeAudioCapture } from './native-audio' -import { nativeAudioDeviceEngine, nativeWakelockDevice } from './native-audio-device' +import { nativeAudioDeviceEngine } from './native-audio-device' import { serveNativeClipboardVerb } from './native-clipboard' import { createNativeMediaVerbServer } from './native-media' import { discardStagedMedia, nativeMediaDeviceDeps } from './native-media-device' -import { createNativeWakelockServer } from './native-wakelock' /** * Every `native.` verb this device serves, behind the one function the host dispatches to. * * Built here rather than in the screen because most of them are stateful where the clipboard ones - * are not: the media verbs hold staged files, the audio verbs hold a live microphone and the wake - * lock holds a tag, and all three have to be born and released with the page session. The screen - * passes a session id and gets a handler whose lifetime already matches it. + * are not: the media verbs hold staged files and the audio verbs hold a live microphone, and both + * have to be born and released with the page session. The screen passes a session id and gets a + * handler whose lifetime already matches it. */ export function useNativeDeviceVerbs( sessionId: string | null @@ -27,19 +26,14 @@ export function useNativeDeviceVerbs( // Keyed on the session for the registry's reason: a new page session is a new document, and a // microphone the previous one left open is nobody's to stop but this seam's. const audio = useMemo(() => createNativeAudioCapture(nativeAudioDeviceEngine), [sessionId]) - const wakelock = useMemo(() => createNativeWakelockServer(nativeWakelockDevice), [sessionId]) useEffect(() => () => audio.dispose(), [audio]) - useEffect(() => () => wakelock.dispose(), [wakelock]) return useMemo( () => (verb, params) => { if (verb === 'native.clipboard.write' || verb === 'native.clipboard.read') { return serveNativeClipboardVerb(verb, params) } - if (verb === 'native.wakelock.set') { - return wakelock.serve(params) - } return verb.startsWith('native.audio.') ? audio.serve(verb, params) : serveMedia(verb, params) }, - [audio, serveMedia, wakelock] + [audio, serveMedia] ) } diff --git a/mobile/src/session/mobile-dictation-mic-control.web.test.tsx b/mobile/src/session/mobile-dictation-mic-control.web.test.tsx index 6549cd594a8..09654e60662 100644 --- a/mobile/src/session/mobile-dictation-mic-control.web.test.tsx +++ b/mobile/src/session/mobile-dictation-mic-control.web.test.tsx @@ -52,7 +52,6 @@ import { type BridgePortPair } from '../mobile-web-shell/bridge/bridge-port-pair-test-harness' import { createNativeAudioCapture, type NativeAudioEngine } from '../platform/native-audio' -import { createNativeWakelockServer } from '../platform/native-wakelock' import { useMobileDictation } from '../hooks/use-mobile-dictation' import { MobileTerminalInputActions } from './MobileTerminalInputActions' import type { BridgeNativeVerb } from '../mobile-web-shell/bridge/bridge-native-verbs' @@ -60,7 +59,7 @@ import type { BridgeNativeVerb } from '../mobile-web-shell/bridge/bridge-native- /** Every message that reached the composer's own error handler, which is what it toasts. */ const reported: string[] = [] -/** The four verbs served by the real handlers over an engine that opens and produces no audio. */ +/** The three verbs served by the real handlers over an engine that opens and produces no audio. */ function createAudioShell(): (verb: BridgeNativeVerb, params: unknown) => Promise { const engine: NativeAudioEngine = { requestPermission: async () => 'granted', @@ -68,15 +67,11 @@ function createAudioShell(): (verb: BridgeNativeVerb, params: unknown) => Promis begin: () => true, end: () => {}, onMicrophoneData: () => ({ remove: () => {} }), - onInterruption: () => ({ remove: () => {} }) + onInterruption: () => ({ remove: () => {} }), + screenLock: { hold: () => {}, release: () => {} } } const capture = createNativeAudioCapture(engine) - const { serve: wakelock } = createNativeWakelockServer({ - activate: async () => undefined, - deactivate: async () => undefined - }) - return (verb, params) => - verb === 'native.wakelock.set' ? wakelock(params) : capture.serve(verb, params) + return (verb, params) => capture.serve(verb, params) } function Composer({ pair }: { pair: BridgePortPair }): ReactElement { @@ -215,7 +210,7 @@ describe('the mic control on a page the shell did grant audio', () => { expect(mic.label()).toBe('Stop voice dictation') }) - it('opens the capture and takes the wake tag through the shell', async () => { + it('opens the capture through the shell, and asks it for nothing else', async () => { const pair = createFakeBridgePortPair({ serveNativeVerb: createAudioShell() }) const mic = await mount(pair) await mic.tap() @@ -225,7 +220,9 @@ describe('the mic control on a page the shell did grant audio', () => { frame.type === 'request' && frame.method.startsWith('native.') ? [frame.method] : [] ) expect(verbs).toContain('native.audio.start') - expect(verbs).toContain('native.wakelock.set') + // Only the microphone: the screen it holds awake is the device's, and the page has no verb for + // it to ask through. + expect(verbs.every((verb) => verb.startsWith('native.audio.'))).toBe(true) // And the desktop was told, which is what makes the recording a session rather than a mic. expect( pair diff --git a/mobile/src/test-support/rpc-recording/adapters/dictation-mount-adapters.ts b/mobile/src/test-support/rpc-recording/adapters/dictation-mount-adapters.ts index 104cd5367df..1769540beec 100644 --- a/mobile/src/test-support/rpc-recording/adapters/dictation-mount-adapters.ts +++ b/mobile/src/test-support/rpc-recording/adapters/dictation-mount-adapters.ts @@ -1,28 +1,10 @@ -import type { MountAdapter, MountContext } from '../recording-scenario' +import type { MountAdapter } from '../recording-scenario' import { hookMount, performHookAction } from '../hook-mount' import type { operationModuleLoader } from '../operation-module-loader' const DICTATION_ID = 'dictation-1' const MODEL_ID = 'whisper-small' -/** The keep-awake owner the desktop-start flow serializes against; acquire/release are observed. */ -function keepAwakeOwner(effect: MountContext['effect']) { - return { - acquire: (id: string) => { - effect('keep-awake-acquire', { id }) - return Promise.resolve() - }, - release: (id?: string) => { - effect('keep-awake-release', { id: id ?? null }) - return Promise.resolve() - }, - reacquire: (id: string) => { - effect('keep-awake-reacquire', { id }) - return Promise.resolve() - } - } -} - /** * The dictation setup sheet's four senders, the desktop session handshake, one audio chunk, and * the session hook that owns finish and cancel. @@ -95,10 +77,6 @@ export function dictationMountAdapters( setIdle: () => { idle = true }, - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the recorder supplies only the owner members the start flow calls. - keepAwakeOwner: keepAwakeOwner(effect) as unknown as Parameters< - typeof start - >[0]['keepAwakeOwner'], commitRecordingStart: () => args.recording !== false, rollbackRecordingStart: () => effect('rollback-recording', {}) }).then((value: unknown) => { diff --git a/mobile/web-entry/web-overrides.json b/mobile/web-entry/web-overrides.json index b975fa59aa2..0bfe24d8fdb 100644 --- a/mobile/web-entry/web-overrides.json +++ b/mobile/web-entry/web-overrides.json @@ -115,7 +115,7 @@ }, { "file": "src/platform/dictation-capture.web.ts", - "reason": "Dictation's capture seam. The native file holds the microphone through @orca/expo-two-way-audio and the wake tag through expo-keep-awake, neither of which a browser has; this one asks the shell for both over native.audio.start|read|stop and native.wakelock.set, draining the shell's ring on a timer and raising each reply as the events the engine emits directly. The flow above the seam is one file on both hosts." + "reason": "Dictation's capture seam. The native file holds the microphone through @orca/expo-two-way-audio, which a browser does not have; this one asks the shell for it over native.audio.start|read|stop, draining the shell's ring on a timer and raising each reply as the events the engine emits directly, and taking the utterance's tail off the stop's own reply. The screen an open microphone holds awake is not in this seam at all: the device module that opens the mic takes it and gives it back on both hosts. The flow above the seam is one file on both hosts." }, { "file": "src/platform/media-picker.web.ts",