Prevent mobile screen locking during voice dictation. (#7746)

* Prevent mobile screen locking during voice dictation

Integrate expo-keep-awake to prevent the mobile device from locking or
sleeping while a voice dictation session is active.

- Modularize useMobileDictation logic into separate helper files for
  keep-awake, audio chunking, session state, and desktop startup.
- Acquire keep-awake lock only after successfully establishing a
  desktop session to avoid locking on stale start attempts.
- Release the keep-awake lock on all completion, cancellation, error,
  and unmount paths.
- Add source invariant unit tests to verify keep-awake ownership and
  strict cleanup ordering.

* serialize keep-awake operations and avoid stale dictation start races

- Implement a global execution queue and tag tracking for keep-awake
  operations to prevent concurrent races and stale deactivations.
- Track failed native deactivations and retry them when a replacement
  hook owner mounts or starts a new dictation session.
- Ensure stale or canceled desktop dictation starts do not reset the
  UI state or propagate outdated start/keep-awake failures.
- Reuse the audio chunk queue wiring in useMobileDictation to avoid
  allocating new closure objects on the high-frequency microphone path.
- Add comprehensive unit tests for the keep-awake and desktop start hooks.

* Commit native recording during dictation session startup

Commit native recording in the same continuation as the final session
stale check. This prevents a queued cancellation from resurrecting the
microphone recording after cleanup has already run. If microphone
initialization fails or throws, acquired resources (like keep-awake
locks and the remote desktop session) are properly rolled back.

* Make keep-awake acquisition best-effort with a bounded startup timeout

- Recording start no longer blocks (or fails) on keep-awake acquisition:
  a hung or failing native call is capped at a short budget and logged
  instead of delaying or aborting dictation.
- Add native-call timeouts, orphan-tag tracking, and reacquire/drain
  logic in mobile-dictation-keep-awake.ts so Activity recreation on
  Android and stale tags no longer wedge the keep-awake queue.
- Add useMobileDictationForegroundKeepAwake to refresh the wake tag on
  Android foreground and retry failed refreshes/deactivations.
- Hold the wake tag through chunk drain and the finish RPC so a screen
  lock can't suspend the app before the transcript arrives, and keep
  cleanup running even if native recording shutdown throws.
- Loosen expo-keep-awake to a caret range to unblock the patch pulling
  in these native fixes.

* Fix cancellation races in mobile dictation keep-awake handling

- Run wake-lock release and dictation cancel concurrently on stale
  starts so a hung acquisition no longer delays the native cancel
- Guard foreground reacquire retries with a run token so a stale
  retry chain can't deactivate a wake lock reacquired by a newer
  AppState transition

* Update source invariant test for concurrent stale-start cleanup

Co-authored-by: Orca <help@stably.ai>

---------

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinjing 2026-07-10 23:31:56 -07:00 committed by GitHub
parent 931e402e93
commit 2e48495273
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 1594 additions and 130 deletions

View File

@ -35,6 +35,7 @@
"expo-haptics": "^55.0.14",
"expo-image-manipulator": "^55.0.17",
"expo-image-picker": "^55.0.20",
"expo-keep-awake": "~55.0.8",
"expo-linking": "^55.0.15",
"expo-modules-core": "~55.0.25",
"expo-network": "~55.0.14",

View File

@ -65,6 +65,9 @@ importers:
expo-image-picker:
specifier: ^55.0.20
version: 55.0.20(expo@55.0.27)
expo-keep-awake:
specifier: ~55.0.8
version: 55.0.8(expo@55.0.27)(react@19.2.6)
expo-linking:
specifier: ^55.0.15
version: 55.0.15(expo@55.0.27)(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)

View File

@ -0,0 +1,52 @@
import {
MOBILE_DICTATION_CONNECTION_SLOW_ERROR_MESSAGE,
MOBILE_DICTATION_PCM_SAMPLE_RATE
} from './mobile-dictation-pending-audio-budget'
import { bytesToBase64 } from './mobile-dictation-session-state'
import type { MicrophoneDataEvent } from '@orca/expo-two-way-audio'
import type { MobileDictationPendingAudioBudget } from './mobile-dictation-pending-audio-budget'
import type { RpcClient } from '../transport/rpc-client'
type MobileDictationAudioChunkQueue = {
pendingChunks: Set<Promise<void>>
pendingAudioBudget: MobileDictationPendingAudioBudget
shouldReleaseBudget: (dictationId: string) => boolean
failActiveDictation: (dictationId: string, err: unknown) => void
}
export function enqueueMobileDictationAudioChunk(
client: RpcClient,
dictationId: string,
event: MicrophoneDataEvent,
queue: MobileDictationAudioChunkQueue
): void {
const raw = event.data
const bytes = raw instanceof Uint8Array ? raw : new Uint8Array(raw)
const byteLength = bytes.byteLength
if (!queue.pendingAudioBudget.tryReserve(byteLength)) {
queue.failActiveDictation(
dictationId,
new Error(MOBILE_DICTATION_CONNECTION_SLOW_ERROR_MESSAGE)
)
return
}
const sendChunk = client
.sendRequest('speech.dictation.chunk', {
dictationId,
audioBase64: bytesToBase64(bytes),
sampleRate: MOBILE_DICTATION_PCM_SAMPLE_RATE
})
.then((response) => {
if (!response.ok) {
throw new Error(response.error.message)
}
})
.catch((err) => queue.failActiveDictation(dictationId, err))
.finally(() => {
if (queue.shouldReleaseBudget(dictationId)) {
queue.pendingAudioBudget.release(byteLength)
}
queue.pendingChunks.delete(sendChunk)
})
queue.pendingChunks.add(sendChunk)
}

View File

@ -0,0 +1,231 @@
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<unknown>
acquire?: () => Promise<void>
commitRecordingStart?: () => boolean
}
function createStartHarness(options: StartHarnessOptions = {}) {
let generation = 1
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: {
client,
dictationId: 'dictation-a',
generation: 1,
getCurrentGeneration: () => generation,
getEnabled: () => enabled,
getActiveId: () => activeId,
clearActiveId: (dictationId: string) => {
if (activeId === dictationId) {
activeId = null
}
},
setIdle,
keepAwakeOwner,
commitRecordingStart,
rollbackRecordingStart
},
setNewerStart: () => {
generation = 2
activeId = 'dictation-b'
},
setDisabled: () => {
enabled = false
},
getActiveId: () => activeId,
setIdle,
release,
sendRequest,
commitRecordingStart,
rollbackRecordingStart
}
}
describe('startMobileDictationDesktopSession', () => {
it('does not reset UI state when a newer start supersedes keep-awake acquisition', async () => {
let setNewerStart = () => undefined
const harness = createStartHarness({
acquire: async () => setNewerStart()
})
setNewerStart = harness.setNewerStart
await expect(startMobileDictationDesktopSession(harness.options)).resolves.toBe(false)
expect(harness.setIdle).not.toHaveBeenCalled()
expect(harness.getActiveId()).toBe('dictation-b')
expect(harness.release).toHaveBeenCalledWith('dictation-a')
expect(harness.commitRecordingStart).not.toHaveBeenCalled()
})
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<void>(() => undefined)
}
})
goStale = harness.setNewerStart
// Release queues behind the still-running acquisition, so it never settles.
harness.release.mockReturnValue(new Promise<void>(() => 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 () => {
let setDisabled = () => undefined
const harness = createStartHarness({
acquire: async () => setDisabled()
})
setDisabled = harness.setDisabled
await expect(startMobileDictationDesktopSession(harness.options)).resolves.toBe(false)
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<void>(() => 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({
sendRequest: async (method) => {
if (method === 'speech.dictation.start') {
setNewerStart()
throw new Error('Desktop start failed')
}
return OK_RESPONSE
}
})
setNewerStart = harness.setNewerStart
await expect(startMobileDictationDesktopSession(harness.options)).resolves.toBe(false)
expect(harness.setIdle).not.toHaveBeenCalled()
expect(harness.getActiveId()).toBe('dictation-b')
expect(harness.commitRecordingStart).not.toHaveBeenCalled()
})
it('commits recording before returning a current start to the hook', async () => {
const harness = createStartHarness()
await expect(startMobileDictationDesktopSession(harness.options)).resolves.toBe(true)
expect(harness.commitRecordingStart).toHaveBeenCalledOnce()
expect(harness.rollbackRecordingStart).not.toHaveBeenCalled()
})
it('cleans up the keep-awake tag and desktop session when native recording throws', async () => {
const harness = createStartHarness({
commitRecordingStart: () => {
throw new Error('Audio focus request failed')
}
})
await expect(startMobileDictationDesktopSession(harness.options)).rejects.toThrow(
'Audio focus request failed'
)
expect(harness.release).toHaveBeenCalledWith('dictation-a')
expect(harness.sendRequest).toHaveBeenCalledWith('speech.dictation.cancel', {
dictationId: 'dictation-a'
})
expect(harness.getActiveId()).toBeNull()
expect(harness.setIdle).toHaveBeenCalledOnce()
expect(harness.rollbackRecordingStart).toHaveBeenCalledOnce()
})
it('rejects and cleans up when the native recorder does not start', async () => {
const harness = createStartHarness({ commitRecordingStart: () => false })
await expect(startMobileDictationDesktopSession(harness.options)).rejects.toThrow(
'Failed to start microphone recording'
)
expect(harness.release).toHaveBeenCalledWith('dictation-a')
expect(harness.sendRequest).toHaveBeenCalledWith('speech.dictation.cancel', {
dictationId: 'dictation-a'
})
expect(harness.getActiveId()).toBeNull()
expect(harness.setIdle).toHaveBeenCalledOnce()
expect(harness.rollbackRecordingStart).toHaveBeenCalledOnce()
})
})

