From ace946d5c7c4392220f69931e59f7586a638d70e Mon Sep 17 00:00:00 2001 From: Yun Chan Date: Mon, 28 Sep 2026 00:54:01 +0900 Subject: [PATCH] fix(mobile-recorder): scope recorder control to its owner and keep Android recordings on stop failure --- .../audio-recorder-redteam-r1-24.test.ts | 292 +++++++ .../talk-screen-redteam-r1-24.test.tsx | 238 ++++++ .../src/features/talk/talk-account.ts | 20 + apps/mobile-rn/src/lib/audio-recorder.ts | 762 +++--------------- .../recorder/android-foreground-backend.ts | 407 ++++++++++ .../src/lib/recorder/nitro-backend.ts | 373 +++++++++ .../src/lib/recorder/recorder-errors.ts | 14 + .../src/lib/recorder/recorder-types.ts | 87 ++ apps/mobile-rn/src/screens/RecordScreen.tsx | 96 ++- apps/mobile-rn/src/screens/TalkScreen.tsx | 81 +- 10 files changed, 1673 insertions(+), 697 deletions(-) create mode 100644 apps/mobile-rn/__tests__/audio-recorder-redteam-r1-24.test.ts create mode 100644 apps/mobile-rn/__tests__/talk-screen-redteam-r1-24.test.tsx create mode 100644 apps/mobile-rn/src/features/talk/talk-account.ts create mode 100644 apps/mobile-rn/src/lib/recorder/android-foreground-backend.ts create mode 100644 apps/mobile-rn/src/lib/recorder/nitro-backend.ts create mode 100644 apps/mobile-rn/src/lib/recorder/recorder-errors.ts create mode 100644 apps/mobile-rn/src/lib/recorder/recorder-types.ts diff --git a/apps/mobile-rn/__tests__/audio-recorder-redteam-r1-24.test.ts b/apps/mobile-rn/__tests__/audio-recorder-redteam-r1-24.test.ts new file mode 100644 index 0000000..257bc77 --- /dev/null +++ b/apps/mobile-rn/__tests__/audio-recorder-redteam-r1-24.test.ts @@ -0,0 +1,292 @@ +import { NativeModules } from 'react-native' +import { + AudioRecorder, + RecorderBusyError, + type RecorderBackend, + type RecordingRuntimeSnapshot, +} from '../src/lib/audio-recorder' +import { AndroidForegroundRecorderBackend } from '../src/lib/recorder/android-foreground-backend' + +const MEETING_PATH = '/data/user/0/com.d3ro.voice/files/recordings/d3ro-recording-11111111-1111-4111-8111-111111111111.wav' + +type NativeState = 'idle' | 'starting' | 'recording' | 'paused' | 'stopped' | 'recoverable' + +function nativeSnapshot(state: NativeState, size = 64_044, path: string | null = MEETING_PATH) { + const hasFile = state !== 'idle' && path !== null + return { + state, + path: hasFile ? path : null, + uri: hasFile ? `file://${path}` : null, + fileName: hasFile ? path!.slice(path!.lastIndexOf('/') + 1) : null, + mimeType: 'audio/wav', + size: hasFile ? size : 0, + durationMs: hasFile ? 4_000 : 0, + meteringDb: null, + meetingId: null, + interruptionReason: null, + startedAtMs: hasFile ? 1_000 : 0, + recoverable: state === 'recoverable', + } +} + +interface FakeNativeModule { + current: ReturnType + getStatus: jest.Mock + start: jest.Mock + pause: jest.Mock + resume: jest.Mock + stop: jest.Mock + cancel: jest.Mock + dispose: jest.Mock +} + +function installNativeModule(initial: NativeState = 'idle'): FakeNativeModule { + const native: FakeNativeModule = { + current: nativeSnapshot(initial), + getStatus: jest.fn(async () => native.current), + start: jest.fn(async () => { + if (native.current.state !== 'idle') { + throw Object.assign(new Error('Finish or discard the existing recording before starting another'), { + code: 'recording-already-exists', + }) + } + native.current = nativeSnapshot('recording') + return native.current + }), + pause: jest.fn(async () => { + native.current = nativeSnapshot('paused') + return native.current + }), + resume: jest.fn(async () => { + native.current = nativeSnapshot('recording') + return native.current + }), + stop: jest.fn(async () => { + native.current = nativeSnapshot('stopped') + return native.current + }), + cancel: jest.fn(async () => { + native.current = nativeSnapshot('idle') + return native.current + }), + dispose: jest.fn(async (path: string) => { + if (native.current.path === path) native.current = nativeSnapshot('idle') + }), + } + ;(NativeModules as Record).D3RORecording = native + return native +} + +function androidRecorder(): AudioRecorder { + return new AudioRecorder(new AndroidForegroundRecorderBackend()) +} + +beforeEach(() => { + jest.useFakeTimers() +}) + +afterEach(() => { + jest.clearAllTimers() + jest.useRealTimers() + delete (NativeModules as Record).D3RORecording +}) + +describe('recorder ownership lease (redteam r1-24 bug 1)', () => { + test('Talk cannot acquire while a Record meeting capture runs, and the capture survives', async () => { + const native = installNativeModule() + const recorder = androidRecorder() + const record = await recorder.acquire('record') + await record.start(() => undefined, { meetingId: '22222222-2222-4222-8222-222222222222' }) + + await expect(recorder.acquire('talk')).rejects.toBeInstanceOf(RecorderBusyError) + + expect(native.cancel).not.toHaveBeenCalled() + expect(native.dispose).not.toHaveBeenCalled() + expect(native.current.state).toBe('recording') + // Record still owns and can finish its capture. + await expect(record.stop()).resolves.toMatchObject({ path: MEETING_PATH }) + }) + + test('a Talk session never cancels a capture it does not hold', async () => { + const native = installNativeModule() + const recorder = androidRecorder() + const talk = await recorder.acquire('talk') + const record = await recorder.acquire('record') + await record.start(() => undefined) + + await expect(talk.start(() => undefined)).rejects.toBeInstanceOf(RecorderBusyError) + await talk.cancel() + await talk.stop().catch(() => undefined) + + expect(native.cancel).not.toHaveBeenCalled() + expect(native.stop).not.toHaveBeenCalled() + expect(native.current.state).toBe('recording') + }) + + test('a native capture that outlived the JS process is treated as Record-owned', async () => { + const native = installNativeModule('recording') + const recorder = androidRecorder() + + await expect(recorder.acquire('talk')).rejects.toMatchObject({ holder: 'record' }) + expect(native.cancel).not.toHaveBeenCalled() + + const record = await recorder.acquire('record') + const snapshot = await record.restore() + expect(snapshot.state).toBe('recording') + }) + + test('a recoverable meeting file also blocks Talk instead of being discarded', async () => { + const native = installNativeModule('recoverable') + const recorder = androidRecorder() + + await expect(recorder.acquire('talk')).rejects.toBeInstanceOf(RecorderBusyError) + expect(native.cancel).not.toHaveBeenCalled() + expect(native.current.state).toBe('recoverable') + }) + + test('Talk can use the recorder again once the meeting capture is disposed', async () => { + installNativeModule() + const recorder = androidRecorder() + const record = await recorder.acquire('record') + await record.start(() => undefined) + const recording = await record.stop() + await record.cleanup(recording) + + const talk = await recorder.acquire('talk') + await expect(talk.start(() => undefined)).resolves.toBeUndefined() + }) + + test('the privileged account-purge cancel still discards any capture', async () => { + const native = installNativeModule() + const recorder = androidRecorder() + const record = await recorder.acquire('record') + await record.start(() => undefined) + + await recorder.cancel() + + expect(native.cancel).toHaveBeenCalledTimes(1) + await expect(recorder.acquire('talk')).resolves.toMatchObject({ owner: 'talk' }) + }) + + test('the lease is enforced for any backend, not only Android', async () => { + let active = false + const backend: RecorderBackend = { + requestPermission: jest.fn(async () => true), + start: jest.fn(async () => { active = true }), + pause: jest.fn(async () => undefined), + resume: jest.fn(async () => undefined), + stop: jest.fn(async () => ({ + uri: 'file:///cache/a.m4a', + path: '/cache/a.m4a', + fileName: 'a.m4a', + mimeType: 'audio/mp4', + size: 10, + durationMs: 1_000, + })), + cleanup: jest.fn(async () => { active = false }), + cancel: jest.fn(async () => { active = false }), + restore: jest.fn(async () => ({ + state: 'idle' as const, + recording: null, + meetingId: null, + interruptionReason: null, + startedAtMs: 0, + })), + isBusy: jest.fn(async () => active), + hasInProcessCapture: jest.fn(() => active), + } + const recorder = new AudioRecorder(backend) + const earlierTalk = await recorder.acquire('talk') + const record = await recorder.acquire('record') + await record.start(() => undefined) + + await expect(recorder.acquire('talk')).rejects.toBeInstanceOf(RecorderBusyError) + await earlierTalk.cancel() + expect(backend.cancel).not.toHaveBeenCalled() + await record.cancel() + expect(backend.cancel).toHaveBeenCalledTimes(1) + }) +}) + +describe('Android stop failure keeps the recording recoverable (redteam r1-24 bug 3)', () => { + test('a native stop timeout surfaces the native error and reports the recoverable file', async () => { + const native = installNativeModule() + const recorder = androidRecorder() + const record = await recorder.acquire('record') + const states: RecordingRuntimeSnapshot[] = [] + await record.start(() => undefined, { onStateChange: (snapshot) => states.push(snapshot) }) + + const timeout = Object.assign(new Error('Recording could not be stopped in time'), { + code: 'recording-stop-timeout', + }) + native.stop.mockImplementationOnce(async () => { + // The service keeps finalizing after the command times out. + native.current = nativeSnapshot('recoverable') + throw timeout + }) + + await expect(record.stop()).rejects.toBe(timeout) + + expect(states.at(-1)).toMatchObject({ + state: 'recoverable', + recording: { path: MEETING_PATH }, + }) + expect(native.dispose).not.toHaveBeenCalled() + expect(native.cancel).not.toHaveBeenCalled() + + // The recovered file can be discarded and the recorder is usable again. + await record.cleanup({ path: MEETING_PATH }) + expect(native.dispose).toHaveBeenCalledWith(MEETING_PATH) + await expect(record.start(() => undefined)).resolves.toBeUndefined() + }) + + test('a header-only WAV reports the stopped file instead of masking the error', async () => { + const native = installNativeModule() + const recorder = androidRecorder() + const record = await recorder.acquire('record') + const states: RecordingRuntimeSnapshot[] = [] + await record.start(() => undefined, { onStateChange: (snapshot) => states.push(snapshot) }) + native.stop.mockImplementationOnce(async () => { + native.current = nativeSnapshot('stopped', 44) + return native.current + }) + + await expect(record.stop()).rejects.toThrow('Android recorder produced no recoverable audio') + expect(states.at(-1)).toMatchObject({ state: 'stopped' }) + + // A later poll must not be able to wedge the recorder in "stopped". + await jest.runOnlyPendingTimersAsync() + await record.cleanup({ path: MEETING_PATH }) + await expect(record.start(() => undefined)).resolves.toBeUndefined() + }) + + test('an unreadable status after a failed stop keeps the capture instead of dropping it', async () => { + const native = installNativeModule() + const recorder = androidRecorder() + const record = await recorder.acquire('record') + await record.start(() => undefined) + const failure = Object.assign(new Error('Only an active recording can be stopped'), { + code: 'recording-invalid-state', + }) + native.stop.mockRejectedValueOnce(failure) + native.getStatus.mockRejectedValueOnce(new Error('bridge unavailable')) + + await expect(record.stop()).rejects.toBe(failure) + + // The capture is still Record's and can still be stopped normally. + await expect(recorder.acquire('talk')).rejects.toBeInstanceOf(RecorderBusyError) + await expect(record.stop()).resolves.toMatchObject({ path: MEETING_PATH }) + }) + + test('disposing an older file does not reset a capture that is running now', async () => { + const native = installNativeModule() + const recorder = androidRecorder() + const record = await recorder.acquire('record') + await record.start(() => undefined) + + await record.cleanup({ path: '/data/user/0/com.d3ro.voice/files/recordings/older.wav' }) + + await expect(record.stop()).resolves.toMatchObject({ path: MEETING_PATH }) + expect(native.stop).toHaveBeenCalledTimes(1) + }) +}) diff --git a/apps/mobile-rn/__tests__/talk-screen-redteam-r1-24.test.tsx b/apps/mobile-rn/__tests__/talk-screen-redteam-r1-24.test.tsx new file mode 100644 index 0000000..89af7da --- /dev/null +++ b/apps/mobile-rn/__tests__/talk-screen-redteam-r1-24.test.tsx @@ -0,0 +1,238 @@ +import React from 'react' +import { act, create, type ReactTestInstance, type ReactTestRenderer } from 'react-test-renderer' + +const USER_ID = '11111111-1111-4111-8111-111111111111' + +let mockSession: { user: { id: string }, access_token: string } | null = { + user: { id: USER_ID }, + access_token: 'token-1', +} +const mockAcquire = jest.fn() +const mockRequestPermission = jest.fn(async () => true) +const mockLegacyStart = jest.fn(async () => { + throw new Error('Cannot start recorder while it is recording') +}) +const mockGlobalCancel = jest.fn(async () => undefined) +const mockStreamTalkResponse = jest.fn() + +jest.mock('@react-navigation/native', () => { + const ReactValue = require('react') as typeof React + return { + useFocusEffect: (effect: () => void | (() => void)) => { + ReactValue.useEffect(effect, [effect]) + }, + } +}) +jest.mock('react-native-safe-area-context', () => ({ + useSafeAreaInsets: () => ({ top: 0, bottom: 0, left: 0, right: 0 }), +})) +jest.mock('@d3ro/i18n', () => ({ + useI18n: () => ({ t: (key: string) => key }), +})) +jest.mock('../src/lib/auth-context', () => ({ + useAuth: () => ({ session: mockSession }), +})) +jest.mock('../src/lib/preferences-context', () => ({ + useMobilePreferences: () => ({ + palette: { + bg: { app: 'app', sidebar: 'sidebar', card: 'card', inset: 'inset' }, + text: { primary: 'primary', muted: 'muted' }, + border: { default: 'border' }, + accent: { main: 'accent', dim: 'dim', pressed: 'pressed' }, + tag: { red: 'red' }, + }, + preferences: { preferredLlmModel: 'default', locale: 'en' }, + }), +})) +jest.mock('../src/theme/themed-components', () => { + const { Pressable, Text } = require('react-native') as typeof import('react-native') + return { + ThemeText: ({ children, ...props }: React.PropsWithChildren>) => ( + {children} + ), + ThemeButton: ({ label, onPress, ...props }: { label: string, onPress?: () => void }) => ( + + {label} + + ), + } +}) +jest.mock('../src/components/ContentReportSheet', () => ({ + __esModule: true, + default: () => null, +})) +jest.mock('../src/features/chat/chat-service', () => { + class MockChatServiceError extends Error { + code: string + retryable: boolean + + constructor(mockCode: string, mockRetryable: boolean) { + super(mockCode) + this.code = mockCode + this.retryable = mockRetryable + } + } + return { ChatServiceError: MockChatServiceError } +}) +jest.mock('../src/features/talk/llm-stream-service', () => ({ + streamTalkResponse: (...args: unknown[]) => mockStreamTalkResponse(...args), +})) +jest.mock('../src/features/talk/talk-transcription-service', () => ({ + transcribeTalkRecording: jest.fn(), +})) +jest.mock('../src/features/talk/talk-tts', () => { + class MockTalkTtsError extends Error { + code = 'FAILED' + } + return { + shutdownTalkSpeech: jest.fn(async () => undefined), + speakTalkText: jest.fn(async () => ({ status: 'completed' })), + stopTalkSpeech: jest.fn(async () => true), + TalkTtsError: MockTalkTtsError, + } +}) +jest.mock('../src/features/talk/talk-debug-audio', () => ({ + isDeterministicTalkAudioAvailable: () => false, + synthesizeDeterministicTalkAudio: jest.fn(), +})) +jest.mock('../src/lib/audio-recorder', () => { + class MockRecorderBusyError extends Error { + code = 'recorder_busy' + holder: string + + constructor(mockHolder: string) { + super(`The recorder is in use by ${mockHolder}`) + this.holder = mockHolder + } + } + return { + RecorderBusyError: MockRecorderBusyError, + audioRecorder: { + acquire: (...args: unknown[]) => mockAcquire(...args), + requestPermission: () => mockRequestPermission(), + // The pre-lease API: a global start/cancel any screen could call. + start: () => mockLegacyStart(), + cancel: () => mockGlobalCancel(), + }, + } +}) + +import TalkScreen from '../src/screens/TalkScreen' +import { RecorderBusyError } from '../src/lib/audio-recorder' + +async function flush(): Promise { + for (let index = 0; index < 5; index += 1) { + await act(async () => { + await Promise.resolve() + }) + } +} + +function byTestId(root: ReactTestInstance, testID: string, prop: string): ReactTestInstance { + const match = root.findAll((node) => ( + node.props.testID === testID && typeof node.props[prop] === 'function' + ))[0] + if (match === undefined) throw new Error(`Missing ${testID} with ${prop}`) + return match +} + +function textContent(root: ReactTestInstance): string { + return root.findAll((node) => typeof node.props.children === 'string') + .map((node) => node.props.children as string) + .join('\n') +} + +let renderer: ReactTestRenderer | null = null + +beforeEach(() => { + mockSession = { user: { id: USER_ID }, access_token: 'token-1' } + mockAcquire.mockReset() + mockGlobalCancel.mockClear() + mockLegacyStart.mockClear() + mockStreamTalkResponse.mockReset() +}) + +afterEach(() => { + act(() => renderer?.unmount()) + renderer = null +}) + +describe('TalkScreen redteam r1-24', () => { + test('pressing the mic while a meeting recording holds the recorder never cancels it', async () => { + mockAcquire.mockRejectedValue(new RecorderBusyError('record')) + await act(async () => { + renderer = create() + }) + + await act(async () => { + byTestId(renderer!.root, 'talk-ptt', 'onPressIn').props.onPressIn() + }) + await flush() + + expect(mockAcquire).toHaveBeenCalledWith('talk') + expect(mockGlobalCancel).not.toHaveBeenCalled() + expect(textContent(renderer!.root)).toContain('mobile.talk.error.recording') + }) + + test('an access-token refresh for the same user keeps the in-flight reply', async () => { + let resolveStream: (value: { text: string, generationId: string }) => void = () => undefined + let streamSignal: AbortSignal | null = null + mockStreamTalkResponse.mockImplementation((_messages: unknown, options: { signal: AbortSignal }) => { + streamSignal = options.signal + return new Promise((resolve) => { resolveStream = resolve }) + }) + await act(async () => { + renderer = create() + }) + + await act(async () => { + byTestId(renderer!.root, 'talk-input', 'onChangeText').props.onChangeText('hello') + }) + await act(async () => { + byTestId(renderer!.root, 'talk-send', 'onPress').props.onPress() + }) + await flush() + expect(mockStreamTalkResponse).toHaveBeenCalledTimes(1) + + // supabase-js TOKEN_REFRESHED: same user, rotated access token. + mockSession = { user: { id: USER_ID }, access_token: 'token-2' } + await act(async () => { + renderer!.update() + }) + expect(streamSignal!.aborted).toBe(false) + + await act(async () => { + resolveStream({ text: 'the finished reply', generationId: 'gen-1' }) + }) + await flush() + + const text = textContent(renderer!.root) + expect(text).toContain('the finished reply') + expect(text).not.toContain('mobile.talk.error.auth') + }) + + test('switching to a different user still cancels the in-flight reply', async () => { + let streamSignal: AbortSignal | null = null + mockStreamTalkResponse.mockImplementation((_messages: unknown, options: { signal: AbortSignal }) => { + streamSignal = options.signal + return new Promise(() => undefined) + }) + await act(async () => { + renderer = create() + }) + await act(async () => { + byTestId(renderer!.root, 'talk-input', 'onChangeText').props.onChangeText('hello') + }) + await act(async () => { + byTestId(renderer!.root, 'talk-send', 'onPress').props.onPress() + }) + await flush() + + mockSession = { user: { id: '33333333-3333-4333-8333-333333333333' }, access_token: 'token-3' } + await act(async () => { + renderer!.update() + }) + + expect(streamSignal!.aborted).toBe(true) + }) +}) diff --git a/apps/mobile-rn/src/features/talk/talk-account.ts b/apps/mobile-rn/src/features/talk/talk-account.ts new file mode 100644 index 0000000..4f6e7db --- /dev/null +++ b/apps/mobile-rn/src/features/talk/talk-account.ts @@ -0,0 +1,20 @@ +interface TalkAccountSession { + user: { id: string } +} + +/** + * Identity of the signed-in account for Talk operations. It deliberately + * ignores the access token: supabase-js rotates it about hourly for the same + * user, and a rotation must not cancel or discard an in-flight reply. + */ +export function talkAccountKey(session: TalkAccountSession | null): string { + return session === null ? 'signed-out' : `user:${session.user.id}` +} + +/** True when a result produced for `started` still belongs to the current account. */ +export function isSameTalkAccount( + started: TalkAccountSession, + latest: TalkAccountSession | null, +): boolean { + return latest !== null && latest.user.id === started.user.id +} diff --git a/apps/mobile-rn/src/lib/audio-recorder.ts b/apps/mobile-rn/src/lib/audio-recorder.ts index 9f70068..971f5ce 100644 --- a/apps/mobile-rn/src/lib/audio-recorder.ts +++ b/apps/mobile-rn/src/lib/audio-recorder.ts @@ -1,676 +1,148 @@ -import { NativeModules, PermissionsAndroid, Platform } from 'react-native'; -import { Dirs, FileSystem } from 'react-native-file-access'; -import { - AudioEncoderAndroidType, - AudioSourceAndroidType, - AVEncoderAudioQualityIOSType, - OutputFormatAndroidType, - createSound, - type AudioSet, - type RecordBackType, -} from 'react-native-nitro-sound'; -import { createUuidV4 } from './random-id'; +import { Platform } from 'react-native'; +import { AndroidForegroundRecorderBackend } from './recorder/android-foreground-backend'; +import { NitroRecorderBackend } from './recorder/nitro-backend'; +import { RecorderBusyError } from './recorder/recorder-errors'; +import type { + RecordedAudio, + RecorderBackend, + RecorderOwner, + RecorderSession, + RecordingProgressHandler, + RecordingStateHandler, + StartRecordingOptions, +} from './recorder/recorder-types'; -const RECORDING_PREFIX = 'd3ro-recording-'; -const RECORDING_EXTENSION = '.m4a'; -const RECORDING_MIME_TYPE = 'audio/mp4'; -const ANDROID_RECORDING_MIME_TYPE = 'audio/wav'; +export type { + RecordedAudio, + RecorderBackend, + RecorderOwner, + RecorderSession, + RecordingProgress, + RecordingRuntimeSnapshot, + RecordingRuntimeState, + StartRecordingOptions, +} from './recorder/recorder-types'; +export { RecorderBusyError } from './recorder/recorder-errors'; -const AUDIO_SETTINGS: AudioSet = { - AudioSourceAndroid: AudioSourceAndroidType.MIC, - OutputFormatAndroid: OutputFormatAndroidType.MPEG_4, - AudioEncoderAndroid: AudioEncoderAndroidType.AAC, - AudioQuality: 'medium', - AudioChannels: 1, - AudioSamplingRate: 44100, - AudioEncodingBitRate: 128000, - AVFormatIDKeyIOS: 'aac', - AVEncoderAudioQualityKeyIOS: AVEncoderAudioQualityIOSType.high, - AVNumberOfChannelsKeyIOS: 1, - AVSampleRateKeyIOS: 44100, -}; +/** + * A capture found with no lease (the Android foreground service survives a JS + * restart) can only have come from a meeting recording. + */ +const UNLEASED_CAPTURE_OWNER: RecorderOwner = 'record'; -type NativeRecorderState = - | 'idle' - | 'starting' - | 'recording' - | 'pausing' - | 'paused' - | 'resuming' - | 'stopping' - | 'stopped'; - -export interface RecordingProgress { - durationMs: number; - meteringDb: number | null; -} - -export interface RecordedAudio { - uri: string; - path: string; - fileName: string; - mimeType: string; - size: number; - durationMs: number; -} - -export type RecordingRuntimeState = - | 'idle' - | 'starting' - | 'recording' - | 'paused' - | 'stopped' - | 'recoverable'; - -export interface RecordingRuntimeSnapshot { - state: RecordingRuntimeState; - recording: RecordedAudio | null; - meetingId: string | null; - interruptionReason: string | null; - startedAtMs: number; -} - -export interface StartRecordingOptions { - meetingId?: string; - onStateChange?: (snapshot: RecordingRuntimeSnapshot) => void; -} - -interface NativeRecordingSnapshot { - state: RecordingRuntimeState; - path: string | null; - uri: string | null; - fileName: string | null; - mimeType: string; - size: number; - durationMs: number; - meteringDb: number | null; - meetingId: string | null; - interruptionReason: string | null; - startedAtMs: number; - recoverable: boolean; -} - -interface D3RORecordingNativeModule { - getStatus: () => Promise; - start: (options: { meetingId?: string }) => Promise; - pause: () => Promise; - resume: () => Promise; - stop: () => Promise; - cancel: () => Promise; - dispose: (path: string) => Promise; -} - -function androidRecordingModule(): D3RORecordingNativeModule { - const candidate = NativeModules.D3RORecording as - | Partial - | undefined; - if ( - candidate === undefined || - typeof candidate.getStatus !== 'function' || - typeof candidate.start !== 'function' || - typeof candidate.pause !== 'function' || - typeof candidate.resume !== 'function' || - typeof candidate.stop !== 'function' || - typeof candidate.cancel !== 'function' || - typeof candidate.dispose !== 'function' - ) { - throw new Error('Android foreground recording module is unavailable'); - } - return candidate as D3RORecordingNativeModule; -} - -function assertFiniteNonNegative(value: unknown, field: string): number { - if (typeof value !== 'number' || !Number.isFinite(value) || value < 0) { - throw new Error(`Android recorder returned invalid ${field}`); - } - return value; -} - -function parseNativeSnapshot(value: NativeRecordingSnapshot): RecordingRuntimeSnapshot { - const states: readonly RecordingRuntimeState[] = [ - 'idle', - 'starting', - 'recording', - 'paused', - 'stopped', - 'recoverable', - ]; - if (!states.includes(value.state)) { - throw new Error('Android recorder returned an invalid state'); - } - const durationMs = assertFiniteNonNegative(value.durationMs, 'duration'); - const size = assertFiniteNonNegative(value.size, 'file size'); - const startedAtMs = assertFiniteNonNegative(value.startedAtMs, 'start time'); - const hasFile = value.path !== null || value.uri !== null || value.fileName !== null; - if ( - hasFile && - (typeof value.path !== 'string' || - value.path.length === 0 || - value.uri !== `file://${value.path}` || - typeof value.fileName !== 'string' || - !value.fileName.endsWith('.wav') || - value.mimeType !== ANDROID_RECORDING_MIME_TYPE) - ) { - throw new Error('Android recorder returned an invalid recording file'); - } - if (value.state !== 'idle' && !hasFile) { - throw new Error('Android recorder state has no recording file'); - } - const recording = hasFile - ? { - uri: value.uri as string, - path: value.path as string, - fileName: value.fileName as string, - mimeType: ANDROID_RECORDING_MIME_TYPE, - size, - durationMs, - } - : null; - return { - state: value.state, - recording, - meetingId: typeof value.meetingId === 'string' ? value.meetingId : null, - interruptionReason: - typeof value.interruptionReason === 'string' - ? value.interruptionReason - : null, - startedAtMs, - }; -} - -function createTemporaryPath(): string { - return `${Dirs.CacheDir}/${RECORDING_PREFIX}${createUuidV4()}${RECORDING_EXTENSION}`; -} - -function pathFromFileUri(uri: string): string | null { - if (!uri.startsWith('file://')) return null; - - try { - return decodeURIComponent(uri.slice('file://'.length)); - } catch { - return null; - } -} - -function isOwnedTemporaryPath(path: string): boolean { - const cachePrefix = `${Dirs.CacheDir}/`; - if (!path.startsWith(cachePrefix)) return false; - - const fileName = path.slice(cachePrefix.length); - return ( - !fileName.includes('/') && - !fileName.includes('\\') && - fileName.startsWith(RECORDING_PREFIX) && - fileName.endsWith(RECORDING_EXTENSION) - ); -} - -async function deleteOwnedTemporaryFile(path: string | null): Promise { - if (path === null) return; - if (!isOwnedTemporaryPath(path)) { - throw new Error('Refusing to delete a recording outside the app cache'); - } - - if (await FileSystem.exists(path)) { - await FileSystem.unlink(path); - } -} - -async function deleteStaleTemporaryFiles(): Promise { - const entries = await FileSystem.ls(Dirs.CacheDir); - const stalePaths = entries - .filter( - entry => - entry.startsWith(RECORDING_PREFIX) && - entry.endsWith(RECORDING_EXTENSION), - ) - .map(entry => `${Dirs.CacheDir}/${entry}`); - - await Promise.all(stalePaths.map(deleteOwnedTemporaryFile)); +export function createPlatformRecorderBackend(): RecorderBackend { + return Platform.OS === 'android' + ? new AndroidForegroundRecorderBackend() + : new NitroRecorderBackend(); } +/** + * Facade over the platform capture backend. Screens never drive the backend + * directly: they acquire an owner-scoped {@link RecorderSession}, so a screen + * can only stop or discard a capture it holds. + */ export class AudioRecorder { - private state: NativeRecorderState = 'idle'; - private currentPath: string | null = null; - private progress: RecordingProgress = { durationMs: 0, meteringDb: null }; - private pendingStart: Promise | null = null; - private pendingTransition: Promise | null = null; - private pendingStop: Promise | null = null; - private sound: ReturnType | null = null; - private progressHandler: ((progress: RecordingProgress) => void) | null = - null; - private stateHandler: ((snapshot: RecordingRuntimeSnapshot) => void) | null = - null; - private androidPollGeneration = 0; + private selectedBackend: RecorderBackend | null; + private lease: RecorderOwner | null = null; - private getSound(): ReturnType { - if (this.sound !== null) return this.sound; - - const sound = createSound(); - sound.setSubscriptionDuration(0.1); - sound.addRecordBackListener((event: RecordBackType) => { - if (this.progressHandler === null) return; - - const durationMs = Number.isFinite(event.currentPosition) - ? Math.max(0, Math.round(event.currentPosition)) - : this.progress.durationMs; - const meteringDb = - typeof event.currentMetering === 'number' && - Number.isFinite(event.currentMetering) - ? event.currentMetering - : null; - - this.progress = { durationMs, meteringDb }; - this.progressHandler(this.progress); - }); - this.sound = sound; - return sound; + constructor(backend?: RecorderBackend) { + this.selectedBackend = backend ?? null; } - async requestPermission(): Promise { - if (Platform.OS !== 'android') { - // Nitro Sound requests iOS microphone access through AVAudioSession. - return true; + private get backend(): RecorderBackend { + if (this.selectedBackend === null) { + this.selectedBackend = createPlatformRecorderBackend(); } - - const permission = PermissionsAndroid.PERMISSIONS.RECORD_AUDIO; - if (await PermissionsAndroid.check(permission)) return true; - - const result = await PermissionsAndroid.request(permission, { - title: 'Microphone access', - message: - 'D3RO Voice needs microphone access to record and transcribe audio.', - buttonPositive: 'Allow', - buttonNegative: 'Cancel', - }); - - return result === PermissionsAndroid.RESULTS.GRANTED; + return this.selectedBackend; } - async start( - onProgress: (progress: RecordingProgress) => void, - options: StartRecordingOptions = {}, - ): Promise { - if (this.state !== 'idle') { - throw new Error(`Cannot start recorder while it is ${this.state}`); - } - - this.state = 'starting'; - if (Platform.OS === 'android') { - this.progressHandler = onProgress; - this.stateHandler = options.onStateChange ?? null; - try { - const snapshot = parseNativeSnapshot( - await androidRecordingModule().start( - options.meetingId === undefined - ? {} - : { meetingId: options.meetingId }, - ), - ); - if (snapshot.state !== 'recording' || snapshot.recording === null) { - throw new Error('Android foreground recorder did not start'); - } - this.currentPath = snapshot.recording.path; - this.progress = { - durationMs: snapshot.recording.durationMs, - meteringDb: null, - }; - this.state = 'recording'; - this.stateHandler?.(snapshot); - this.startAndroidPolling(); - return; - } catch (error) { - this.progressHandler = null; - this.stateHandler = null; - this.currentPath = null; - this.state = 'idle'; - throw error; - } - } - try { - await deleteStaleTemporaryFiles(); - } catch (error) { - this.state = 'idle'; - throw error; - } - - const outputPath = createTemporaryPath(); - this.currentPath = outputPath; - this.progress = { durationMs: 0, meteringDb: null }; - this.progressHandler = onProgress; - const sound = this.getSound(); - - const pendingStart = (async () => { - try { - const startedUri = await sound.startRecorder( - outputPath, - AUDIO_SETTINGS, - true, - ); - const startedPath = pathFromFileUri(startedUri); - - if (startedPath !== outputPath) { - throw new Error('Recorder returned an unexpected output URI'); - } - - this.state = 'recording'; - } catch (error) { - try { - await sound.stopRecorder(); - } catch { - // stopRecorder also releases Android's MediaRecorder when stopping fails. - } - this.progressHandler = null; - if (this.currentPath === outputPath) this.currentPath = null; - this.state = 'idle'; - await deleteOwnedTemporaryFile(outputPath); - throw error; - } - })(); - - this.pendingStart = pendingStart; - try { - await pendingStart; - } finally { - if (this.pendingStart === pendingStart) this.pendingStart = null; - } + requestPermission(): Promise { + return this.backend.requestPermission(); } - async pause(): Promise { - if (this.state !== 'recording') { - throw new Error(`Cannot pause recorder while it is ${this.state}`); - } - - this.state = 'pausing'; - if (Platform.OS === 'android') { - try { - const snapshot = parseNativeSnapshot(await androidRecordingModule().pause()); - if (snapshot.state !== 'paused') throw new Error('Android recorder did not pause'); - this.state = 'paused'; - this.stateHandler?.(snapshot); - return; - } catch (error) { - this.state = 'recording'; - throw error; - } - } - const pendingTransition = (async () => { - try { - await this.getSound().pauseRecorder(); - this.state = 'paused'; - } catch (error) { - this.state = 'recording'; - throw error; - } - })(); - - this.pendingTransition = pendingTransition; - try { - await pendingTransition; - } finally { - if (this.pendingTransition === pendingTransition) { - this.pendingTransition = null; - } - } - } - - async resume(): Promise { - if (this.state !== 'paused') { - throw new Error(`Cannot resume recorder while it is ${this.state}`); - } - - this.state = 'resuming'; - if (Platform.OS === 'android') { - try { - const snapshot = parseNativeSnapshot(await androidRecordingModule().resume()); - if (snapshot.state !== 'recording') throw new Error('Android recorder did not resume'); - this.state = 'recording'; - this.stateHandler?.(snapshot); - return; - } catch (error) { - this.state = 'paused'; - throw error; - } - } - const pendingTransition = (async () => { - try { - await this.getSound().resumeRecorder(); - this.state = 'recording'; - } catch (error) { - this.state = 'paused'; - throw error; - } - })(); - - this.pendingTransition = pendingTransition; - try { - await pendingTransition; - } finally { - if (this.pendingTransition === pendingTransition) { - this.pendingTransition = null; - } - } - } - - async stop(): Promise { - if (this.state !== 'recording' && this.state !== 'paused') { - throw new Error(`Cannot stop recorder while it is ${this.state}`); - } - - const outputPath = this.currentPath; - if (outputPath === null) throw new Error('Recorder has no output path'); - - const pendingStop = (async (): Promise => { - this.state = 'stopping'; - - try { - if (Platform.OS === 'android') { - const snapshot = parseNativeSnapshot(await androidRecordingModule().stop()); - if ( - (snapshot.state !== 'stopped' && snapshot.state !== 'recoverable') || - snapshot.recording === null || - snapshot.recording.size <= 44 - ) { - throw new Error('Android recorder produced no recoverable audio'); - } - this.stopAndroidPolling(); - this.currentPath = snapshot.recording.path; - this.progress = { - durationMs: snapshot.recording.durationMs, - meteringDb: null, - }; - this.state = 'stopped'; - this.stateHandler?.(snapshot); - return snapshot.recording; - } - const stoppedUri = await this.getSound().stopRecorder(); - const stoppedPath = pathFromFileUri(stoppedUri); - - if (stoppedPath !== outputPath) { - throw new Error('Recorder did not return the expected output file'); - } - - const stat = await FileSystem.stat(outputPath); - if (stat.type !== 'file' || stat.size <= 0) { - throw new Error('Recorder produced an empty audio file'); - } - - this.state = 'stopped'; - return { - uri: stoppedUri, - path: outputPath, - fileName: outputPath.slice(outputPath.lastIndexOf('/') + 1), - mimeType: RECORDING_MIME_TYPE, - size: stat.size, - durationMs: this.progress.durationMs, - }; - } catch (error) { - this.currentPath = null; - this.state = 'idle'; - await deleteOwnedTemporaryFile(outputPath); - throw error; - } finally { - this.progressHandler = null; - this.stateHandler = null; - } - })(); - - this.pendingStop = pendingStop; - try { - return await pendingStop; - } finally { - if (this.pendingStop === pendingStop) this.pendingStop = null; - } - } - - async cleanup(recording?: Pick): Promise { - const path = recording?.path ?? this.currentPath; - if (Platform.OS === 'android') { - if (path !== null) await androidRecordingModule().dispose(path); - this.stopAndroidPolling(); - this.currentPath = null; - this.state = 'idle'; - this.progress = { durationMs: 0, meteringDb: null }; - this.progressHandler = null; - this.stateHandler = null; - return; - } - await deleteOwnedTemporaryFile(path); - - if (path === this.currentPath) { - this.currentPath = null; - this.state = 'idle'; - this.progress = { durationMs: 0, meteringDb: null }; - } + /** + * Returns a session for `owner`, or throws {@link RecorderBusyError} without + * touching the capture when another owner holds it. + */ + async acquire(owner: RecorderOwner): Promise { + await this.assertAvailable(owner); + return this.createSession(owner); } + /** + * Privileged discard of whatever capture exists, regardless of owner. Only + * the account-purge boundary may call this; screens use their session. + */ async cancel(): Promise { - if (Platform.OS === 'android') { - this.stopAndroidPolling(); - await androidRecordingModule().cancel(); - this.progressHandler = null; - this.stateHandler = null; - this.currentPath = null; - this.state = 'idle'; - this.progress = { durationMs: 0, meteringDb: null }; - return; - } - if (this.state === 'starting' && this.pendingStart !== null) { - try { - await this.pendingStart; - } catch { - return; - } - } - - if (this.state === 'stopping' && this.pendingStop !== null) { - try { - await this.pendingStop; - } catch { - return; - } - } - - if (this.pendingTransition !== null) { - try { - await this.pendingTransition; - } catch { - // Continue cleanup from the stable pre-transition recording state. - } - } - - const outputPath = this.currentPath; - let stopError: unknown = null; - try { - if (this.state === 'recording' || this.state === 'paused') { - await this.getSound().stopRecorder(); - } - } catch (error) { - stopError = error; + await this.backend.cancel(); } finally { - this.progressHandler = null; - this.currentPath = null; - this.state = 'idle'; - this.progress = { durationMs: 0, meteringDb: null }; - await deleteOwnedTemporaryFile(outputPath); + this.lease = null; } - - if (stopError !== null) throw stopError; } - async restore( - onProgress?: (progress: RecordingProgress) => void, - onStateChange?: (snapshot: RecordingRuntimeSnapshot) => void, - ): Promise { - if (Platform.OS !== 'android') { - return { - state: 'idle', - recording: null, - meetingId: null, - interruptionReason: null, - startedAtMs: 0, - }; - } - const snapshot = parseNativeSnapshot(await androidRecordingModule().getStatus()); - this.stopAndroidPolling(); - this.progressHandler = onProgress ?? null; - this.stateHandler = onStateChange ?? null; - this.currentPath = snapshot.recording?.path ?? null; - this.progress = { - durationMs: snapshot.recording?.durationMs ?? 0, - meteringDb: null, + private createSession(owner: RecorderOwner): RecorderSession { + return { + owner, + start: (onProgress: RecordingProgressHandler, options?: StartRecordingOptions) => + this.runAsOwner(owner, true, () => this.backend.start(onProgress, options)), + pause: () => this.runAsOwner(owner, false, () => this.backend.pause()), + resume: () => this.runAsOwner(owner, false, () => this.backend.resume()), + stop: () => this.runAsOwner(owner, false, () => this.backend.stop()), + cleanup: (recording?: Pick) => this.cleanupAsOwner(owner, recording), + cancel: () => this.cancelAsOwner(owner), + restore: (onProgress?: RecordingProgressHandler, onStateChange?: RecordingStateHandler) => + this.runAsOwner(owner, true, () => this.backend.restore(onProgress, onStateChange)), }; - if (snapshot.state === 'recording' || snapshot.state === 'paused') { - this.state = snapshot.state; - this.startAndroidPolling(); - } else if (snapshot.state === 'stopped' || snapshot.state === 'recoverable') { - this.state = 'stopped'; - } else { - this.state = 'idle'; + } + + private async holder(): Promise { + if (!(await this.backend.isBusy())) return null; + return this.lease ?? UNLEASED_CAPTURE_OWNER; + } + + private async assertAvailable(owner: RecorderOwner): Promise { + const holder = await this.holder(); + if (holder !== null && holder !== owner) throw new RecorderBusyError(holder); + // Another owner may have started a capture while the status was read. + if (this.lease !== null && this.lease !== owner && this.backend.hasInProcessCapture()) { + throw new RecorderBusyError(this.lease); } - this.progressHandler?.(this.progress); - this.stateHandler?.(snapshot); - return snapshot; } - private startAndroidPolling(): void { - const generation = ++this.androidPollGeneration; - const poll = async (): Promise => { - if (generation !== this.androidPollGeneration) return; - try { - const native = await androidRecordingModule().getStatus(); - if (generation !== this.androidPollGeneration) return; - const snapshot = parseNativeSnapshot(native); - const nextProgress = { - durationMs: snapshot.recording?.durationMs ?? 0, - meteringDb: - typeof native.meteringDb === 'number' && Number.isFinite(native.meteringDb) - ? native.meteringDb - : null, - }; - this.progress = nextProgress; - this.progressHandler?.(nextProgress); - this.stateHandler?.(snapshot); - if (snapshot.state === 'recording' || snapshot.state === 'paused') { - this.state = snapshot.state; - setTimeout(() => { void poll(); }, 250); - } else { - this.currentPath = snapshot.recording?.path ?? null; - this.state = snapshot.state === 'idle' ? 'idle' : 'stopped'; - } - } catch { - // A transient React bridge failure must not stop the native foreground capture. - if (generation === this.androidPollGeneration) { - setTimeout(() => { void poll(); }, 500); - } - } - }; - void poll(); + private async runAsOwner( + owner: RecorderOwner, + claim: boolean, + operation: () => Promise, + ): Promise { + await this.assertAvailable(owner); + if (claim) this.lease = owner; + return operation(); } - private stopAndroidPolling(): void { - this.androidPollGeneration += 1; + private async cleanupAsOwner( + owner: RecorderOwner, + recording?: Pick, + ): Promise { + // An explicit recording is path-scoped and cannot touch another capture; + // an implicit cleanup targets the current capture and needs ownership. + if (recording === undefined) await this.assertAvailable(owner); + await this.backend.cleanup(recording); + this.releaseIfIdle(owner); + } + + private async cancelAsOwner(owner: RecorderOwner): Promise { + if ((await this.holder()) !== owner) return; + if (this.lease !== null && this.lease !== owner) return; + try { + await this.backend.cancel(); + } finally { + this.releaseIfIdle(owner); + } + } + + private releaseIfIdle(owner: RecorderOwner): void { + if (this.lease === owner && !this.backend.hasInProcessCapture()) this.lease = null; } } diff --git a/apps/mobile-rn/src/lib/recorder/android-foreground-backend.ts b/apps/mobile-rn/src/lib/recorder/android-foreground-backend.ts new file mode 100644 index 0000000..17d6fd2 --- /dev/null +++ b/apps/mobile-rn/src/lib/recorder/android-foreground-backend.ts @@ -0,0 +1,407 @@ +import { NativeModules, PermissionsAndroid } from 'react-native'; +import type { + RecordedAudio, + RecorderBackend, + RecordingProgress, + RecordingProgressHandler, + RecordingRuntimeSnapshot, + RecordingRuntimeState, + RecordingStateHandler, + StartRecordingOptions, +} from './recorder-types'; + +const ANDROID_RECORDING_MIME_TYPE = 'audio/wav'; +/** A WAV with nothing but its RIFF header holds no audio. */ +const WAV_HEADER_BYTES = 44; + +type AndroidRecorderState = + | 'idle' + | 'starting' + | 'recording' + | 'pausing' + | 'paused' + | 'resuming' + | 'stopping' + | 'stopped'; + +interface NativeRecordingSnapshot { + state: RecordingRuntimeState; + path: string | null; + uri: string | null; + fileName: string | null; + mimeType: string; + size: number; + durationMs: number; + meteringDb: number | null; + meetingId: string | null; + interruptionReason: string | null; + startedAtMs: number; + recoverable: boolean; +} + +interface D3RORecordingNativeModule { + getStatus: () => Promise; + start: (options: { meetingId?: string }) => Promise; + pause: () => Promise; + resume: () => Promise; + stop: () => Promise; + cancel: () => Promise; + dispose: (path: string) => Promise; +} + +function androidRecordingModule(): D3RORecordingNativeModule { + const candidate = NativeModules.D3RORecording as + | Partial + | undefined; + if ( + candidate === undefined || + typeof candidate.getStatus !== 'function' || + typeof candidate.start !== 'function' || + typeof candidate.pause !== 'function' || + typeof candidate.resume !== 'function' || + typeof candidate.stop !== 'function' || + typeof candidate.cancel !== 'function' || + typeof candidate.dispose !== 'function' + ) { + throw new Error('Android foreground recording module is unavailable'); + } + return candidate as D3RORecordingNativeModule; +} + +function assertFiniteNonNegative(value: unknown, field: string): number { + if (typeof value !== 'number' || !Number.isFinite(value) || value < 0) { + throw new Error(`Android recorder returned invalid ${field}`); + } + return value; +} + +export function parseNativeSnapshot(value: NativeRecordingSnapshot): RecordingRuntimeSnapshot { + const states: readonly RecordingRuntimeState[] = [ + 'idle', + 'starting', + 'recording', + 'paused', + 'stopped', + 'recoverable', + ]; + if (!states.includes(value.state)) { + throw new Error('Android recorder returned an invalid state'); + } + const durationMs = assertFiniteNonNegative(value.durationMs, 'duration'); + const size = assertFiniteNonNegative(value.size, 'file size'); + const startedAtMs = assertFiniteNonNegative(value.startedAtMs, 'start time'); + const hasFile = value.path !== null || value.uri !== null || value.fileName !== null; + if ( + hasFile && + (typeof value.path !== 'string' || + value.path.length === 0 || + value.uri !== `file://${value.path}` || + typeof value.fileName !== 'string' || + !value.fileName.endsWith('.wav') || + value.mimeType !== ANDROID_RECORDING_MIME_TYPE) + ) { + throw new Error('Android recorder returned an invalid recording file'); + } + if (value.state !== 'idle' && !hasFile) { + throw new Error('Android recorder state has no recording file'); + } + const recording = hasFile + ? { + uri: value.uri as string, + path: value.path as string, + fileName: value.fileName as string, + mimeType: ANDROID_RECORDING_MIME_TYPE, + size, + durationMs, + } + : null; + return { + state: value.state, + recording, + meetingId: typeof value.meetingId === 'string' ? value.meetingId : null, + interruptionReason: + typeof value.interruptionReason === 'string' + ? value.interruptionReason + : null, + startedAtMs, + }; +} + +/** + * Android capture through the D3RORecording foreground service. The native + * side owns the WAV file (under filesDir) and its journal, so this backend + * never deletes files itself: it only asks the module to cancel or dispose. + */ +export class AndroidForegroundRecorderBackend implements RecorderBackend { + private state: AndroidRecorderState = 'idle'; + private currentPath: string | null = null; + private progress: RecordingProgress = { durationMs: 0, meteringDb: null }; + private progressHandler: RecordingProgressHandler | null = null; + private stateHandler: RecordingStateHandler | null = null; + private pollGeneration = 0; + + async requestPermission(): Promise { + const permission = PermissionsAndroid.PERMISSIONS.RECORD_AUDIO; + if (await PermissionsAndroid.check(permission)) return true; + + const result = await PermissionsAndroid.request(permission, { + title: 'Microphone access', + message: + 'D3RO Voice needs microphone access to record and transcribe audio.', + buttonPositive: 'Allow', + buttonNegative: 'Cancel', + }); + + return result === PermissionsAndroid.RESULTS.GRANTED; + } + + async start( + onProgress: RecordingProgressHandler, + options: StartRecordingOptions = {}, + ): Promise { + if (this.state !== 'idle') { + throw new Error(`Cannot start recorder while it is ${this.state}`); + } + + this.state = 'starting'; + this.progressHandler = onProgress; + this.stateHandler = options.onStateChange ?? null; + try { + const snapshot = parseNativeSnapshot( + await androidRecordingModule().start( + options.meetingId === undefined + ? {} + : { meetingId: options.meetingId }, + ), + ); + if (snapshot.state !== 'recording' || snapshot.recording === null) { + throw new Error('Android foreground recorder did not start'); + } + this.currentPath = snapshot.recording.path; + this.progress = { + durationMs: snapshot.recording.durationMs, + meteringDb: null, + }; + this.state = 'recording'; + this.stateHandler?.(snapshot); + this.startPolling(); + } catch (error) { + this.progressHandler = null; + this.stateHandler = null; + this.currentPath = null; + this.state = 'idle'; + throw error; + } + } + + async pause(): Promise { + if (this.state !== 'recording') { + throw new Error(`Cannot pause recorder while it is ${this.state}`); + } + + this.state = 'pausing'; + try { + const snapshot = parseNativeSnapshot(await androidRecordingModule().pause()); + if (snapshot.state !== 'paused') throw new Error('Android recorder did not pause'); + this.state = 'paused'; + this.stateHandler?.(snapshot); + } catch (error) { + this.state = 'recording'; + throw error; + } + } + + async resume(): Promise { + if (this.state !== 'paused') { + throw new Error(`Cannot resume recorder while it is ${this.state}`); + } + + this.state = 'resuming'; + try { + const snapshot = parseNativeSnapshot(await androidRecordingModule().resume()); + if (snapshot.state !== 'recording') throw new Error('Android recorder did not resume'); + this.state = 'recording'; + this.stateHandler?.(snapshot); + } catch (error) { + this.state = 'paused'; + throw error; + } + } + + async stop(): Promise { + if (this.state !== 'recording' && this.state !== 'paused') { + throw new Error(`Cannot stop recorder while it is ${this.state}`); + } + if (this.currentPath === null) throw new Error('Recorder has no output path'); + + const previousState = this.state; + this.state = 'stopping'; + let snapshot: RecordingRuntimeSnapshot; + try { + snapshot = parseNativeSnapshot(await androidRecordingModule().stop()); + } catch (error) { + await this.resyncAfterFailedStop(previousState, null); + throw error; + } + + const recording = snapshot.recording; + if ( + (snapshot.state !== 'stopped' && snapshot.state !== 'recoverable') || + recording === null || + recording.size <= WAV_HEADER_BYTES + ) { + const error = new Error('Android recorder produced no recoverable audio'); + await this.resyncAfterFailedStop(previousState, snapshot); + throw error; + } + + this.stopPolling(); + this.currentPath = recording.path; + this.progress = { durationMs: recording.durationMs, meteringDb: null }; + this.state = 'stopped'; + const stateHandler = this.stateHandler; + this.progressHandler = null; + this.stateHandler = null; + stateHandler?.(snapshot); + return recording; + } + + async cleanup(recording?: Pick): Promise { + const path = recording?.path ?? this.currentPath; + if (path !== null) await androidRecordingModule().dispose(path); + // Disposing an older file must not reset a capture that is running now. + if (recording !== undefined && this.currentPath !== null && path !== this.currentPath) { + return; + } + this.stopPolling(); + this.resetToIdle(); + } + + async cancel(): Promise { + this.stopPolling(); + await androidRecordingModule().cancel(); + this.resetToIdle(); + } + + async restore( + onProgress?: RecordingProgressHandler, + onStateChange?: RecordingStateHandler, + ): Promise { + const snapshot = parseNativeSnapshot(await androidRecordingModule().getStatus()); + this.stopPolling(); + this.progressHandler = onProgress ?? null; + this.stateHandler = onStateChange ?? null; + this.adoptSnapshot(snapshot); + this.progressHandler?.(this.progress); + this.stateHandler?.(snapshot); + return snapshot; + } + + async isBusy(): Promise { + if (this.state !== 'idle') return true; + // The foreground service outlives the JS process, so idle JS state alone + // does not prove the microphone and its journal are free. + const snapshot = parseNativeSnapshot(await androidRecordingModule().getStatus()); + return snapshot.state !== 'idle'; + } + + hasInProcessCapture(): boolean { + return this.state !== 'idle'; + } + + /** + * A failed stop must leave the recording where the native side says it is: + * still capturing, or stopped/recoverable with its WAV intact. The owner is + * told through its state handler so it can offer process/discard actions. + */ + private async resyncAfterFailedStop( + previousState: 'recording' | 'paused', + known: RecordingRuntimeSnapshot | null, + ): Promise { + this.stopPolling(); + let snapshot = known; + if (snapshot === null) { + try { + snapshot = parseNativeSnapshot(await androidRecordingModule().getStatus()); + } catch { + snapshot = null; + } + } + if (snapshot === null) { + // Native state is unreadable right now; keep the last known capture and + // let polling report whatever the service settles on. + this.state = previousState; + this.startPolling(); + return; + } + this.adoptSnapshot(snapshot); + this.stateHandler?.(snapshot); + if (this.state !== 'recording' && this.state !== 'paused') { + this.progressHandler = null; + this.stateHandler = null; + } + } + + private adoptSnapshot(snapshot: RecordingRuntimeSnapshot): void { + this.currentPath = snapshot.recording?.path ?? null; + this.progress = { + durationMs: snapshot.recording?.durationMs ?? 0, + meteringDb: null, + }; + if (snapshot.state === 'recording' || snapshot.state === 'paused') { + this.state = snapshot.state; + this.startPolling(); + } else if (snapshot.state === 'stopped' || snapshot.state === 'recoverable') { + this.state = 'stopped'; + } else { + this.state = 'idle'; + } + } + + private resetToIdle(): void { + this.progressHandler = null; + this.stateHandler = null; + this.currentPath = null; + this.state = 'idle'; + this.progress = { durationMs: 0, meteringDb: null }; + } + + private startPolling(): void { + const generation = ++this.pollGeneration; + const poll = async (): Promise => { + if (generation !== this.pollGeneration) return; + try { + const native = await androidRecordingModule().getStatus(); + if (generation !== this.pollGeneration) return; + const snapshot = parseNativeSnapshot(native); + const nextProgress = { + durationMs: snapshot.recording?.durationMs ?? 0, + meteringDb: + typeof native.meteringDb === 'number' && Number.isFinite(native.meteringDb) + ? native.meteringDb + : null, + }; + this.progress = nextProgress; + this.progressHandler?.(nextProgress); + this.stateHandler?.(snapshot); + if (snapshot.state === 'recording' || snapshot.state === 'paused') { + this.state = snapshot.state; + setTimeout(() => { void poll(); }, 250); + } else { + this.currentPath = snapshot.recording?.path ?? null; + this.state = snapshot.state === 'idle' ? 'idle' : 'stopped'; + } + } catch { + // A transient React bridge failure must not stop the native foreground capture. + if (generation === this.pollGeneration) { + setTimeout(() => { void poll(); }, 500); + } + } + }; + void poll(); + } + + private stopPolling(): void { + this.pollGeneration += 1; + } +} diff --git a/apps/mobile-rn/src/lib/recorder/nitro-backend.ts b/apps/mobile-rn/src/lib/recorder/nitro-backend.ts new file mode 100644 index 0000000..d9f0573 --- /dev/null +++ b/apps/mobile-rn/src/lib/recorder/nitro-backend.ts @@ -0,0 +1,373 @@ +import { Dirs, FileSystem } from 'react-native-file-access'; +import { + AudioEncoderAndroidType, + AudioSourceAndroidType, + AVEncoderAudioQualityIOSType, + OutputFormatAndroidType, + createSound, + type AudioSet, + type RecordBackType, +} from 'react-native-nitro-sound'; +import { createUuidV4 } from '../random-id'; +import type { + RecordedAudio, + RecorderBackend, + RecordingProgress, + RecordingProgressHandler, + RecordingRuntimeSnapshot, +} from './recorder-types'; + +const RECORDING_PREFIX = 'd3ro-recording-'; +const RECORDING_EXTENSION = '.m4a'; +const RECORDING_MIME_TYPE = 'audio/mp4'; + +const AUDIO_SETTINGS: AudioSet = { + AudioSourceAndroid: AudioSourceAndroidType.MIC, + OutputFormatAndroid: OutputFormatAndroidType.MPEG_4, + AudioEncoderAndroid: AudioEncoderAndroidType.AAC, + AudioQuality: 'medium', + AudioChannels: 1, + AudioSamplingRate: 44100, + AudioEncodingBitRate: 128000, + AVFormatIDKeyIOS: 'aac', + AVEncoderAudioQualityKeyIOS: AVEncoderAudioQualityIOSType.high, + AVNumberOfChannelsKeyIOS: 1, + AVSampleRateKeyIOS: 44100, +}; + +type NitroRecorderState = + | 'idle' + | 'starting' + | 'recording' + | 'pausing' + | 'paused' + | 'resuming' + | 'stopping' + | 'stopped'; + +function createTemporaryPath(): string { + return `${Dirs.CacheDir}/${RECORDING_PREFIX}${createUuidV4()}${RECORDING_EXTENSION}`; +} + +function pathFromFileUri(uri: string): string | null { + if (!uri.startsWith('file://')) return null; + + try { + return decodeURIComponent(uri.slice('file://'.length)); + } catch { + return null; + } +} + +export function isOwnedTemporaryPath(path: string): boolean { + const cachePrefix = `${Dirs.CacheDir}/`; + if (!path.startsWith(cachePrefix)) return false; + + const fileName = path.slice(cachePrefix.length); + return ( + !fileName.includes('/') && + !fileName.includes('\\') && + fileName.startsWith(RECORDING_PREFIX) && + fileName.endsWith(RECORDING_EXTENSION) + ); +} + +async function deleteOwnedTemporaryFile(path: string | null): Promise { + if (path === null) return; + if (!isOwnedTemporaryPath(path)) { + throw new Error('Refusing to delete a recording outside the app cache'); + } + + if (await FileSystem.exists(path)) { + await FileSystem.unlink(path); + } +} + +async function deleteStaleTemporaryFiles(): Promise { + const entries = await FileSystem.ls(Dirs.CacheDir); + const stalePaths = entries + .filter( + entry => + entry.startsWith(RECORDING_PREFIX) && + entry.endsWith(RECORDING_EXTENSION), + ) + .map(entry => `${Dirs.CacheDir}/${entry}`); + + await Promise.all(stalePaths.map(deleteOwnedTemporaryFile)); +} + +/** + * In-process capture through Nitro Sound (iOS). This backend creates its + * .m4a files in the app cache and is the only code allowed to delete them. + */ +export class NitroRecorderBackend implements RecorderBackend { + private state: NitroRecorderState = 'idle'; + private currentPath: string | null = null; + private progress: RecordingProgress = { durationMs: 0, meteringDb: null }; + private pendingStart: Promise | null = null; + private pendingTransition: Promise | null = null; + private pendingStop: Promise | null = null; + private sound: ReturnType | null = null; + private progressHandler: RecordingProgressHandler | null = null; + + private getSound(): ReturnType { + if (this.sound !== null) return this.sound; + + const sound = createSound(); + sound.setSubscriptionDuration(0.1); + sound.addRecordBackListener((event: RecordBackType) => { + if (this.progressHandler === null) return; + + const durationMs = Number.isFinite(event.currentPosition) + ? Math.max(0, Math.round(event.currentPosition)) + : this.progress.durationMs; + const meteringDb = + typeof event.currentMetering === 'number' && + Number.isFinite(event.currentMetering) + ? event.currentMetering + : null; + + this.progress = { durationMs, meteringDb }; + this.progressHandler(this.progress); + }); + this.sound = sound; + return sound; + } + + async requestPermission(): Promise { + // Nitro Sound requests iOS microphone access through AVAudioSession. + return true; + } + + async start(onProgress: RecordingProgressHandler): Promise { + if (this.state !== 'idle') { + throw new Error(`Cannot start recorder while it is ${this.state}`); + } + + this.state = 'starting'; + try { + await deleteStaleTemporaryFiles(); + } catch (error) { + this.state = 'idle'; + throw error; + } + + const outputPath = createTemporaryPath(); + this.currentPath = outputPath; + this.progress = { durationMs: 0, meteringDb: null }; + this.progressHandler = onProgress; + const sound = this.getSound(); + + const pendingStart = (async () => { + try { + const startedUri = await sound.startRecorder( + outputPath, + AUDIO_SETTINGS, + true, + ); + const startedPath = pathFromFileUri(startedUri); + + if (startedPath !== outputPath) { + throw new Error('Recorder returned an unexpected output URI'); + } + + this.state = 'recording'; + } catch (error) { + try { + await sound.stopRecorder(); + } catch { + // stopRecorder also releases the native recorder when stopping fails. + } + this.progressHandler = null; + if (this.currentPath === outputPath) this.currentPath = null; + this.state = 'idle'; + await deleteOwnedTemporaryFile(outputPath); + throw error; + } + })(); + + this.pendingStart = pendingStart; + try { + await pendingStart; + } finally { + if (this.pendingStart === pendingStart) this.pendingStart = null; + } + } + + async pause(): Promise { + if (this.state !== 'recording') { + throw new Error(`Cannot pause recorder while it is ${this.state}`); + } + + this.state = 'pausing'; + await this.runTransition(async () => { + try { + await this.getSound().pauseRecorder(); + this.state = 'paused'; + } catch (error) { + this.state = 'recording'; + throw error; + } + }); + } + + async resume(): Promise { + if (this.state !== 'paused') { + throw new Error(`Cannot resume recorder while it is ${this.state}`); + } + + this.state = 'resuming'; + await this.runTransition(async () => { + try { + await this.getSound().resumeRecorder(); + this.state = 'recording'; + } catch (error) { + this.state = 'paused'; + throw error; + } + }); + } + + async stop(): Promise { + if (this.state !== 'recording' && this.state !== 'paused') { + throw new Error(`Cannot stop recorder while it is ${this.state}`); + } + + const outputPath = this.currentPath; + if (outputPath === null) throw new Error('Recorder has no output path'); + + const pendingStop = (async (): Promise => { + this.state = 'stopping'; + + try { + const stoppedUri = await this.getSound().stopRecorder(); + const stoppedPath = pathFromFileUri(stoppedUri); + + if (stoppedPath !== outputPath) { + throw new Error('Recorder did not return the expected output file'); + } + + const stat = await FileSystem.stat(outputPath); + if (stat.type !== 'file' || stat.size <= 0) { + throw new Error('Recorder produced an empty audio file'); + } + + this.state = 'stopped'; + return { + uri: stoppedUri, + path: outputPath, + fileName: outputPath.slice(outputPath.lastIndexOf('/') + 1), + mimeType: RECORDING_MIME_TYPE, + size: stat.size, + durationMs: this.progress.durationMs, + }; + } catch (error) { + this.currentPath = null; + this.state = 'idle'; + try { + await deleteOwnedTemporaryFile(outputPath); + } catch { + // The stop failure is what the caller must see; a stale cache file + // is swept by deleteStaleTemporaryFiles() on the next start. + } + throw error; + } finally { + this.progressHandler = null; + } + })(); + + this.pendingStop = pendingStop; + try { + return await pendingStop; + } finally { + if (this.pendingStop === pendingStop) this.pendingStop = null; + } + } + + async cleanup(recording?: Pick): Promise { + const path = recording?.path ?? this.currentPath; + await deleteOwnedTemporaryFile(path); + + if (path === this.currentPath) { + this.currentPath = null; + this.state = 'idle'; + this.progress = { durationMs: 0, meteringDb: null }; + } + } + + async cancel(): Promise { + if (this.state === 'starting' && this.pendingStart !== null) { + try { + await this.pendingStart; + } catch { + return; + } + } + + if (this.state === 'stopping' && this.pendingStop !== null) { + try { + await this.pendingStop; + } catch { + return; + } + } + + if (this.pendingTransition !== null) { + try { + await this.pendingTransition; + } catch { + // Continue cleanup from the stable pre-transition recording state. + } + } + + const outputPath = this.currentPath; + let stopError: unknown = null; + + try { + if (this.state === 'recording' || this.state === 'paused') { + await this.getSound().stopRecorder(); + } + } catch (error) { + stopError = error; + } finally { + this.progressHandler = null; + this.currentPath = null; + this.state = 'idle'; + this.progress = { durationMs: 0, meteringDb: null }; + await deleteOwnedTemporaryFile(outputPath); + } + + if (stopError !== null) throw stopError; + } + + async restore(): Promise { + // Nitro captures die with the JS process; there is nothing to reattach. + return { + state: 'idle', + recording: null, + meetingId: null, + interruptionReason: null, + startedAtMs: 0, + }; + } + + async isBusy(): Promise { + return this.state !== 'idle'; + } + + hasInProcessCapture(): boolean { + return this.state !== 'idle'; + } + + private async runTransition(transition: () => Promise): Promise { + const pendingTransition = transition(); + this.pendingTransition = pendingTransition; + try { + await pendingTransition; + } finally { + if (this.pendingTransition === pendingTransition) { + this.pendingTransition = null; + } + } + } +} diff --git a/apps/mobile-rn/src/lib/recorder/recorder-errors.ts b/apps/mobile-rn/src/lib/recorder/recorder-errors.ts new file mode 100644 index 0000000..5651631 --- /dev/null +++ b/apps/mobile-rn/src/lib/recorder/recorder-errors.ts @@ -0,0 +1,14 @@ +import type { RecorderOwner } from './recorder-types'; + +/** + * Thrown before any side effect when another owner holds the shared capture. + * Callers must not try to clean up after it: they never owned anything. + */ +export class RecorderBusyError extends Error { + readonly code = 'recorder_busy'; + + constructor(readonly holder: RecorderOwner) { + super(`The recorder is in use by ${holder}`); + this.name = 'RecorderBusyError'; + } +} diff --git a/apps/mobile-rn/src/lib/recorder/recorder-types.ts b/apps/mobile-rn/src/lib/recorder/recorder-types.ts new file mode 100644 index 0000000..b990f1c --- /dev/null +++ b/apps/mobile-rn/src/lib/recorder/recorder-types.ts @@ -0,0 +1,87 @@ +export interface RecordingProgress { + durationMs: number; + meteringDb: number | null; +} + +export interface RecordedAudio { + uri: string; + path: string; + fileName: string; + mimeType: string; + size: number; + durationMs: number; +} + +export type RecordingRuntimeState = + | 'idle' + | 'starting' + | 'recording' + | 'paused' + | 'stopped' + | 'recoverable'; + +export interface RecordingRuntimeSnapshot { + state: RecordingRuntimeState; + recording: RecordedAudio | null; + meetingId: string | null; + interruptionReason: string | null; + startedAtMs: number; +} + +export type RecordingProgressHandler = (progress: RecordingProgress) => void; +export type RecordingStateHandler = (snapshot: RecordingRuntimeSnapshot) => void; + +export interface StartRecordingOptions { + meetingId?: string; + onStateChange?: RecordingStateHandler; +} + +/** Screens that may hold the shared microphone capture. */ +export type RecorderOwner = 'record' | 'talk'; + +/** + * Platform capture port. Each implementation owns its native API, its state + * machine and its file-ownership/cleanup policy; nothing is shared between + * platforms, so one platform's cleanup rule cannot leak into another's. + */ +export interface RecorderBackend { + requestPermission(): Promise; + start(onProgress: RecordingProgressHandler, options?: StartRecordingOptions): Promise; + pause(): Promise; + resume(): Promise; + stop(): Promise; + /** Removes one finished recording. With no argument it targets the current capture. */ + cleanup(recording?: Pick): Promise; + /** Discards the current capture and its file, whoever started it. */ + cancel(): Promise; + restore( + onProgress?: RecordingProgressHandler, + onStateChange?: RecordingStateHandler, + ): Promise; + /** + * True while any capture exists, including one that outlived the JS process + * or a stopped/recoverable file that has not been disposed yet. + */ + isBusy(): Promise; + /** Synchronous view of the capture this JS process is driving. */ + hasInProcessCapture(): boolean; +} + +/** + * Owner-scoped handle on the shared recorder. Every mutating call verifies + * that no other owner holds the capture; cancel() is a no-op for a capture the + * owner does not hold, so one screen can never discard another screen's audio. + */ +export interface RecorderSession { + readonly owner: RecorderOwner; + start(onProgress: RecordingProgressHandler, options?: StartRecordingOptions): Promise; + pause(): Promise; + resume(): Promise; + stop(): Promise; + cleanup(recording?: Pick): Promise; + cancel(): Promise; + restore( + onProgress?: RecordingProgressHandler, + onStateChange?: RecordingStateHandler, + ): Promise; +} diff --git a/apps/mobile-rn/src/screens/RecordScreen.tsx b/apps/mobile-rn/src/screens/RecordScreen.tsx index 98b9428..b1b7ec3 100644 --- a/apps/mobile-rn/src/screens/RecordScreen.tsx +++ b/apps/mobile-rn/src/screens/RecordScreen.tsx @@ -22,7 +22,9 @@ import { useI18n } from '@d3ro/i18n'; import { useAuth } from '../lib/auth-context'; import { audioRecorder, + RecorderBusyError, type RecordedAudio, + type RecorderSession, type RecordingProgress, type RecordingRuntimeSnapshot, } from '../lib/audio-recorder'; @@ -67,6 +69,8 @@ type RecordingState = | 'error'; function errorMessage(error: unknown, fallback: string): string { + // Talk holding the microphone is not a failure detail worth showing raw. + if (error instanceof RecorderBusyError) return fallback; return error instanceof Error && error.message.trim() !== '' ? error.message : fallback; @@ -142,12 +146,32 @@ export default function RecordScreen({ route, navigation }: RecordScreenProps): const queueItemIdRef = useRef(null); const recoveredRecordingRef = useRef(null); const activeMeetingIdRef = useRef(route.params?.meetingId ?? null); - const recorder = audioRecorder; + // Record's owner-scoped handle on the shared recorder; see recordSession(). + const recorderSessionRef = useRef(null); const routeMeetingId = route.params?.meetingId ?? null; const routeMeetingTitle = route.params?.meetingTitle ?? null; const routeIncomingMediaId = route.params?.incomingMediaId ?? null; const handledIncomingMediaIds = useRef(new Set()); + /** + * Record owns meeting captures, including one the Android foreground service + * kept alive across a JS restart. Throws RecorderBusyError, with no side + * effects, while Talk holds the microphone. + */ + async function recordSession(): Promise { + const existing = recorderSessionRef.current; + if (existing !== null) return existing; + const session = await audioRecorder.acquire('record'); + recorderSessionRef.current = session; + return session; + } + + function handleRecorderProgress(progress: RecordingProgress): void { + if (!mountedRef.current) return; + setDurationMs(progress.durationMs); + setMeteringDb(progress.meteringDb); + } + function applyRecorderSnapshot(snapshot: RecordingRuntimeSnapshot): void { if (!mountedRef.current) return; activeMeetingIdRef.current = snapshot.meetingId ?? activeMeetingIdRef.current; @@ -178,24 +202,20 @@ export default function RecordScreen({ route, navigation }: RecordScreenProps): useEffect(() => { mountedRef.current = true; - void recorder.restore( - (progress: RecordingProgress) => { - if (!mountedRef.current) return; - setDurationMs(progress.durationMs); - setMeteringDb(progress.meteringDb); - }, - applyRecorderSnapshot, - ).catch(restoreError => { - if (!mountedRef.current) return; - setError(errorMessage(restoreError, t('mobile.rec.recoveryFailed'))); - setState('error'); - }); + void recordSession() + .then(session => session.restore(handleRecorderProgress, applyRecorderSnapshot)) + .catch(restoreError => { + // A Talk capture is not Record's to restore; leave Record idle. + if (!mountedRef.current || restoreError instanceof RecorderBusyError) return; + setError(errorMessage(restoreError, t('mobile.rec.recoveryFailed'))); + setState('error'); + }); return () => { mountedRef.current = false; }; // Foreground recording intentionally survives tab/screen unmount. Logout cleanup is global. // eslint-disable-next-line react-hooks/exhaustive-deps - }, [recorder]); + }, []); useEffect(() => subscribeToDurableQueue(event => { if (event.type === 'completed') { @@ -320,7 +340,11 @@ export default function RecordScreen({ route, navigation }: RecordScreenProps): recorderActiveRef.current = true; let meetingBegun = false; try { - const granted = await recorder.requestPermission(); + // Re-check on every start (not the cached handle) so a busy recorder is + // reported before a meeting is marked as recording. + const recorder = await audioRecorder.acquire('record'); + recorderSessionRef.current = recorder; + const granted = await audioRecorder.requestPermission(); if (!mountedRef.current) return; if (!granted) { recorderActiveRef.current = false; @@ -336,11 +360,7 @@ export default function RecordScreen({ route, navigation }: RecordScreenProps): } else { activeMeetingIdRef.current = null; } - await recorder.start((progress: RecordingProgress) => { - if (!mountedRef.current) return; - setDurationMs(progress.durationMs); - setMeteringDb(progress.meteringDb); - }, { + await recorder.start(handleRecorderProgress, { meetingId: activeMeetingIdRef.current ?? undefined, onStateChange: applyRecorderSnapshot, }); @@ -367,6 +387,7 @@ export default function RecordScreen({ route, navigation }: RecordScreenProps): if (!beginAction()) return; setError(null); try { + const recorder = await recordSession(); if (state === 'recording') { await recorder.pause(); if (mountedRef.current) { @@ -391,7 +412,14 @@ export default function RecordScreen({ route, navigation }: RecordScreenProps): setError(null); setState('stopping'); try { - const recording = await recorder.stop(); + const recorder = await recordSession(); + let recording: RecordedAudio; + try { + recording = await recorder.stop(); + } catch (stopError) { + await recoverFromFailedStop(recorder, stopError); + return; + } recorderActiveRef.current = false; recoveredRecordingRef.current = null; if (!mountedRef.current) { @@ -420,12 +448,36 @@ export default function RecordScreen({ route, navigation }: RecordScreenProps): } } + /** + * A failed stop does not mean the audio is gone: the native capture may + * still be running or may have left a recoverable file. Re-read it so the + * user keeps the stop/cancel or process/discard actions. + */ + async function recoverFromFailedStop( + recorder: RecorderSession, + stopError: unknown, + ): Promise { + let snapshot: RecordingRuntimeSnapshot | null; + try { + snapshot = await recorder.restore(handleRecorderProgress, applyRecorderSnapshot); + } catch { + snapshot = null; + } + if (snapshot !== null && snapshot.state === 'idle') recorderActiveRef.current = false; + if (!mountedRef.current) return; + setError(errorMessage(stopError, t('mobile.rec.processFailed'))); + // Otherwise applyRecorderSnapshot already moved the UI to the live capture + // or to the recoverable file. + if (snapshot === null || snapshot.state === 'idle') setState('error'); + } + async function processRecoveredRecording(): Promise { const recording = recoveredRecordingRef.current; if (recording === null || !beginAction()) return; setError(null); setState('validating'); try { + const recorder = await recordSession(); const input = await prepareRecordedAudio(recording, async () => { await recorder.cleanup(recording); }); @@ -444,6 +496,7 @@ export default function RecordScreen({ route, navigation }: RecordScreenProps): const recording = recoveredRecordingRef.current; if (recording === null || !beginAction()) return; try { + const recorder = await recordSession(); await recorder.cleanup(recording); recoveredRecordingRef.current = null; const meetingId = activeMeetingIdRef.current; @@ -584,6 +637,7 @@ export default function RecordScreen({ route, navigation }: RecordScreenProps): } if (!recorderActiveRef.current || !beginAction()) return; try { + const recorder = await recordSession(); await recorder.cancel(); recorderActiveRef.current = false; const meetingId = activeMeetingIdRef.current; diff --git a/apps/mobile-rn/src/screens/TalkScreen.tsx b/apps/mobile-rn/src/screens/TalkScreen.tsx index 3b6f1e8..3904f79 100644 --- a/apps/mobile-rn/src/screens/TalkScreen.tsx +++ b/apps/mobile-rn/src/screens/TalkScreen.tsx @@ -17,7 +17,12 @@ import { useI18n } from '@d3ro/i18n' import { ThemeButton, ThemeText } from '../theme/themed-components' import { useAuth } from '../lib/auth-context' import { useMobilePreferences } from '../lib/preferences-context' -import { audioRecorder, type RecordingProgress } from '../lib/audio-recorder' +import { + audioRecorder, + type RecordedAudio, + type RecorderSession, + type RecordingProgress, +} from '../lib/audio-recorder' import { ChatServiceError, type ChatErrorCode, @@ -25,6 +30,7 @@ import { } from '../features/chat/chat-service' import { AudioPipelineError } from '../features/import/audio-import-types' import { streamTalkResponse } from '../features/talk/llm-stream-service' +import { isSameTalkAccount, talkAccountKey } from '../features/talk/talk-account' import { transcribeTalkRecording } from '../features/talk/talk-transcription-service' import { shutdownTalkSpeech, @@ -128,6 +134,9 @@ export default function TalkScreen(): React.ReactElement { const recordingStartingRef = useRef(false) const recordingFinishingRef = useRef(false) const recorderCleanupRef = useRef>(Promise.resolve()) + // Talk's own handle on the shared recorder. It can only stop or discard a + // capture Talk holds, never a meeting recording running on the Record tab. + const recorderSessionRef = useRef(null) const pressHeldRef = useRef(false) const textSendPendingRef = useRef(false) const retryMessagesRef = useRef(null) @@ -197,8 +206,12 @@ export default function TalkScreen(): React.ReactElement { if (assistantId !== null) { updateMessages((current) => current.filter((message) => message.id !== assistantId)) } - if (recordingStartingRef.current || recordingActiveRef.current || recordingFinishingRef.current) { - const cleanup = audioRecorder.cancel().catch(() => undefined) + const recorderSession = recorderSessionRef.current + if ( + recorderSession !== null + && (recordingStartingRef.current || recordingActiveRef.current || recordingFinishingRef.current) + ) { + const cleanup = recorderSession.cancel().catch(() => undefined) recorderCleanupRef.current = cleanup } recordingStartingRef.current = false @@ -234,7 +247,7 @@ export default function TalkScreen(): React.ReactElement { () => cancelActiveOperation(false) ), [cancelActiveOperation])) - const authIdentity = session === null ? 'signed-out' : `${session.user.id}:${session.access_token}` + const authIdentity = talkAccountKey(session) const previousAuthIdentityRef = useRef(authIdentity) useEffect(() => { if (previousAuthIdentityRef.current !== authIdentity) { @@ -328,12 +341,7 @@ export default function TalkScreen(): React.ReactElement { }, ) if (!isCurrentOperation(operationId)) return - const latestSession = sessionRef.current - if ( - latestSession === null - || latestSession.user.id !== currentSession.user.id - || latestSession.access_token !== currentSession.access_token - ) { + if (!isSameTalkAccount(currentSession, sessionRef.current)) { throw new ChatServiceError('AUTH_REQUIRED', false, 401) } @@ -395,18 +403,31 @@ export default function TalkScreen(): React.ReactElement { }, [replaceMessages, runConversation]) const finishRecording = useCallback(async (operationId: number): Promise => { - if (recordingFinishingRef.current || !recordingActiveRef.current) return + const recorderSession = recorderSessionRef.current + if (recordingFinishingRef.current || !recordingActiveRef.current || recorderSession === null) return recordingFinishingRef.current = true recordingActiveRef.current = false try { - const recording = await audioRecorder.stop() + let recording: RecordedAudio + try { + recording = await recorderSession.stop() + } catch { + // The failed capture is Talk's own; discard it so the microphone is free again. + await recorderSession.cancel().catch(() => undefined) + if (isCurrentOperation(operationId)) { + setErrorCode('RECORDING_FAILED') + setCanRetry(false) + updatePhase('idle') + } + return + } if (!isCurrentOperation(operationId)) { - await audioRecorder.cleanup(recording) + await recorderSession.cleanup(recording) return } if (recording.durationMs < MIN_RECORDING_MS) { - await audioRecorder.cleanup(recording) + await recorderSession.cleanup(recording) setErrorCode('RECORDING_TOO_SHORT') updatePhase('idle') return @@ -417,7 +438,7 @@ export default function TalkScreen(): React.ReactElement { updatePhase('transcribing') const currentSession = sessionRef.current if (currentSession === null) { - await audioRecorder.cleanup(recording) + await recorderSession.cleanup(recording) throw new AudioPipelineError('auth', 'Sign in to transcribe speech') } @@ -425,16 +446,11 @@ export default function TalkScreen(): React.ReactElement { accessToken: currentSession.access_token, languageCode: preferences.locale, signal: controller.signal, - disposeRecording: () => audioRecorder.cleanup(recording), + disposeRecording: () => recorderSession.cleanup(recording), }) transcriptionControllerRef.current = null if (!isCurrentOperation(operationId)) return - const latestSession = sessionRef.current - if ( - latestSession === null - || latestSession.user.id !== currentSession.user.id - || latestSession.access_token !== currentSession.access_token - ) { + if (!isSameTalkAccount(currentSession, sessionRef.current)) { throw new AudioPipelineError('auth', 'The account changed during transcription') } setInput('') @@ -476,6 +492,7 @@ export default function TalkScreen(): React.ReactElement { retryMessagesRef.current = null await stopSpeech() + let recorderSession: RecorderSession | null = null try { await recorderCleanupRef.current if (!isCurrentOperation(operationId)) return @@ -484,6 +501,11 @@ export default function TalkScreen(): React.ReactElement { setErrorCode('AUTH_REQUIRED') return } + // Throws RecorderBusyError without side effects while a meeting + // recording on the Record tab holds the microphone. + recorderSession = await audioRecorder.acquire('talk') + recorderSessionRef.current = recorderSession + if (!isCurrentOperation(operationId)) return const granted = await audioRecorder.requestPermission() if (!isCurrentOperation(operationId)) return if (!granted) { @@ -491,11 +513,11 @@ export default function TalkScreen(): React.ReactElement { return } - await audioRecorder.start((progress) => { + await recorderSession.start((progress) => { if (isCurrentOperation(operationId)) setRecordingProgress(progress) }) if (!isCurrentOperation(operationId)) { - await audioRecorder.cancel().catch(() => undefined) + await recorderSession.cancel().catch(() => undefined) return } recordingActiveRef.current = true @@ -506,7 +528,9 @@ export default function TalkScreen(): React.ReactElement { setErrorCode('RECORDING_FAILED') updatePhase('idle') } - await audioRecorder.cancel().catch(() => undefined) + // A session only discards a capture Talk holds; when acquisition failed + // there is nothing of Talk's to clean up. + if (recorderSession !== null) await recorderSession.cancel().catch(() => undefined) } finally { recordingStartingRef.current = false } @@ -575,12 +599,7 @@ export default function TalkScreen(): React.ReactElement { fixture = null transcriptionControllerRef.current = null if (!isCurrentOperation(operationId)) return - const latestSession = sessionRef.current - if ( - latestSession === null || - latestSession.user.id !== currentSession.user.id || - latestSession.access_token !== currentSession.access_token - ) { + if (!isSameTalkAccount(currentSession, sessionRef.current)) { throw new AudioPipelineError('auth', 'The account changed during transcription') } submitMessage(result.text, true)