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) }) })