View File

@ -0,0 +1,142 @@
import {
MOBILE_DICTATION_KEEP_AWAKE_STARTUP_BUDGET_MS,
isCurrentMobileDictationStart
} from './mobile-dictation-session-state'
import type { MobileDictationKeepAwakeOwner } from './mobile-dictation-keep-awake'
import type { RpcClient } from '../transport/rpc-client'
type StartMobileDictationDesktopSessionOptions = {
client: RpcClient
dictationId: string
generation: number
getCurrentGeneration: () => number
getEnabled: () => boolean
getActiveId: () => string | null
clearActiveId: (dictationId: string) => void
setIdle: () => void
keepAwakeOwner: MobileDictationKeepAwakeOwner
commitRecordingStart: () => boolean
rollbackRecordingStart: () => void
}
function isCurrentStart(options: StartMobileDictationDesktopSessionOptions): boolean {
return isCurrentMobileDictationStart(
options.getCurrentGeneration(),
options.generation,
options.getEnabled(),
options.getActiveId(),
options.dictationId
)
}
function canReportStartFailure(options: StartMobileDictationDesktopSessionOptions): boolean {
return options.getCurrentGeneration() === options.generation && options.getEnabled()
}
function setIdleIfGenerationCurrent(options: StartMobileDictationDesktopSessionOptions): void {
if (options.getCurrentGeneration() === options.generation) {
options.setIdle()
}
}
// 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<void> {
const { client, dictationId, keepAwakeOwner } = options
options.clearActiveId(dictationId)
setIdleIfGenerationCurrent(options)
const cleanups: Promise<unknown>[] = [
client.sendRequest('speech.dictation.cancel', { dictationId })
]
if (releaseKeepAwake) {
cleanups.push(keepAwakeOwner.release(dictationId))
}
await Promise.allSettled(cleanups)
}
export async function startMobileDictationDesktopSession(
options: StartMobileDictationDesktopSessionOptions
): Promise<boolean> {
const { client, dictationId, keepAwakeOwner } = options
try {
const response = await client.sendRequest('speech.dictation.start', { dictationId })
if (!response.ok) {
throw new Error(response.error.message)
}
} catch (err) {
const wasCurrent = isCurrentStart(options)
options.clearActiveId(dictationId)
await client.sendRequest('speech.dictation.cancel', { dictationId }).catch(() => undefined)
// Awaited cleanup may overlap a newer start; stale work must not reset or
// report over the replacement session.
const shouldReport = wasCurrent && canReportStartFailure(options)
setIdleIfGenerationCurrent(options)
if (!shouldReport) {
return false
}
throw err
}
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<void>((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 })
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.
if (!options.commitRecordingStart()) {
throw new Error('Failed to start microphone recording')
}
} catch (err) {
const wasCurrent = isCurrentStart(options)
// Native recording can partially start before throwing, so stop audio before
// releasing the wake tag and remote session.
try {
options.rollbackRecordingStart()
} catch {
// Continue releasing independently owned resources after native audio failure.
}
options.clearActiveId(dictationId)
await Promise.allSettled([
keepAwakeOwner.release(dictationId),
client.sendRequest('speech.dictation.cancel', { dictationId })
])
const shouldReport = wasCurrent && canReportStartFailure(options)
setIdleIfGenerationCurrent(options)
if (!shouldReport) {
return false
}
throw err
}
return true
}

