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

@ -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"
/>