fix(mobile-recording): never lose a meeting capture on offline stop, oversize, or re-record

This commit is contained in:
Yun Chan 2026-09-28 02:16:24 +09:00
parent 80538c0f23
commit 5b1621d314
9 changed files with 1238 additions and 56 deletions

View file

@ -0,0 +1,241 @@
import React from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import type { AudioPipelineResult, LocalAudioInput } from '../src/features/import/audio-import-types'
const USER_ID = '11111111-1111-4111-8111-111111111111'
const MEETING_ID = '22222222-2222-4222-8222-222222222222'
const mockBeginMeetingRecording = jest.fn(async () => ({}))
const mockCancelMeetingRecording = jest.fn(async () => ({}))
const mockFailMeetingRecording = jest.fn(async () => ({}))
const mockGetMeetingRecordingLanguage = jest.fn(async (): Promise<string | null> => 'ko')
const mockEnqueueAudioProcessing = jest.fn()
const mockProcessQueuedAudioNow = jest.fn()
const mockValidateLocalAudio = jest.fn()
const mockCleanup = jest.fn(async () => undefined)
const mockStart = jest.fn(async () => undefined)
const mockStop = jest.fn()
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, locale: 'ko' }),
}))
jest.mock('../src/lib/auth-context', () => ({
useAuth: () => ({ user: { id: '11111111-1111-4111-8111-111111111111' } }),
}))
jest.mock('../src/lib/preferences-context', () => ({
useMobilePreferences: () => ({
palette: {
bg: { app: 'app', card: 'card', inset: 'inset' },
accent: { main: 'accent' },
tag: { red: 'red' },
},
}),
}))
jest.mock('@d3ro/ui-native', () => {
const { Pressable, Text, View } = require('react-native') as typeof import('react-native')
const Passthrough = ({ children }: React.PropsWithChildren<Record<string, unknown>>) => <View>{children}</View>
return {
AppStatusBar: () => null,
Header: () => null,
Led: () => null,
WaveBars: () => null,
NativeThemeProvider: Passthrough,
PhosphorText: ({ children, ...props }: React.PropsWithChildren<Record<string, unknown>>) => (
<Text {...props}>{children}</Text>
),
PhysicalButton: ({ label, onPress, disabled, testID }: {
label: string
onPress?: () => void
disabled?: boolean
testID?: string
}) => (
<Pressable testID={testID} disabled={disabled} onPress={onPress}>
<Text>{label}</Text>
</Pressable>
),
d3roNativeFonts: { mono: 'mono' },
}
})
jest.mock('../src/lib/audio-recorder', () => {
class RecorderBusyError extends Error {}
const session = {
restore: async () => ({ state: 'idle', recording: null, meetingId: null, interruptionReason: null }),
start: () => mockStart(),
stop: () => mockStop(),
cleanup: () => mockCleanup(),
cancel: async () => undefined,
pause: async () => undefined,
resume: async () => undefined,
}
return {
RecorderBusyError,
audioRecorder: {
acquire: async () => session,
requestPermission: async () => true,
},
}
})
jest.mock('../src/features/import/audio-validation', () => ({
validateLocalAudio: (options: unknown) => mockValidateLocalAudio(options),
}))
jest.mock('../src/features/import/audio-file-picker', () => ({ pickAudioFile: jest.fn() }))
jest.mock('../src/features/import/incoming-media-intent', () => ({ consumeIncomingMediaAudio: jest.fn() }))
jest.mock('../src/features/notifications/notification-native', () => ({
showLocalTranscriptionCompleted: jest.fn(async () => true),
}))
jest.mock('../src/features/recording/durable-processing-queue', () => {
class QueuedAudioPendingError extends Error {}
return {
QueuedAudioPendingError,
discardQueuedAudio: jest.fn(async () => undefined),
enqueueAudioProcessing: (...args: unknown[]) => mockEnqueueAudioProcessing(...args),
processQueuedAudioNow: (...args: unknown[]) => mockProcessQueuedAudioNow(...args),
retryQueuedAudio: jest.fn(async () => undefined),
subscribeToDurableQueue: () => () => undefined,
}
})
jest.mock('../src/features/meetings/meetings-service', () => ({
beginMeetingRecording: (...args: unknown[]) => mockBeginMeetingRecording(...(args as [])),
cancelMeetingRecording: (...args: unknown[]) => mockCancelMeetingRecording(...(args as [])),
failMeetingRecording: (...args: unknown[]) => mockFailMeetingRecording(...(args as [])),
getMeetingRecordingLanguage: (...args: unknown[]) => mockGetMeetingRecordingLanguage(...(args as [])),
}))
import RecordScreen from '../src/screens/RecordScreen'
import { AudioPipelineError } from '../src/features/import/audio-import-types'
const RECORDING = {
uri: 'file:///mock/capture.wav',
path: '/mock/capture.wav',
fileName: 'capture.wav',
mimeType: 'audio/wav',
size: 64,
durationMs: 20 * 60_000,
}
function validInput(): LocalAudioInput {
return {
uri: RECORDING.uri,
path: RECORDING.path,
fileName: RECORDING.fileName,
mimeType: 'audio/wav',
sizeBytes: RECORDING.size,
durationMs: RECORDING.durationMs,
source: 'recording',
dispose: async () => undefined,
}
}
function meetingResult(): AudioPipelineResult {
return {
historyId: null,
audioFileId: 'audio',
meetingId: MEETING_ID,
processingJobId: 'job',
transcript: 'meeting transcript',
provider: 'test',
language: 'ko',
durationSeconds: 1,
deduplicated: false,
}
}
type Params = { meetingId?: string; meetingTitle?: string; meetingLanguage?: string }
async function render(params: Params, setParams: (next: Params) => void): Promise<ReactTestRenderer> {
let renderer: ReactTestRenderer | undefined
await act(async () => {
renderer = create(
<RecordScreen
route={{ params }}
navigation={{ getParent: () => undefined, setParams }}
/>,
)
})
if (renderer === undefined) throw new Error('RecordScreen did not render')
return renderer
}
async function press(renderer: ReactTestRenderer, testID: string): Promise<void> {
await act(async () => {
renderer.root.findByProps({ testID }).props.onPress()
})
}
function has(renderer: ReactTestRenderer, testID: string): boolean {
return renderer.root.findAllByProps({ testID }).length > 0
}
describe('RecordScreen meeting durability (redteam r2-27)', () => {
beforeEach(() => {
jest.clearAllMocks()
mockStop.mockResolvedValue(RECORDING)
mockValidateLocalAudio.mockImplementation(async () => validInput())
})
test('a hand-off that fails before persistence keeps the capture recoverable', async () => {
mockEnqueueAudioProcessing.mockRejectedValue(new AudioPipelineError('file-read', 'disk full'))
const renderer = await render({ meetingId: MEETING_ID, meetingLanguage: 'ko' }, jest.fn())
await press(renderer, 'record-start')
await press(renderer, 'record-stop')
expect(mockCleanup).not.toHaveBeenCalled()
expect(has(renderer, 'record-process-recovered')).toBe(true)
expect(has(renderer, 'record-discard-recovered')).toBe(true)
})
test('a recording over the upload limit is kept instead of deleted on STOP', async () => {
mockValidateLocalAudio.mockRejectedValue(
new AudioPipelineError('file-too-large', 'Audio files must be 25 MB or smaller'),
)
const renderer = await render({}, jest.fn())
await press(renderer, 'record-start')
await press(renderer, 'record-stop')
expect(mockCleanup).not.toHaveBeenCalled()
expect(mockEnqueueAudioProcessing).not.toHaveBeenCalled()
expect(has(renderer, 'record-process-recovered')).toBe(true)
})
test('an offline meeting language lookup does not block the hand-off', async () => {
mockGetMeetingRecordingLanguage.mockRejectedValueOnce(new TypeError('Network request failed'))
mockEnqueueAudioProcessing.mockResolvedValue({ id: 'item' })
mockProcessQueuedAudioNow.mockResolvedValue(meetingResult())
const renderer = await render({ meetingId: MEETING_ID }, jest.fn())
await press(renderer, 'record-start')
await press(renderer, 'record-stop')
expect(mockEnqueueAudioProcessing).toHaveBeenCalledTimes(1)
expect(has(renderer, 'record-transcript-result')).toBe(true)
})
test('a finished meeting capture releases the binding so NEW RECORDING cannot restart it', async () => {
mockEnqueueAudioProcessing.mockResolvedValue({ id: 'item' })
mockProcessQueuedAudioNow.mockResolvedValue(meetingResult())
const setParams = jest.fn()
const renderer = await render({ meetingId: MEETING_ID, meetingLanguage: 'ko' }, setParams)
await press(renderer, 'record-start')
expect(mockBeginMeetingRecording).toHaveBeenCalledWith(USER_ID, MEETING_ID)
await press(renderer, 'record-stop')
expect(setParams).toHaveBeenCalledWith({
meetingId: undefined,
meetingTitle: undefined,
meetingLanguage: undefined,
})
expect(has(renderer, 'record-view-meeting')).toBe(true)
expect(has(renderer, 'record-meeting-context')).toBe(false)
// The tab bar can bring the screen back with its old params still set.
await press(renderer, 'record-start')
expect(mockBeginMeetingRecording).toHaveBeenCalledTimes(1)
expect(mockStart).toHaveBeenCalledTimes(2)
})
})