View File

@ -0,0 +1,70 @@
import { useEffect } from 'react'
import { AppState, Platform } from 'react-native'
import { drainMobileDictationKeepAwakeCleanup } from './mobile-dictation-keep-awake'
import type { RefObject } from 'react'
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
// 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(): void {
if (globalStaleTagDrainInstalled) {
return
}
globalStaleTagDrainInstalled = true
AppState.addEventListener('change', (state) => {
if (state === 'active') {
void drainMobileDictationKeepAwakeCleanup().catch(() => undefined)
}
})
}
export function useMobileDictationForegroundKeepAwake(
keepAwakeOwner: MobileDictationKeepAwakeOwner,
activeIdRef: RefObject<string | null>
): void {
useEffect(() => {
installGlobalStaleTagForegroundDrain()
// 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])
}

View File

@ -0,0 +1,442 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const keepAwake = vi.hoisted(() => ({
activate: vi.fn<(tag: string) => Promise<void>>(),
deactivate: vi.fn<(tag: string) => Promise<void>>()
}))
vi.mock('expo-keep-awake', () => ({
activateKeepAwakeAsync: keepAwake.activate,
deactivateKeepAwake: keepAwake.deactivate
}))
import {
MOBILE_DICTATION_KEEP_AWAKE_NATIVE_TIMEOUT_MS,
MobileDictationKeepAwakeOwner,
drainMobileDictationKeepAwakeCleanup
} from './mobile-dictation-keep-awake'
function deferred(): {
promise: Promise<void>
resolve: () => void
reject: (error: Error) => void
} {
let resolvePromise: (() => void) | undefined
let rejectPromise: ((error: Error) => void) | undefined
const promise = new Promise<void>((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()
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()
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()
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()
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()
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()
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()
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<void>(() => undefined))
const hungOwner = new MobileDictationKeepAwakeOwner()
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()
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()
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()
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()
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()
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()
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<void>(() => 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()
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<void>(() => undefined))
const owner = new MobileDictationKeepAwakeOwner()
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()
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()
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()
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()
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<void>(() => undefined))
const owner = new MobileDictationKeepAwakeOwner()
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()
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()
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()
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')
})
})

