Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/mobile/app.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,7 @@ const config: ExpoConfig = {
`webcredentials:${variant.relyingParty}`,
],
infoPlist: {
UIBackgroundModes: ["audio"],
NSAppTransportSecurity: {
NSAllowsArbitraryLoads: true,
},
Expand Down
5 changes: 4 additions & 1 deletion apps/mobile/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
AppearancePreferencesProvider,
useAppearancePreferences,
} from "./features/settings/appearance/AppearancePreferencesProvider";
import { VoiceInputProvider } from "./features/voice-input/VoiceInputProvider";
import { RootStack } from "./Stack";
import { appAtomRegistry } from "./state/atom-registry";
import { OverlayPortalHost } from "./components/OverlayPortal";
Expand Down Expand Up @@ -91,7 +92,9 @@ function AppContent() {
{/* Blur target for Android dropdown backdrops — see appBlurTarget.ts. */}
<BlurTargetView ref={appBlurTargetRef} style={{ flex: 1 }}>
<IncomingShareProvider>
<Navigation linking={appLinking} theme={navigationTheme} />
<VoiceInputProvider>
<Navigation linking={appLinking} theme={navigationTheme} />
</VoiceInputProvider>
</IncomingShareProvider>
<ConfirmDialogHost />
</BlurTargetView>
Expand Down
2 changes: 0 additions & 2 deletions apps/mobile/src/features/threads/NewTaskDraftScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -326,10 +326,8 @@ export function NewTaskDraftScreen(props: {
});
const voiceInput = useVoiceInputController({
ownerKey: flow.draftKey,
draftMessage: flow.prompt,
selection: composerMenu.selection,
disabled: isIncomingShareTransferPending || isImportingShare || flow.submitting,
onChangeDraftMessage: flow.setPrompt,
onChangeSelection: composerMenu.onSelectionChange,
});
const voicePresentation = resolveVoiceComposerPresentation(
Expand Down
2 changes: 0 additions & 2 deletions apps/mobile/src/features/threads/ThreadComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -353,9 +353,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
});
const voiceInput = useVoiceInputController({
ownerKey: composerOwnerKey,
draftMessage: props.draftMessage,
selection: composerMenu.selection,
onChangeDraftMessage: props.onChangeDraftMessage,
onChangeSelection: composerMenu.onSelectionChange,
});
const voicePresentation = resolveVoiceComposerPresentation(
Expand Down
110 changes: 110 additions & 0 deletions apps/mobile/src/features/voice-input/VoiceInputProvider.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import {
RecordingPresets,
requestRecordingPermissionsAsync,
setAudioModeAsync,
setIsAudioActiveAsync,
useAudioRecorder,
type RecordingStatus,
} from "expo-audio";
import { File } from "expo-file-system";
import { createContext, useCallback, useEffect, useRef, useState, type ReactNode } from "react";
import { AppState, Platform } from "react-native";
import { getLocalVoiceTranscriber } from "../../native/voiceTranscription";
import { appAtomRegistry } from "../../state/atom-registry";
import {
composerDraftsAtom,
getComposerDraftSnapshot,
setComposerDraftText,
} from "../../state/use-composer-drafts";
import { VoiceInputSession } from "./voiceInputSession";

export const VoiceInputSessionContext = createContext<{
session: VoiceInputSession;
recorder: ReturnType<typeof useAudioRecorder>;
} | null>(null);

async function releaseVoiceRecordingAudio(): Promise<void> {
try {
await setAudioModeAsync({
allowsRecording: false,
allowsBackgroundRecording: false,
shouldPlayInBackground: false,
});
} finally {
// Expo does not deactivate AVAudioSession when recording stops or its
// category changes. Explicit deactivation resumes interrupted app audio.
await setIsAudioActiveAsync(false);
}
}

async function configureVoiceRecordingAudio(): Promise<void> {
try {
await setAudioModeAsync({
allowsRecording: true,
interruptionMode: "doNotMix",
playsInSilentMode: true,
shouldPlayInBackground: Platform.OS === "ios",
allowsBackgroundRecording: Platform.OS === "ios",
});
await setIsAudioActiveAsync(true);
} catch (error) {
try {
await releaseVoiceRecordingAudio();
} catch {
// Keep the setup error. The controller has not started a recorder yet.
}
throw error;
}
}

/** Keep the native recorder alive while navigation mounts and releases composers. */
export function VoiceInputProvider({ children }: { children: ReactNode }) {
const sessionRef = useRef<VoiceInputSession | null>(null);
const handleStatus = useCallback((status: RecordingStatus) => {
void sessionRef.current?.controller.handleRecorderStatus({
isFinished: status.isFinished,
hasError: status.hasError || status.mediaServicesDidReset === true,
error: status.error,
url: status.url,
});
}, []);
const recorder = useAudioRecorder(
{ ...RecordingPresets.HIGH_QUALITY, isMeteringEnabled: true },
handleStatus,
);
const [value] = useState(() => {
const session = new VoiceInputSession({
recorder,
getTranscriber: getLocalVoiceTranscriber,
requestPermission: async () => {
const permission = await requestRecordingPermissionsAsync();
return { granted: permission.granted, canAskAgain: permission.canAskAgain };
},
configureRecording: configureVoiceRecordingAudio,
releaseRecording: releaseVoiceRecordingAudio,
deleteRecording: (uri) => new File(uri).delete(),
readText: (ownerKey) => getComposerDraftSnapshot(ownerKey).text,
writeText: setComposerDraftText,
});
return { session, recorder };
});
useEffect(() => {
sessionRef.current = value.session;
const unsubscribe = appAtomRegistry.subscribe(composerDraftsAtom, () =>
value.session.draftChanged(),
);
const subscription = AppState.addEventListener("change", (state) => {
if (state !== "background") return;
void value.session.controller.appMovedToBackground();
if (Platform.OS !== "ios") void value.session.controller.interruptRecording();
});
return () => {
unsubscribe();
subscription.remove();
value.session.controller.dispose();
};
}, [value]);
return (
<VoiceInputSessionContext.Provider value={value}>{children}</VoiceInputSessionContext.Provider>
);
}
Loading
Loading