View file

@ -0,0 +1,281 @@
import AsyncStorage from '@react-native-async-storage/async-storage'
import { FileSystem } from 'react-native-file-access'
import {
AudioPipelineError,
MAX_IMPORT_BYTES,
type AudioPipelineResult,
type LocalAudioInput,
} from '../src/features/import/audio-import-types'
import type { RecordedAudio } from '../src/lib/audio-recorder'
const mockProcessAudioInput = jest.fn<Promise<AudioPipelineResult>, [LocalAudioInput, unknown]>()
const mockQueueMeetingRecording = jest.fn<Promise<unknown>, [string, string, number]>()
const mockMarkMeetingProcessingFailure = jest.fn(async () => undefined)
const mockFailMeetingRecording = jest.fn(async () => undefined)
const mockValidateLocalAudio = jest.fn<Promise<LocalAudioInput>, [unknown]>()
jest.mock('../src/features/import/audio-transcription-service', () => ({
processAudioInput: (input: LocalAudioInput, options: unknown) => mockProcessAudioInput(input, options),
}))
jest.mock('../src/features/import/audio-validation', () => ({
validateLocalAudio: (options: unknown) => mockValidateLocalAudio(options),
}))
jest.mock('../src/features/meetings/meetings-service', () => {
class MeetingServiceError extends Error {
readonly code: string
constructor(code: string, message: string) {
super(message)
this.code = code
this.name = 'MeetingServiceError'
}
}
return {
MeetingServiceError,
queueMeetingRecording: (userId: string, meetingId: string, durationMs: number) =>
mockQueueMeetingRecording(userId, meetingId, durationMs),
markMeetingProcessingFailure: () => mockMarkMeetingProcessingFailure(),
failMeetingRecording: () => mockFailMeetingRecording(),
}
})
import { MeetingServiceError } from '../src/features/meetings/meetings-service'
import {
clearQueuedAudioForUser,
durableQueueTestContract,
enqueueAudioProcessing,
processQueuedAudioNow,
QueuedAudioPendingError,
type DurableQueueItem,
} from '../src/features/recording/durable-processing-queue'
import { classifyMarkQueuedFailure } from '../src/features/recording/meeting-lifecycle-port'
import { mayDiscardOriginalOnFailure } from '../src/features/import/audio-input-ownership'
import { prepareRecordedAudio } from '../src/features/import/recorded-audio-input'
const START_MS = 1_700_000_000_000
const USER = '44444444-4444-4444-8444-444444444444'
const MEETING = '55555555-5555-4555-8555-555555555555'
const ORIGINAL = '/mock/CacheDir/meeting-capture.wav'
const mockFileSystem = FileSystem as typeof FileSystem & {
filesystem: Map<string, string>
mkdir: (path: string) => Promise<string>
}
function recordingInput(dispose: () => Promise<void>, source: LocalAudioInput['source'] = 'recording'): LocalAudioInput {
return {
uri: `file://${ORIGINAL}`,
path: ORIGINAL,
fileName: 'meeting-capture.wav',
mimeType: 'audio/wav',
sizeBytes: 5,
durationMs: 1_234,
source,
dispose,
}
}
function result(): AudioPipelineResult {
return {
historyId: null,
audioFileId: 'audio-file',
meetingId: MEETING,
processingJobId: 'job',
transcript: 'minutes',
provider: 'test',
language: 'ko',
durationSeconds: 1,
deduplicated: false,
}
}
async function stored(): Promise<DurableQueueItem[]> {
return durableQueueTestContract.parseQueue(
await AsyncStorage.getItem(durableQueueTestContract.storageKey),
)
}
describe('meeting recording durability (redteam r2-27)', () => {
beforeEach(async () => {
jest.useFakeTimers({ now: START_MS })
await AsyncStorage.clear()
mockFileSystem.filesystem.clear()
mockFileSystem.filesystem.set(ORIGINAL, 'audio')
// The upstream jest mock has no mkdir; the queue creates its directory.
mockFileSystem.mkdir = jest.fn(async (path: string) => path)
mockProcessAudioInput.mockReset()
mockQueueMeetingRecording.mockReset()
mockMarkMeetingProcessingFailure.mockClear()
mockFailMeetingRecording.mockClear()
mockValidateLocalAudio.mockReset()
})
afterEach(async () => {
await clearQueuedAudioForUser(USER).catch(() => undefined)
for (let index = 0; index < 5; index += 1) await jest.advanceTimersByTimeAsync(0)
jest.useRealTimers()
})
test('an offline stop keeps the meeting recording queued instead of deleting it', async () => {
mockQueueMeetingRecording.mockRejectedValue(
new MeetingServiceError('network', 'Network request failed'),
)
const dispose = jest.fn(async () => {
mockFileSystem.filesystem.delete(ORIGINAL)
})
const item = await enqueueAudioProcessing(recordingInput(dispose), {
userId: USER,
languageCode: 'ko',
meetingId: MEETING,
})
// Enqueue only persists; it never talks to the server.
expect(mockQueueMeetingRecording).not.toHaveBeenCalled()
expect(item).toMatchObject({ meetingId: MEETING, meetingQueuePending: true, status: 'pending' })
expect(dispose).toHaveBeenCalledTimes(1)
expect(mockFileSystem.filesystem.get(item.path)).toBe('audio')
await expect(processQueuedAudioNow(USER, item.id)).rejects.toBeInstanceOf(QueuedAudioPendingError)
expect(mockProcessAudioInput).not.toHaveBeenCalled()
const [retrying] = await stored()
expect(retrying).toMatchObject({
id: item.id,
status: 'retry',
lastErrorCode: 'persist',
meetingQueuePending: true,
})
expect(mockFileSystem.filesystem.get(item.path)).toBe('audio')
// Back online: the idempotent mark runs first, then processing.
mockQueueMeetingRecording.mockResolvedValue({})
mockProcessAudioInput.mockResolvedValue(result())
await expect(processQueuedAudioNow(USER, item.id)).resolves.toMatchObject({ transcript: 'minutes' })
expect(mockQueueMeetingRecording).toHaveBeenLastCalledWith(USER, MEETING, 1_234)
expect(mockQueueMeetingRecording).toHaveBeenCalledTimes(2)
expect(await stored()).toEqual([])
expect(mockFileSystem.filesystem.has(item.path)).toBe(false)
})
test('a successful mark is not repeated when processing itself is retried', async () => {
mockQueueMeetingRecording.mockResolvedValue({})
mockProcessAudioInput.mockRejectedValueOnce(new AudioPipelineError('upload', 'offline'))
const item = await enqueueAudioProcessing(recordingInput(async () => undefined), {
userId: USER,
languageCode: 'ko',
meetingId: MEETING,
})
await expect(processQueuedAudioNow(USER, item.id)).rejects.toBeInstanceOf(QueuedAudioPendingError)
expect((await stored())[0]).toMatchObject({ status: 'retry', meetingQueuePending: false })
mockProcessAudioInput.mockResolvedValue(result())
await processQueuedAudioNow(USER, item.id)
// ended_at on the server is not pushed to the retry time.
expect(mockQueueMeetingRecording).toHaveBeenCalledTimes(1)
})
test('a meeting the server no longer considers recordable is handed to processing, not retried forever', async () => {
mockQueueMeetingRecording.mockRejectedValue(
new MeetingServiceError('validation', 'recordable meeting not found'),
)
mockProcessAudioInput.mockResolvedValue(result())
const item = await enqueueAudioProcessing(recordingInput(async () => undefined), {
userId: USER,
languageCode: 'ko',
meetingId: MEETING,
})
await expect(processQueuedAudioNow(USER, item.id)).resolves.toMatchObject({ meetingId: MEETING })
expect(mockProcessAudioInput).toHaveBeenCalledTimes(1)
})
test('items persisted before the pending flag existed are not re-marked', async () => {
mockProcessAudioInput.mockResolvedValue(result())
const path = `${durableQueueTestContract.queueDirectory}/queued-abc123.wav`
mockFileSystem.filesystem.set(path, 'audio')
const legacy: DurableQueueItem = {
schemaVersion: 1,
id: 'abc123',
userId: USER,
meetingId: MEETING,
path,
uri: `file://${path}`,
fileName: 'legacy.wav',
mimeType: 'audio/wav',
sizeBytes: 5,
durationMs: 100,
source: 'recording',
languageCode: 'ko',
status: 'pending',
phase: null,
attempts: 0,
uploadedBytes: 0,
nextAttemptAtMs: START_MS,
lastErrorCode: null,
lastErrorMessage: null,
createdAtMs: 1,
updatedAtMs: 1,
}
await AsyncStorage.setItem(durableQueueTestContract.storageKey, JSON.stringify([legacy]))
await processQueuedAudioNow(USER, legacy.id)
expect(mockQueueMeetingRecording).not.toHaveBeenCalled()
expect(durableQueueTestContract.parseQueue(JSON.stringify([{ ...legacy, meetingQueuePending: 'yes' }]))).toEqual([])
})
test('a failed copy keeps the original recording but still cleans an imported temp copy', async () => {
const recordingDispose = jest.fn(async () => undefined)
mockFileSystem.filesystem.delete(ORIGINAL)
await expect(enqueueAudioProcessing(recordingInput(recordingDispose), {
userId: USER,
languageCode: 'ko',
meetingId: MEETING,
})).rejects.toThrow()
expect(recordingDispose).not.toHaveBeenCalled()
expect(await stored()).toEqual([])
const importDispose = jest.fn(async () => undefined)
await expect(enqueueAudioProcessing(recordingInput(importDispose, 'file-picker'), {
userId: USER,
languageCode: 'ko',
})).rejects.toThrow()
expect(importDispose).toHaveBeenCalledTimes(1)
})
test('a recording over the upload limit is retained for Record and still disposed for Talk', async () => {
const recording: RecordedAudio = {
uri: `file://${ORIGINAL}`,
path: ORIGINAL,
fileName: 'meeting-capture.wav',
mimeType: 'audio/wav',
size: MAX_IMPORT_BYTES + 1,
durationMs: 20 * 60_000,
} as RecordedAudio
mockValidateLocalAudio.mockRejectedValue(
new AudioPipelineError('file-too-large', 'Audio files must be 25 MB or smaller'),
)
const retained = jest.fn(async () => undefined)
await expect(prepareRecordedAudio(recording, retained, { onInvalid: 'retain' }))
.rejects.toMatchObject({ code: 'file-too-large' })
expect(retained).not.toHaveBeenCalled()
const talk = jest.fn(async () => undefined)
await expect(prepareRecordedAudio(recording, talk)).rejects.toMatchObject({ code: 'file-too-large' })
expect(talk).toHaveBeenCalledTimes(1)
})
test('ownership and mark-queued policies', () => {
expect(mayDiscardOriginalOnFailure('recording')).toBe(false)
expect(mayDiscardOriginalOnFailure('file-picker')).toBe(true)
expect(mayDiscardOriginalOnFailure('share-intent')).toBe(true)
expect(classifyMarkQueuedFailure(new MeetingServiceError('network', 'x'))).toBe('retry')
expect(classifyMarkQueuedFailure(new MeetingServiceError('auth', 'x'))).toBe('retry')
expect(classifyMarkQueuedFailure(new MeetingServiceError('server', 'x'))).toBe('retry')
expect(classifyMarkQueuedFailure(new MeetingServiceError('validation', 'x'))).toBe('proceed')
expect(classifyMarkQueuedFailure(new MeetingServiceError('forbidden', 'x'))).toBe('proceed')
expect(classifyMarkQueuedFailure(new MeetingServiceError('not-found', 'x'))).toBe('proceed')
expect(classifyMarkQueuedFailure(new TypeError('Network request failed'))).toBe('retry')
})
})