View File

@ -0,0 +1,236 @@
import { activateKeepAwakeAsync, deactivateKeepAwake } from 'expo-keep-awake'
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<void> = Promise.resolve()
const activeTags = new Set<string>()
const pendingCleanupTags = new Set<string>()
// Timed-out activations that may still land natively, keyed to a predicate
// saying whether their dictation still wants the tag.
const pendingActivations = new Map<string, () => boolean>()
function createOwnerId(): string {
nextOwnerId += 1
return `${Date.now()}-${nextOwnerId}-${Math.random().toString(36).slice(2)}`
}
function enqueueKeepAwakeOperation(action: () => Promise<void>): Promise<void> {
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<void>): Promise<void> {
return new Promise<void>((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(tag: string, isStillWanted: () => boolean): Promise<void> {
const nativeActivation = activateKeepAwakeAsync(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(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(tag: string): Promise<void> {
try {
await withNativeCallTimeout(deactivateKeepAwake(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(): Promise<void> {
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(tag).catch(() => undefined))
)
}
export class MobileDictationKeepAwakeOwner {
private readonly ownerId = createOwnerId()
private acquiredTag: string | null = null
acquire(dictationId: string): Promise<void> {
const tag = this.createTag(dictationId)
return enqueueKeepAwakeOperation(async () => {
await cleanupPendingTags()
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(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(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<void> {
const tag = this.createTag(dictationId)
return enqueueKeepAwakeOperation(async () => {
await cleanupPendingTags()
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(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(tag, () => this.acquiredTag === tag)
})
}
release(dictationId?: string): Promise<void> {
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(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()
}
})
}
private createTag(dictationId: string): string {
return `${MOBILE_DICTATION_KEEP_AWAKE_TAG_PREFIX}:${this.ownerId}:${dictationId}`
}
}
export function createMobileDictationKeepAwakeOwner(): MobileDictationKeepAwakeOwner {
return new MobileDictationKeepAwakeOwner()
}
// 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(): Promise<void> {
return enqueueKeepAwakeOperation(cleanupPendingTags)
}

View File

@ -0,0 +1,65 @@
// Why: import from 'buffer' (the npm polyfill), not 'node:buffer' because
// Metro cannot resolve Node builtins in a React Native bundle.
import { Buffer } from 'buffer'
import type { RpcClient } from '../transport/rpc-client'
export type DictationStatus = 'idle' | 'starting' | 'recording' | 'processing' | 'error'
export type UseMobileDictationOptions = {
client: RpcClient | null
enabled: boolean
onTranscript: (text: string) => void
onError?: (error: Error) => void
}
export type UseMobileDictationResult = {
status: DictationStatus
isStarting: boolean
isRecording: boolean
isProcessing: boolean
error: string | null
start: () => Promise<void>
stop: () => Promise<void>
cancel: () => Promise<void>
}
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')
}
export function createMobileDictationId(): string {
return `mobile-dictation-${Date.now()}-${Math.random().toString(36).slice(2)}`
}
export function isCurrentMobileDictationStart(
currentGeneration: number,
generation: number,
enabled: boolean,
activeId: string | null,
dictationId: string
): boolean {
return currentGeneration === generation && enabled && activeId === dictationId
}
export function isCurrentMobileDictationFinish(
currentGeneration: number,
generation: number,
enabled: boolean,
activeId: string | null,
finishingId: string | null,
dictationId: string
): boolean {
return (
currentGeneration === generation &&
enabled &&
activeId === dictationId &&
finishingId === dictationId
)
}

View File

@ -2,13 +2,41 @@ import { readFileSync } from 'node:fs'
import { describe, expect, it } from 'vitest'
const source = readFileSync(new URL('./use-mobile-dictation.ts', import.meta.url), 'utf8')
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'
)
function sliceSource(sourceText: string, startPattern: string, endPattern: string): string {
const start = sourceText.indexOf(startPattern)
expect(start).toBeGreaterThanOrEqual(0)
const end = sourceText.indexOf(endPattern, start)
expect(end).toBeGreaterThan(start)
return sourceText.slice(start, end)
}
function sliceBetween(startPattern: string, endPattern: string): string {
const start = source.indexOf(startPattern)
expect(start).toBeGreaterThanOrEqual(0)
const end = source.indexOf(endPattern, start)
expect(end).toBeGreaterThan(start)
return source.slice(start, end)
return sliceSource(source, startPattern, endPattern)
}
function sliceDesktopStartBetween(startPattern: string, endPattern: string): string {
return sliceSource(desktopStartSource, startPattern, endPattern)
}
describe('useMobileDictation source invariants', () => {
@ -28,9 +56,10 @@ describe('useMobileDictation source invariants', () => {
})
it('reserves pending audio bytes before encoding microphone chunks', () => {
const microphoneEffect = sliceBetween(
"addExpoTwoWayAudioEventListener('onMicrophoneData'",
'return () => sub.remove()'
const microphoneEffect = sliceSource(
audioChunkSource,
'export function enqueueMobileDictationAudioChunk',
' queue.pendingChunks.add(sendChunk)'
)
const reserveIndex = microphoneEffect.indexOf('tryReserve(byteLength)')
@ -39,7 +68,19 @@ describe('useMobileDictation source invariants', () => {
expect(encodeIndex).toBeGreaterThanOrEqual(0)
expect(reserveIndex).toBeLessThan(encodeIndex)
expect(microphoneEffect).toContain('MOBILE_DICTATION_CONNECTION_SLOW_ERROR_MESSAGE')
expect(microphoneEffect).toContain('pendingAudioBudgetRef.current.release(byteLength)')
expect(microphoneEffect).toContain('queue.pendingAudioBudget.release(byteLength)')
expect(source).toContain('enqueueMobileDictationAudioChunk(client, dictationId, event')
})
it('reuses audio chunk queue wiring across microphone events', () => {
const queueIndex = source.indexOf('const audioChunkQueue =')
const listenerIndex = source.indexOf("addExpoTwoWayAudioEventListener('onMicrophoneData'")
expect(queueIndex).toBeGreaterThanOrEqual(0)
expect(queueIndex).toBeLessThan(listenerIndex)
expect(source).toContain(
'enqueueMobileDictationAudioChunk(client, dictationId, event, audioChunkQueue)'
)
})
it('resets pending audio bytes whenever pending chunk tracking is cleared', () => {
@ -48,4 +89,198 @@ 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(
'const keepAwakeOwner = useMemo(createMobileDictationKeepAwakeOwner, [])'
)
expect(keepAwakeSource).toContain('activateKeepAwakeAsync')
expect(keepAwakeSource).toContain('deactivateKeepAwake')
expect(keepAwakeSource).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(
"client.sendRequest('speech.dictation.start', { dictationId })"
)
const acquireIndex = startBody.indexOf('.acquire(dictationId)')
const desktopSessionIndex = hookStartBody.indexOf('await startMobileDictationDesktopSession')
const toggleRecordingIndex = hookStartBody.indexOf('toggleRecording(true)')
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(
"client.sendRequest('speech.dictation.cancel', { 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', () => {
const closeAudio = sliceBetween(
'const closeDictationAudio = useCallback(',
'const failActiveDictation ='
)
expect(closeAudio.indexOf('toggleRecording(false)')).toBeLessThan(
closeAudio.indexOf('void keepAwakeOwner.release')
)
expect(closeAudio).toContain('.catch(() => undefined)')
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)')
}
const stopBody = sliceBetween('const stop = useCallback(async () => {', 'const cancel =')
expect(stopBody.indexOf('toggleRecording(false)')).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)')
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()')
expect(foregroundKeepAwakeSource).toContain('drainMobileDictationKeepAwakeCleanup()')
// 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(tag,')).toBeGreaterThanOrEqual(0)
expect(reacquireBody.indexOf('deactivateTrackedTag(tag)')).toBeGreaterThanOrEqual(0)
expect(reacquireBody.indexOf('deactivateTrackedTag(tag)')).toBeLessThan(
reacquireBody.indexOf('await activateTrackedTag(tag,')
)
})
it('keeps cleanup going when native recording shutdown throws', () => {
const closeAudio = sliceBetween(
'const closeDictationAudio = useCallback(',
'const failActiveDictation ='
)
const toggleIndex = closeAudio.indexOf('toggleRecording(false)')
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)
// stop()'s recording shutdown sits inside the try so a native throw still
// runs the finally release and error cleanup.
const stopBody = sliceBetween('const stop = useCallback(async () => {', 'const cancel =')
expect(stopBody.indexOf('try {')).toBeGreaterThanOrEqual(0)
expect(stopBody.indexOf('try {')).toBeLessThan(stopBody.indexOf('toggleRecording(false)'))
})
it('routes disabled state and audio interruptions through cancel cleanup', () => {
const interruptionEffect = sliceBetween(
"addExpoTwoWayAudioEventListener('onAudioInterruption'",
'return () => sub.remove()'
)
const disabledEffect = sliceBetween(
'useEffect(() => {\n if (!enabled) {',
' }, [cancel, enabled])'
)
expect(interruptionEffect).toContain("event.data === 'began' || event.data === 'blocked'")
expect(interruptionEffect).toContain('void cancel()')
expect(disabledEffect).toContain('void cancel()')
})
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<void> = Promise.resolve()')
expect(keepAwakeSource).toContain('const pendingCleanupTags = new Set<string>()')
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()')
})
})

View File

@ -1,7 +1,4 @@
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'
// Why: import from 'buffer' (the npm polyfill), not 'node:buffer' — Metro
// can't resolve Node's builtin in a React Native bundle.
import { Buffer } from 'buffer'
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import {
addExpoTwoWayAudioEventListener,
initialize,
@ -9,45 +6,27 @@ import {
tearDown,
toggleRecording
} from '@orca/expo-two-way-audio'
import type { RpcClient } from '../transport/rpc-client'
import { 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 {
MOBILE_DICTATION_CONNECTION_SLOW_ERROR_MESSAGE,
MOBILE_DICTATION_PCM_SAMPLE_RATE,
MobileDictationPendingAudioBudget
} from './mobile-dictation-pending-audio-budget'
DICTATION_FINISH_TIMEOUT_MS,
createMobileDictationId,
isCurrentMobileDictationFinish
} from './mobile-dictation-session-state'
import { startMobileDictationDesktopSession } from './mobile-dictation-desktop-start'
import type {
DictationStatus,
UseMobileDictationOptions,
UseMobileDictationResult
} from './mobile-dictation-session-state'
type DictationStatus = 'idle' | 'starting' | 'recording' | 'processing' | 'error'
type UseMobileDictationOptions = {
client: RpcClient | null
enabled: boolean
onTranscript: (text: string) => void
onError?: (error: Error) => void
}
export type UseMobileDictationResult = {
status: DictationStatus
isStarting: boolean
isRecording: boolean
isProcessing: boolean
error: string | null
start: () => Promise<void>
stop: () => Promise<void>
cancel: () => Promise<void>
}
const DICTATION_FINISH_TIMEOUT_MS = 75_000
function bytesToBase64(bytes: Uint8Array): string {
return Buffer.from(bytes).toString('base64')
}
function createDictationId(): string {
return `mobile-dictation-${Date.now()}-${Math.random().toString(36).slice(2)}`
}
export type { UseMobileDictationResult } from './mobile-dictation-session-state'
export function useMobileDictation(options: UseMobileDictationOptions): UseMobileDictationResult {
const { client, enabled, onTranscript, onError } = options
const keepAwakeOwner = useMemo(createMobileDictationKeepAwakeOwner, [])
const [status, setStatus] = useState<DictationStatus>('idle')
const [error, setError] = useState<string | null>(null)
const activeIdRef = useRef<string | null>(null)
@ -77,6 +56,23 @@ 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 {
toggleRecording(false)
} 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)
},
[keepAwakeOwner]
)
const failActiveDictation = useCallback(
(dictationId: string, err: unknown) => {
const client = clientRef.current
@ -84,51 +80,32 @@ export function useMobileDictation(options: UseMobileDictationOptions): UseMobil
return
}
activeIdRef.current = null
acceptingChunksRef.current = false
pendingChunksRef.current.clear()
pendingAudioBudgetRef.current.reset()
toggleRecording(false)
closeDictationAudio(dictationId)
if (client && dictationId) {
void client.sendRequest('speech.dictation.cancel', { dictationId }).catch(() => undefined)
}
reportError(err)
},
[reportError]
[closeDictationAudio, reportError]
)
useEffect(() => {
// Microphone events are a hot path; reuse this wiring instead of allocating
// a queue object and release predicate for every audio chunk.
const audioChunkQueue = {
pendingChunks: pendingChunksRef.current,
pendingAudioBudget: pendingAudioBudgetRef.current,
shouldReleaseBudget: (id: string) =>
activeIdRef.current === id || finishingIdRef.current === id,
failActiveDictation
}
const sub = addExpoTwoWayAudioEventListener('onMicrophoneData', (event) => {
const client = clientRef.current
const dictationId = activeIdRef.current
if (!client || !dictationId || !enabledRef.current || !acceptingChunksRef.current) {
return
}
const raw = event.data
const bytes = raw instanceof Uint8Array ? raw : new Uint8Array(raw)
const byteLength = bytes.byteLength
if (!pendingAudioBudgetRef.current.tryReserve(byteLength)) {
failActiveDictation(dictationId, new Error(MOBILE_DICTATION_CONNECTION_SLOW_ERROR_MESSAGE))
return
}
const sendChunk = client
.sendRequest('speech.dictation.chunk', {
dictationId,
audioBase64: bytesToBase64(bytes),
sampleRate: MOBILE_DICTATION_PCM_SAMPLE_RATE
})
.then((response) => {
if (!response.ok) {
throw new Error(response.error.message)
}
})
.catch((err) => failActiveDictation(dictationId, err))
.finally(() => {
if (activeIdRef.current === dictationId || finishingIdRef.current === dictationId) {
pendingAudioBudgetRef.current.release(byteLength)
}
pendingChunksRef.current.delete(sendChunk)
})
pendingChunksRef.current.add(sendChunk)
enqueueMobileDictationAudioChunk(client, dictationId, event, audioChunkQueue)
})
return () => sub.remove()
}, [failActiveDictation, reportError])
@ -168,40 +145,41 @@ export function useMobileDictation(options: UseMobileDictationOptions): UseMobil
throw new Error('Failed to initialize microphone')
}
const dictationId = createDictationId()
const dictationId = createMobileDictationId()
activeIdRef.current = dictationId
try {
const response = await client.sendRequest('speech.dictation.start', { dictationId })
if (!response.ok) {
throw new Error(response.error.message)
}
} catch (err) {
if (activeIdRef.current === dictationId) {
activeIdRef.current = null
}
await client.sendRequest('speech.dictation.cancel', { dictationId }).catch(() => undefined)
setStatus('idle')
throw err
}
if (
generationRef.current !== generation ||
!enabledRef.current ||
activeIdRef.current !== dictationId
) {
await client.sendRequest('speech.dictation.cancel', { dictationId }).catch(() => undefined)
if (activeIdRef.current === dictationId) {
activeIdRef.current = null
}
setStatus('idle')
return
}
acceptingChunksRef.current = true
pendingChunksRef.current.clear()
pendingAudioBudgetRef.current.reset()
toggleRecording(true)
setStatus('recording')
}, [])
await startMobileDictationDesktopSession({
client,
dictationId,
generation,
getCurrentGeneration: () => generationRef.current,
getEnabled: () => enabledRef.current,
getActiveId: () => activeIdRef.current,
clearActiveId: (id) => {
if (activeIdRef.current === id) {
activeIdRef.current = null
}
},
setIdle: () => setStatus('idle'),
keepAwakeOwner,
commitRecordingStart: () => {
acceptingChunksRef.current = true
pendingChunksRef.current.clear()
pendingAudioBudgetRef.current.reset()
if (!toggleRecording(true)) {
return false
}
setStatus('recording')
return true
},
rollbackRecordingStart: () => {
acceptingChunksRef.current = false
pendingChunksRef.current.clear()
pendingAudioBudgetRef.current.reset()
toggleRecording(false)
}
})
}, [keepAwakeOwner])
const stop = useCallback(async () => {
const client = clientRef.current
@ -215,14 +193,20 @@ export function useMobileDictation(options: UseMobileDictationOptions): UseMobil
finishingIdRef.current = dictationId
setStatus('processing')
acceptingChunksRef.current = false
toggleRecording(false)
try {
// Inside the try so a throwing native shutdown still runs the finally
// release and error cleanup.
toggleRecording(false)
await Promise.allSettled(Array.from(pendingChunksRef.current))
if (
generationRef.current !== generation ||
activeIdRef.current !== dictationId ||
finishingIdRef.current !== dictationId ||
!enabledRef.current
!isCurrentMobileDictationFinish(
generationRef.current,
generation,
enabledRef.current,
activeIdRef.current,
finishingIdRef.current,
dictationId
)
) {
return
}
@ -235,10 +219,14 @@ export function useMobileDictation(options: UseMobileDictationOptions): UseMobil
throw new Error(response.error.message)
}
if (
generationRef.current !== generation ||
activeIdRef.current !== dictationId ||
finishingIdRef.current !== dictationId ||
!enabledRef.current
!isCurrentMobileDictationFinish(
generationRef.current,
generation,
enabledRef.current,
activeIdRef.current,
finishingIdRef.current,
dictationId
)
) {
return
}
@ -257,11 +245,14 @@ 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)
if (finishingIdRef.current === dictationId) {
finishingIdRef.current = null
}
}
}, [failActiveDictation])
}, [failActiveDictation, keepAwakeOwner])
const cancel = useCallback(async () => {
const client = clientRef.current
@ -269,16 +260,15 @@ export function useMobileDictation(options: UseMobileDictationOptions): UseMobil
generationRef.current += 1
activeIdRef.current = null
finishingIdRef.current = null
acceptingChunksRef.current = false
pendingChunksRef.current.clear()
pendingAudioBudgetRef.current.reset()
toggleRecording(false)
closeDictationAudio(dictationId)
if (client && dictationId) {
await client.sendRequest('speech.dictation.cancel', { dictationId }).catch(() => undefined)
}
setStatus('idle')
setError(null)
}, [])
}, [closeDictationAudio])
useMobileDictationForegroundKeepAwake(keepAwakeOwner, activeIdRef)
useEffect(() => {
const sub = addExpoTwoWayAudioEventListener('onAudioInterruption', (event) => {
@ -301,10 +291,7 @@ export function useMobileDictation(options: UseMobileDictationOptions): UseMobil
generationRef.current += 1
activeIdRef.current = null
finishingIdRef.current = null
acceptingChunksRef.current = false
pendingChunksRef.current.clear()
pendingAudioBudgetRef.current.reset()
toggleRecording(false)
closeDictationAudio(dictationId)
void tearDown()
if (clientRef.current && dictationId) {
void clientRef.current
@ -312,7 +299,7 @@ export function useMobileDictation(options: UseMobileDictationOptions): UseMobil
.catch(() => undefined)
}
}
}, [])
}, [closeDictationAudio])
return {
status,