fix(mobile-recorder): scope recorder control to its owner and keep Android recordings on stop failure

This commit is contained in:
Yun Chan 2026-09-28 00:54:01 +09:00
parent 72c4ddaefb
commit ace946d5c7
10 changed files with 1673 additions and 697 deletions

View file

@ -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<NativeRecordingSnapshot>;
start: (options: { meetingId?: string }) => Promise<NativeRecordingSnapshot>;
pause: () => Promise<NativeRecordingSnapshot>;
resume: () => Promise<NativeRecordingSnapshot>;
stop: () => Promise<NativeRecordingSnapshot>;
cancel: () => Promise<NativeRecordingSnapshot>;
dispose: (path: string) => Promise<void>;
}
function androidRecordingModule(): D3RORecordingNativeModule {
const candidate = NativeModules.D3RORecording as
| Partial<D3RORecordingNativeModule>
| 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<void> {
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<void> {
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<void> | null = null;
private pendingTransition: Promise<void> | null = null;
private pendingStop: Promise<RecordedAudio> | null = null;
private sound: ReturnType<typeof createSound> | 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<typeof createSound> {
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<boolean> {
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<void> {
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<boolean> {
return this.backend.requestPermission();
}
async pause(): Promise<void> {
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<void> {
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<RecordedAudio> {
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<RecordedAudio> => {
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<RecordedAudio, 'path'>): Promise<void> {
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<RecorderSession> {
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<void> {
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<RecordingRuntimeSnapshot> {
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<RecordedAudio, 'path'>) => 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<RecorderOwner | null> {
if (!(await this.backend.isBusy())) return null;
return this.lease ?? UNLEASED_CAPTURE_OWNER;
}
private async assertAvailable(owner: RecorderOwner): Promise<void> {
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<void> => {
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<T>(
owner: RecorderOwner,
claim: boolean,
operation: () => Promise<T>,
): Promise<T> {
await this.assertAvailable(owner);
if (claim) this.lease = owner;
return operation();
}
private stopAndroidPolling(): void {
this.androidPollGeneration += 1;
private async cleanupAsOwner(
owner: RecorderOwner,
recording?: Pick<RecordedAudio, 'path'>,
): Promise<void> {
// 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<void> {
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;
}
}

View file

@ -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<NativeRecordingSnapshot>;
start: (options: { meetingId?: string }) => Promise<NativeRecordingSnapshot>;
pause: () => Promise<NativeRecordingSnapshot>;
resume: () => Promise<NativeRecordingSnapshot>;
stop: () => Promise<NativeRecordingSnapshot>;
cancel: () => Promise<NativeRecordingSnapshot>;
dispose: (path: string) => Promise<void>;
}
function androidRecordingModule(): D3RORecordingNativeModule {
const candidate = NativeModules.D3RORecording as
| Partial<D3RORecordingNativeModule>
| 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<boolean> {
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<void> {
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<void> {
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<void> {
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<RecordedAudio> {
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<RecordedAudio, 'path'>): Promise<void> {
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<void> {
this.stopPolling();
await androidRecordingModule().cancel();
this.resetToIdle();
}
async restore(
onProgress?: RecordingProgressHandler,
onStateChange?: RecordingStateHandler,
): Promise<RecordingRuntimeSnapshot> {
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<boolean> {
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<void> {
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<void> => {
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;
}
}

View file

@ -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<void> {
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<void> {
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<void> | null = null;
private pendingTransition: Promise<void> | null = null;
private pendingStop: Promise<RecordedAudio> | null = null;
private sound: ReturnType<typeof createSound> | null = null;
private progressHandler: RecordingProgressHandler | null = null;
private getSound(): ReturnType<typeof createSound> {
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<boolean> {
// Nitro Sound requests iOS microphone access through AVAudioSession.
return true;
}
async start(onProgress: RecordingProgressHandler): Promise<void> {
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<void> {
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<void> {
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<RecordedAudio> {
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<RecordedAudio> => {
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<RecordedAudio, 'path'>): Promise<void> {
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<void> {
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<RecordingRuntimeSnapshot> {
// 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<boolean> {
return this.state !== 'idle';
}
hasInProcessCapture(): boolean {
return this.state !== 'idle';
}
private async runTransition(transition: () => Promise<void>): Promise<void> {
const pendingTransition = transition();
this.pendingTransition = pendingTransition;
try {
await pendingTransition;
} finally {
if (this.pendingTransition === pendingTransition) {
this.pendingTransition = null;
}
}
}
}

View file

@ -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';
}
}

View file

@ -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<boolean>;
start(onProgress: RecordingProgressHandler, options?: StartRecordingOptions): Promise<void>;
pause(): Promise<void>;
resume(): Promise<void>;
stop(): Promise<RecordedAudio>;
/** Removes one finished recording. With no argument it targets the current capture. */
cleanup(recording?: Pick<RecordedAudio, 'path'>): Promise<void>;
/** Discards the current capture and its file, whoever started it. */
cancel(): Promise<void>;
restore(
onProgress?: RecordingProgressHandler,
onStateChange?: RecordingStateHandler,
): Promise<RecordingRuntimeSnapshot>;
/**
* 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<boolean>;
/** 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<void>;
pause(): Promise<void>;
resume(): Promise<void>;
stop(): Promise<RecordedAudio>;
cleanup(recording?: Pick<RecordedAudio, 'path'>): Promise<void>;
cancel(): Promise<void>;
restore(
onProgress?: RecordingProgressHandler,
onStateChange?: RecordingStateHandler,
): Promise<RecordingRuntimeSnapshot>;
}