View file

@ -0,0 +1,18 @@
import type { LocalAudioInput } from './audio-import-types';
/**
* Who may delete the original audio when a hand-off fails before the audio
* is safely persisted somewhere else.
*
* A capture is the only copy of what the user said, so a failed hand-off must
* leave it recoverable for the caller (Record keeps it as a recovered
* recording the user can process again or discard explicitly). Picked and
* shared files are app-owned temporary copies of a file the user still has
* elsewhere, so they are cleaned up as before.
*/
export function mayDiscardOriginalOnFailure(source: LocalAudioInput['source']): boolean {
return source !== 'recording';
}
/** What prepareRecordedAudio does with the capture when validation rejects it. */
export type InvalidRecordingPolicy = 'dispose' | 'retain';

View file

@ -1,10 +1,22 @@
import type { RecordedAudio } from '../../lib/audio-recorder';
import type { LocalAudioInput } from './audio-import-types';
import type { InvalidRecordingPolicy } from './audio-input-ownership';
import { validateLocalAudio } from './audio-validation';
export interface PrepareRecordedAudioOptions {
/**
* 'dispose' (default) deletes a capture that fails validation, which suits
* ephemeral dictation (Talk). 'retain' leaves it untouched so the caller can
* keep it recoverable: a long meeting over the upload size limit must not
* be deleted the moment the user presses STOP.
*/
onInvalid?: InvalidRecordingPolicy;
}
export async function prepareRecordedAudio(
recording: RecordedAudio,
dispose: () => Promise<void>,
options: PrepareRecordedAudioOptions = {},
): Promise<LocalAudioInput> {
try {
return await validateLocalAudio({
@ -18,7 +30,7 @@ export async function prepareRecordedAudio(
dispose,
});
} catch (error) {
await dispose();
if ((options.onInvalid ?? 'dispose') === 'dispose') await dispose();
throw error;
}
}

View file

@ -8,14 +8,15 @@ import {
type LocalAudioInput,
} from '../import/audio-import-types';
import { processAudioInput } from '../import/audio-transcription-service';
import {
failMeetingRecording,
markMeetingProcessingFailure,
queueMeetingRecording,
} from '../meetings/meetings-service';
import { mayDiscardOriginalOnFailure } from '../import/audio-input-ownership';
import { createUuidV4 } from '../../lib/random-id';
import { createQueueRepository, type QueueRepository } from './durable-queue-repository';
import { createRetryScheduler, type RetryScheduler } from './retry-scheduler';
import {
classifyMarkQueuedFailure,
meetingsServiceLifecyclePort,
type MeetingLifecyclePort,
} from './meeting-lifecycle-port';
const STORAGE_KEY = '@d3ro/audio-processing-queue/v1';
const QUEUE_DIRECTORY = `${Dirs.DocumentDir}/d3ro-processing-queue`;
@ -51,6 +52,13 @@ export interface DurableQueueItem {
lastErrorMessage: string | null;
createdAtMs: number;
updatedAtMs: number;
/**
* True while the server has not yet been told that this meeting capture
* stopped and is waiting for processing. Absent on non-meeting items and on
* items persisted before this field existed (their enqueue only kept the
* item after that call had succeeded).
*/
meetingQueuePending?: boolean;
}
export type DurableQueueEvent =
@ -125,7 +133,8 @@ function parseQueue(raw: string | null): DurableQueueItem[] {
(item.lastErrorCode === null || (typeof item.lastErrorCode === 'string' && item.lastErrorCode.length <= 64)) &&
(item.lastErrorMessage === null || (typeof item.lastErrorMessage === 'string' && item.lastErrorMessage.length <= MAX_ERROR_LENGTH)) &&
typeof item.createdAtMs === 'number' && Number.isFinite(item.createdAtMs) &&
typeof item.updatedAtMs === 'number' && Number.isFinite(item.updatedAtMs)
typeof item.updatedAtMs === 'number' && Number.isFinite(item.updatedAtMs) &&
(item.meetingQueuePending === undefined || typeof item.meetingQueuePending === 'boolean')
);
});
}
@ -137,6 +146,8 @@ const repository: QueueRepository<DurableQueueItem> = createQueueRepository({
parse: parseQueue,
});
let meetingLifecycle: MeetingLifecyclePort = meetingsServiceLifecyclePort;
const scheduler: RetryScheduler = createRetryScheduler({
now: () => Date.now(),
setTimer: (callback, delayMs) => setTimeout(callback, delayMs),
@ -176,22 +187,34 @@ function fileExtension(fileName: string, mimeType: string): string {
return 'audio';
}
/**
* Makes the audio durable and nothing else: copy into queue storage, verify
* the copy, persist the item. The caller's original is released only after
* the item is persisted. When persisting fails, a recording is left untouched
* so the caller can keep it recoverable; an imported temporary copy is
* cleaned up as before. Server state transitions happen later, in the
* retryable processing step, so a network error can never undo a persisted
* item.
*/
export async function enqueueAudioProcessing(
input: LocalAudioInput,
options: { userId: string; languageCode: MeetingLanguage; meetingId?: string },
): Promise<DurableQueueItem> {
const releaseOnFailure = async (): Promise<void> => {
if (mayDiscardOriginalOnFailure(input.source)) await input.dispose();
};
if (!/^[0-9a-f-]{36}$/i.test(options.userId)) {
await input.dispose();
await releaseOnFailure();
throw new AudioPipelineError('auth', 'A valid account is required for the processing queue');
}
if (options.meetingId !== undefined && !/^[0-9a-f-]{36}$/i.test(options.meetingId)) {
await input.dispose();
await releaseOnFailure();
throw new AudioPipelineError('persist', 'Meeting id is invalid');
}
const id = queueId();
const target = `${QUEUE_DIRECTORY}/queued-${id}.${fileExtension(input.fileName, input.mimeType)}`;
let persisted = false;
let item: DurableQueueItem;
try {
await ensureQueueDirectory();
await FileSystem.cp(input.path, target);
@ -200,7 +223,7 @@ export async function enqueueAudioProcessing(
throw new AudioPipelineError('file-read', 'Durable audio copy could not be verified');
}
const now = Date.now();
const item: DurableQueueItem = {
const created: DurableQueueItem = {
schemaVersion: 1,
id,
userId: options.userId,
@ -222,25 +245,19 @@ export async function enqueueAudioProcessing(
lastErrorMessage: null,
createdAtMs: now,
updatedAtMs: now,
...(options.meetingId !== undefined ? { meetingQueuePending: true } : {}),
};
await mutateQueue(items => [...items.filter(current => current.id !== id), item]);
persisted = true;
if (item.meetingId !== null) {
await queueMeetingRecording(
item.userId,
item.meetingId,
item.durationMs ?? 0,
);
}
emit({ type: 'changed', item });
return item;
await mutateQueue(items => [...items.filter(current => current.id !== id), created]);
item = created;
} catch (error) {
if (persisted) await mutateQueue(items => items.filter(item => item.id !== id));
await removeOwnedFile(target).catch(() => undefined);
await releaseOnFailure().catch(() => undefined);
throw error;
} finally {
await input.dispose();
}
// The durable copy now owns the audio; the original is no longer needed.
await input.dispose().catch(() => undefined);
emit({ type: 'changed', item });
return item;
}
function queueInput(item: DurableQueueItem): LocalAudioInput {
@ -286,33 +303,59 @@ async function reportMeetingFailure(
terminal: boolean,
): Promise<void> {
if (item.meetingId === null) return;
const meetingId = item.meetingId;
const idempotencyKey = await FileSystem.hash(item.path, 'SHA-256')
.then(hash => `mobile-meeting:${item.meetingId}:${hash.toLowerCase()}`)
.then(hash => `mobile-meeting:${meetingId}:${hash.toLowerCase()}`)
.catch(() => null);
if (idempotencyKey !== null) {
try {
await markMeetingProcessingFailure(
item.userId,
item.meetingId,
await meetingLifecycle.markProcessingFailure({
userId: item.userId,
meetingId,
idempotencyKey,
error.code,
error.message.slice(0, MAX_ERROR_LENGTH),
errorCode: error.code,
errorMessage: error.message.slice(0, MAX_ERROR_LENGTH),
terminal,
);
});
return;
} catch {
// Upload failures happen before the server processing job exists.
}
}
if (terminal) {
await failMeetingRecording(
await meetingLifecycle.failRecording(
item.userId,
item.meetingId,
meetingId,
error.message.slice(0, MAX_ERROR_LENGTH),
).catch(() => undefined);
}
}
/**
* First, retryable processing step for a meeting capture: tell the server the
* recording stopped and is queued. Runs until it succeeds once per item. A
* reachable server that says the meeting is not recordable is not asked again
* (see classifyMarkQueuedFailure), while an unreachable one leaves the item
* for a later attempt instead of spending an upload on it.
*/
async function markMeetingQueued(item: DurableQueueItem): Promise<DurableQueueItem> {
if (item.meetingId === null || item.meetingQueuePending !== true) return item;
try {
await meetingLifecycle.markQueued(item.userId, item.meetingId, item.durationMs ?? 0);
} catch (cause) {
if (classifyMarkQueuedFailure(cause) === 'retry') {
throw new AudioPipelineError('persist', 'Meeting recording could not be marked as queued', cause);
}
}
// Discarded while the call was in flight: do not resurrect the item.
if (discardedItems.has(item.id)) {
throw new AudioPipelineError('cancelled', 'Queued audio processing was cancelled');
}
const updated: DurableQueueItem = { ...item, meetingQueuePending: false, updatedAtMs: Date.now() };
await replaceItem(updated);
return updated;
}
async function processItemCore(item: DurableQueueItem): Promise<AudioPipelineResult> {
if (!(await FileSystem.exists(item.path))) {
throw new AudioPipelineError('file-read', 'Queued audio file is missing');
@ -329,6 +372,7 @@ async function processItemCore(item: DurableQueueItem): Promise<AudioPipelineRes
};
await replaceItem(current);
try {
current = await markMeetingQueued(current);
const result = await processAudioInput(queueInput(current), {
expectedUserId: current.userId,
languageCode: current.languageCode,
@ -520,4 +564,11 @@ export const durableQueueTestContract = {
retryDelayMs,
ownedQueuePath,
parseQueue,
/** Swaps the meeting lifecycle port; returns a function restoring the default. */
useMeetingLifecyclePort(port: MeetingLifecyclePort): () => void {
meetingLifecycle = port;
return () => {
meetingLifecycle = meetingsServiceLifecyclePort;
};
},
};

View file

@ -0,0 +1,67 @@
import {
failMeetingRecording,
markMeetingProcessingFailure,
queueMeetingRecording,
} from '../meetings/meetings-service';
/**
* Server-side meeting state transitions the durable queue needs. The queue
* owns the local audio; these calls only tell the server what happened to it,
* so none of them may decide whether the local audio survives.
*/
export interface MeetingLifecyclePort {
/** Idempotent: recording -> processing with the captured duration. */
markQueued(userId: string, meetingId: string, durationMs: number): Promise<void>;
markProcessingFailure(failure: {
userId: string;
meetingId: string;
idempotencyKey: string;
errorCode: string;
errorMessage: string;
terminal: boolean;
}): Promise<void>;
failRecording(userId: string, meetingId: string, errorMessage: string): Promise<void>;
}
export const meetingsServiceLifecyclePort: MeetingLifecyclePort = {
async markQueued(userId, meetingId, durationMs) {
await queueMeetingRecording(userId, meetingId, durationMs);
},
async markProcessingFailure(failure) {
await markMeetingProcessingFailure(
failure.userId,
failure.meetingId,
failure.idempotencyKey,
failure.errorCode,
failure.errorMessage,
failure.terminal,
);
},
async failRecording(userId, meetingId, errorMessage) {
await failMeetingRecording(userId, meetingId, errorMessage);
},
};
/**
* What a failed markQueued means for the queued item.
*
* - 'retry': the server could not be reached or refused the session for now
* (network, auth, server, conflict). Keep the item and try again later;
* uploading now would fail for the same reason.
* - 'proceed': the server answered that the meeting is not in a recordable
* state (validation, forbidden, not-found), e.g. after this item already
* failed terminally once and the user retries it. Marking it queued will
* never succeed, so stop asking and let processing, whose begin step is the
* authoritative transition, decide.
*/
export type MeetingQueueFailurePolicy = 'retry' | 'proceed';
const NOT_RECORDABLE_CODES: ReadonlySet<string> = new Set(['validation', 'forbidden', 'not-found']);
export function classifyMarkQueuedFailure(error: unknown): MeetingQueueFailurePolicy {
// Structural check: meetings-service maps every RPC failure to a
// MeetingServiceError whose `code` is one of its MeetingServiceErrorCode.
if (!(error instanceof Error) || error.name !== 'MeetingServiceError') return 'retry';
const code: unknown = (error as Error & { code?: unknown }).code;
return typeof code === 'string' && NOT_RECORDABLE_CODES.has(code) ? 'proceed' : 'retry';
}

View file

@ -36,6 +36,7 @@ import {
type LocalAudioInput,
} from '../features/import/audio-import-types';
import { prepareRecordedAudio } from '../features/import/recorded-audio-input';
import { mayDiscardOriginalOnFailure } from '../features/import/audio-input-ownership';
import { useMobilePreferences } from '../lib/preferences-context';
import { showLocalTranscriptionCompleted } from '../features/notifications/notification-native';
import {
@ -115,10 +116,18 @@ function isPipelineState(state: RecordingState): boolean {
);
}
interface RecordScreenMeetingParams {
meetingId?: string;
meetingTitle?: string;
meetingLanguage?: string;
}
interface RecordScreenProps {
route: { params?: { meetingId?: string; meetingTitle?: string; meetingLanguage?: string; incomingMediaId?: string } }
route: { params?: RecordScreenMeetingParams & { incomingMediaId?: string } }
navigation: {
getParent: () => { navigate: (name: 'MeetingDetail', params: { meetingId: string }) => void } | undefined
/** Clears the meeting binding once its capture is committed. */
setParams?: (params: RecordScreenMeetingParams) => void
}
}
@ -139,6 +148,8 @@ export default function RecordScreen({ route, navigation }: RecordScreenProps):
const [actionPending, setActionPending] = useState(false);
const [sharePending, setSharePending] = useState(false);
const [pendingIncomingMediaId, setPendingIncomingMediaId] = useState<string | null>(null);
// Meeting whose capture produced the result on screen (for "view meeting").
const [completedMeetingId, setCompletedMeetingId] = useState<string | null>(null);
const actionPendingRef = useRef(false);
const mountedRef = useRef(true);
const recorderActiveRef = useRef(false);
@ -146,12 +157,38 @@ export default function RecordScreen({ route, navigation }: RecordScreenProps):
const queueItemIdRef = useRef<string | null>(null);
const recoveredRecordingRef = useRef<RecordedAudio | null>(null);
const activeMeetingIdRef = useRef<string | null>(route.params?.meetingId ?? null);
// Meeting the next START binds to. MeetingDetail arms it through route
// params; it is released once that meeting's capture is committed, so a
// later "NEW RECORDING" cannot re-begin (and restart) a finished meeting.
const armedMeetingIdRef = useRef<string | null>(route.params?.meetingId ?? null);
// Record's owner-scoped handle on the shared recorder; see recordSession().
const recorderSessionRef = useRef<RecorderSession | null>(null);
const routeMeetingId = route.params?.meetingId ?? null;
const routeMeetingTitle = route.params?.meetingTitle ?? null;
const routeIncomingMediaId = route.params?.incomingMediaId ?? null;
const handledIncomingMediaIds = useRef(new Set<string>());
// Latest route binding, read by callbacks registered on an earlier render.
const routeMeetingIdRef = useRef<string | null>(routeMeetingId);
routeMeetingIdRef.current = routeMeetingId;
/**
* A meeting binding covers one capture. Once the capture's transcript is
* committed, Record falls back to plain recording and the route params are
* cleared, so returning through the tab bar does not re-arm the meeting.
* MeetingDetail re-arms it explicitly ("record again") with fresh params.
*/
function releaseMeetingBinding(meetingId: string | null): void {
setCompletedMeetingId(meetingId);
if (meetingId === null) return;
if (armedMeetingIdRef.current === meetingId) armedMeetingIdRef.current = null;
if (activeMeetingIdRef.current === meetingId) activeMeetingIdRef.current = null;
if (routeMeetingIdRef.current === meetingId) {
navigation.setParams?.({ meetingId: undefined, meetingTitle: undefined, meetingLanguage: undefined });
}
}
// Latest release callback, for subscriptions registered on an earlier render.
const releaseMeetingBindingRef = useRef(releaseMeetingBinding);
releaseMeetingBindingRef.current = releaseMeetingBinding;
/**
* Record owns meeting captures, including one the Android foreground service
@ -222,6 +259,7 @@ export default function RecordScreen({ route, navigation }: RecordScreenProps):
if (event.itemId !== queueItemIdRef.current || !mountedRef.current) return;
queueItemIdRef.current = null;
operationUserRef.current = null;
releaseMeetingBindingRef.current(event.result.meetingId ?? activeMeetingIdRef.current);
setTranscript(event.result.transcript);
setProvider(event.result.provider);
setDurationMs(Math.max(0, Math.round(event.result.durationSeconds * 1_000)));
@ -254,6 +292,7 @@ export default function RecordScreen({ route, navigation }: RecordScreenProps):
}, [user?.id]);
useEffect(() => {
armedMeetingIdRef.current = routeMeetingId;
if (!recorderActiveRef.current && recoveredRecordingRef.current === null) {
activeMeetingIdRef.current = routeMeetingId;
}
@ -275,19 +314,29 @@ export default function RecordScreen({ route, navigation }: RecordScreenProps):
if (mountedRef.current) setActionPending(false);
}
/**
* Hands the input to the durable queue and processes it. `onPersisted` runs
* once the queue owns its own copy; before that, a recording is still the
* caller's only copy and must not be deleted on failure.
*/
async function processInput(
input: LocalAudioInput,
meetingId: string | null = activeMeetingIdRef.current,
onPersisted?: () => void,
): Promise<void> {
if (user === null) {
await input.dispose();
if (mayDiscardOriginalOnFailure(input.source)) await input.dispose();
throw new AudioPipelineError('auth', t('mobile.rec.loginRequired'));
}
operationUserRef.current = user.id;
const routeLanguage = route.params?.meetingLanguage;
const routeLanguage = meetingId !== null && meetingId === route.params?.meetingId
? route.params?.meetingLanguage
: undefined;
// The language lookup is an optimisation, not a precondition: offline it
// falls back to the app locale instead of blocking the hand-off.
const storedLanguage = meetingId !== null && routeLanguage === undefined
? await getMeetingRecordingLanguage(user.id, meetingId)
? await getMeetingRecordingLanguage(user.id, meetingId).catch(() => null)
: null;
const queued = await enqueueAudioProcessing(input, {
userId: user.id,
@ -295,9 +344,11 @@ export default function RecordScreen({ route, navigation }: RecordScreenProps):
meetingId: meetingId ?? undefined,
});
queueItemIdRef.current = queued.id;
onPersisted?.();
try {
const result = await processQueuedAudioNow(user.id, queued.id);
if (!mountedRef.current) return;
releaseMeetingBinding(result.meetingId ?? meetingId);
setTranscript(result.transcript);
setProvider(result.provider);
setDurationMs(Math.max(0, Math.round(result.durationSeconds * 1000)));
@ -335,9 +386,11 @@ export default function RecordScreen({ route, navigation }: RecordScreenProps):
setDeduplicated(false);
setDurationMs(0);
setMeteringDb(null);
setCompletedMeetingId(null);
recoveredRecordingRef.current = null;
setState('starting');
recorderActiveRef.current = true;
const meetingToBegin = armedMeetingIdRef.current;
let meetingBegun = false;
try {
// Re-check on every start (not the cached handle) so a busy recorder is
@ -352,11 +405,11 @@ export default function RecordScreen({ route, navigation }: RecordScreenProps):
setState('error');
return;
}
if (routeMeetingId !== null) {
if (meetingToBegin !== null) {
if (user === null) throw new AudioPipelineError('auth', t('mobile.rec.loginRequired'));
await beginMeetingRecording(user.id, routeMeetingId);
await beginMeetingRecording(user.id, meetingToBegin);
meetingBegun = true;
activeMeetingIdRef.current = routeMeetingId;
activeMeetingIdRef.current = meetingToBegin;
} else {
activeMeetingIdRef.current = null;
}
@ -367,10 +420,10 @@ export default function RecordScreen({ route, navigation }: RecordScreenProps):
if (mountedRef.current) setState('recording');
} catch (recordingError) {
recorderActiveRef.current = false;
if (meetingBegun && user !== null && routeMeetingId !== null) {
if (meetingBegun && user !== null && meetingToBegin !== null) {
await failMeetingRecording(
user.id,
routeMeetingId,
meetingToBegin,
errorMessage(recordingError, t('mobile.rec.startFailed')),
).catch(() => undefined);
}
@ -428,10 +481,7 @@ export default function RecordScreen({ route, navigation }: RecordScreenProps):
}
setDurationMs(recording.durationMs);
setFileName(recording.fileName);
const input = await prepareRecordedAudio(recording, async () => {
await recorder.cleanup(recording);
});
await processInput(input, activeMeetingIdRef.current);
await handOffRecording(recorder, recording);
} catch (recordingError) {
recorderActiveRef.current = false;
if (!mountedRef.current) return;
@ -471,6 +521,40 @@ export default function RecordScreen({ route, navigation }: RecordScreenProps):
if (snapshot === null || snapshot.state === 'idle') setState('error');
}
/**
* Hands a finished capture to the durable queue. Until the queue has
* persisted its own copy the capture is the only copy, so a failure before
* that point (too large to upload, unreadable, no session, storage full)
* keeps it as a recovered recording the user can process again or discard
* explicitly, instead of deleting it. Failures after that point belong to
* the queue item and are rethrown.
*/
async function handOffRecording(
recorder: RecorderSession,
recording: RecordedAudio,
): Promise<void> {
let persisted = false;
try {
const input = await prepareRecordedAudio(
recording,
async () => {
await recorder.cleanup(recording);
},
{ onInvalid: 'retain' },
);
await processInput(input, activeMeetingIdRef.current, () => {
persisted = true;
});
} catch (handOffError) {
if (persisted) throw handOffError;
recoveredRecordingRef.current = recording;
if (!mountedRef.current) return;
setMeteringDb(null);
setError(errorMessage(handOffError, t('mobile.rec.processFailed')));
setState('recoverable');
}
}
async function processRecoveredRecording(): Promise<void> {
const recording = recoveredRecordingRef.current;
if (recording === null || !beginAction()) return;
@ -478,11 +562,8 @@ export default function RecordScreen({ route, navigation }: RecordScreenProps):
setState('validating');
try {
const recorder = await recordSession();
const input = await prepareRecordedAudio(recording, async () => {
await recorder.cleanup(recording);
});
recoveredRecordingRef.current = null;
await processInput(input, activeMeetingIdRef.current);
await handOffRecording(recorder, recording);
} catch (processingError) {
if (!mountedRef.current) return;
setError(errorMessage(processingError, t('mobile.rec.processFailed')));
@ -503,7 +584,7 @@ export default function RecordScreen({ route, navigation }: RecordScreenProps):
if (meetingId !== null && user !== null) {
await cancelMeetingRecording(user.id, meetingId).catch(() => undefined);
}
activeMeetingIdRef.current = routeMeetingId;
activeMeetingIdRef.current = armedMeetingIdRef.current;
if (mountedRef.current) {
setState('idle');
setError(null);
@ -526,6 +607,7 @@ export default function RecordScreen({ route, navigation }: RecordScreenProps):
let selectedForProcessing = false;
setError(null);
setState('selecting');
setCompletedMeetingId(null);
try {
const input = await pickAudioFile();
selectedForProcessing = true;
@ -566,6 +648,7 @@ export default function RecordScreen({ route, navigation }: RecordScreenProps):
const previousState = state;
setError(null);
setState('selecting');
setCompletedMeetingId(null);
try {
const input = await consumeIncomingMediaAudio();
if (input === null) {
@ -644,7 +727,7 @@ export default function RecordScreen({ route, navigation }: RecordScreenProps):
if (meetingId !== null && user !== null) {
await cancelMeetingRecording(user.id, meetingId).catch(() => undefined);
}
activeMeetingIdRef.current = routeMeetingId;
activeMeetingIdRef.current = armedMeetingIdRef.current;
if (!mountedRef.current) return;
setState('idle');
setTranscript('');
@ -830,15 +913,14 @@ export default function RecordScreen({ route, navigation }: RecordScreenProps):
/>
</>
)}
{state === 'done' && activeMeetingIdRef.current !== null && (
{state === 'done' && completedMeetingId !== null && (
<>
<View style={styles.buttonSpacer} />
<PhysicalButton
label={t('mobile.rec.viewMeeting')}
variant="secondary"
onPress={() => {
const meetingId = activeMeetingIdRef.current;
if (meetingId !== null) navigation.getParent()?.navigate('MeetingDetail', { meetingId });
navigation.getParent()?.navigate('MeetingDetail', { meetingId: completedMeetingId });
}}
testID="record-view-meeting"
/>