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:
parent
931e402e93
commit
2e48495273
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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])
|
||||
}
|
||||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
|
|
@ -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()')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in New Issue