292 lines
11 KiB
TypeScript
292 lines
11 KiB
TypeScript
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<typeof nativeSnapshot>
|
|
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<string, unknown>).D3RORecording = native
|
|
return native
|
|
}
|
|
|
|
function androidRecorder(): AudioRecorder {
|
|
return new AudioRecorder(new AndroidForegroundRecorderBackend())
|
|
}
|
|
|
|
beforeEach(() => {
|
|
jest.useFakeTimers()
|
|
})
|
|
|
|
afterEach(() => {
|
|
jest.clearAllTimers()
|
|
jest.useRealTimers()
|
|
delete (NativeModules as Record<string, unknown>).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)
|
|
})
|
|
})
|