diff --git a/packages/app-mobile/components/voiceTyping/AudioRecordingBanner.tsx b/packages/app-mobile/components/voiceTyping/AudioRecordingBanner.tsx index 1306cc031c..d8cd49f31b 100644 --- a/packages/app-mobile/components/voiceTyping/AudioRecordingBanner.tsx +++ b/packages/app-mobile/components/voiceTyping/AudioRecordingBanner.tsx @@ -2,7 +2,7 @@ import * as React from 'react'; import { PrimaryButton, SecondaryButton } from '../buttons'; import { _ } from '@joplin/lib/locale'; import { useCallback, useEffect, useRef, useState } from 'react'; -import { Audio, InterruptionModeIOS } from 'expo-av'; +import { AudioQuality, getRecordingPermissionsAsync, IOSOutputFormat, requestRecordingPermissionsAsync, setAudioModeAsync, type RecordingOptions, useAudioRecorder as useExpoAudioRecorder, useAudioRecorderState } from 'expo-audio'; import Logger from '@joplin/utils/Logger'; import { OnFileSavedCallback, RecorderState } from './types'; import { Platform } from 'react-native'; @@ -11,7 +11,6 @@ import FsDriverWeb from '../../utils/fs-driver/fs-driver-rn.web'; import uuid from '@joplin/lib/uuid'; import RecordingControls from './RecordingControls'; import { Text } from 'react-native-paper'; -import { AndroidAudioEncoder, AndroidOutputFormat, IOSAudioQuality, IOSOutputFormat, RecordingOptions } from 'expo-av/build/Audio'; import time from '@joplin/lib/time'; import { toFileExtension } from '@joplin/lib/mime-utils'; import { formatMsToDurationCompat, msleep } from '@joplin/utils/time'; @@ -25,23 +24,21 @@ interface Props { // Modified from the Expo default recording options to create // .m4a recordings on both Android and iOS (rather than .3gp on Android). -const recordingOptions = (): RecordingOptions => ({ +const recordingOptions: RecordingOptions = { + extension: '.m4a', isMeteringEnabled: true, + sampleRate: 44100, + numberOfChannels: 2, + bitRate: 64000, android: { extension: '.m4a', - outputFormat: AndroidOutputFormat.MPEG_4, - audioEncoder: AndroidAudioEncoder.AAC, - sampleRate: 44100, - numberOfChannels: 2, - bitRate: 64000, + outputFormat: 'mpeg4', + audioEncoder: 'aac', }, ios: { extension: '.m4a', - audioQuality: IOSAudioQuality.MIN, + audioQuality: AudioQuality.MIN, outputFormat: IOSOutputFormat.MPEG4AAC, - sampleRate: 44100, - numberOfChannels: 2, - bitRate: 64000, linearPCMBitDepth: 16, linearPCMIsBigEndian: false, linearPCMIsFloat: false, @@ -56,14 +53,16 @@ const recordingOptions = (): RecordingOptions => ({ ].find(type => MediaRecorder.isTypeSupported(type)) ?? 'audio/webm', bitsPerSecond: 128000, } : {}, -}); +}; const getRecordingFileName = (extension: string) => { return `recording-${time.formatDateToLocal(new Date())}${extension}`; }; -const recordingToSaveData = async (recording: Audio.Recording) => { - let uri = recording.getURI(); +const recordingToSaveData = async (recordingUri: string|null) => { + if (!recordingUri) throw new Error(_('Unable to access the recording file.')); + + let uri = recordingUri; let type: string|undefined; let fileName; @@ -73,7 +72,7 @@ const recordingToSaveData = async (recording: Audio.Recording) => { const fetchResult = await fetch(uri); const blob = await fetchResult.blob(); - type = recordingOptions().web.mimeType; + type = recordingOptions.web.mimeType; const extension = `.${toFileExtension(type)}`; fileName = getRecordingFileName(extension); const file = new File([blob], fileName); @@ -82,10 +81,9 @@ const recordingToSaveData = async (recording: Audio.Recording) => { await (shim.fsDriver() as FsDriverWeb).createReadOnlyVirtualFile(path, file); uri = path; } else { - const options = recordingOptions(); const extension = Platform.select({ - android: options.android.extension, - ios: options.ios.extension, + android: recordingOptions.android.extension, + ios: recordingOptions.ios.extension, default: '', }); fileName = getRecordingFileName(extension); @@ -95,75 +93,72 @@ const recordingToSaveData = async (recording: Audio.Recording) => { }; const resetAudioMode = async () => { - await Audio.setAudioModeAsync({ - // When enabled, iOS may use the small (phone call) speaker - // instead of the default one, so it's disabled when not recording: - allowsRecordingIOS: false, - playsInSilentModeIOS: false, - staysActiveInBackground: false, + await setAudioModeAsync({ + allowsRecording: false, + allowsBackgroundRecording: false, + playsInSilentMode: false, + shouldPlayInBackground: false, }); }; const useAudioRecorder = (onFileSaved: OnFileSavedCallback, onDismiss: ()=> void) => { - const [permissionResponse, requestPermissions] = Audio.usePermissions(); const [recordingState, setRecordingState] = useState(RecorderState.Idle); const [error, setError] = useState(''); - const [duration, setDuration] = useState(0); + const recorder = useExpoAudioRecorder(recordingOptions); + const recorderStatus = useAudioRecorderState(recorder, 100); + const isRecordingRef = useRef(false); - const recordingRef = useRef(null); const onStartRecording = useCallback(async () => { try { setRecordingState(RecorderState.Loading); + setError(''); - if (permissionResponse?.status !== 'granted') { - const response = await requestPermissions(); + const permissionResponse = await getRecordingPermissionsAsync(); + if (permissionResponse.status !== 'granted') { + const response = await requestRecordingPermissionsAsync(); if (!response.granted) { throw new Error(_('Missing permission to record audio.')); } // Work around "This experience is currently in the background, so the audio session could not be activated" // See https://github.com/expo/expo/issues/21782 - // May be resolved by migrating to expo-audio. await msleep(500); } - await Audio.setAudioModeAsync({ - allowsRecordingIOS: true, - playsInSilentModeIOS: true, - staysActiveInBackground: true, + await setAudioModeAsync({ + allowsRecording: true, + allowsBackgroundRecording: true, + playsInSilentMode: true, + shouldPlayInBackground: true, // Fixes an issue where opening a recording in the iOS audio player // breaks creating new recordings. // See https://github.com/expo/expo/issues/31152#issuecomment-2341811087 - interruptionModeIOS: InterruptionModeIOS.DoNotMix, + interruptionMode: 'doNotMix', }); + await recorder.prepareToRecordAsync(); + isRecordingRef.current = true; + recorder.record(); setRecordingState(RecorderState.Recording); - const recording = new Audio.Recording(); - await recording.prepareToRecordAsync(recordingOptions()); - recording.setOnRecordingStatusUpdate(status => { - setDuration(status.durationMillis); - }); - recordingRef.current = recording; - await recording.startAsync(); } catch (error) { logger.error('Error starting recording:', error); setError(`Recording error: ${error}`); setRecordingState(RecorderState.Error); - void recordingRef.current?.stopAndUnloadAsync(); - recordingRef.current = null; + if (isRecordingRef.current) { + isRecordingRef.current = false; + void recorder.stop(); + } } - }, [permissionResponse, requestPermissions]); + }, [recorder]); const onStopRecording = useCallback(async () => { - const recording = recordingRef.current; - recordingRef.current = null; - try { setRecordingState(RecorderState.Processing); - await recording.stopAndUnloadAsync(); + await recorder.stop(); + isRecordingRef.current = false; await resetAudioMode(); - const saveEvent = await recordingToSaveData(recording); + const saveEvent = await recordingToSaveData(recorder.uri); onFileSaved(saveEvent); onDismiss(); } catch (error) { @@ -171,25 +166,35 @@ const useAudioRecorder = (onFileSaved: OnFileSavedCallback, onDismiss: ()=> void setError(`Save error: ${error}`); setRecordingState(RecorderState.Error); } - }, [onFileSaved, onDismiss]); + }, [onFileSaved, onDismiss, recorder]); const onStartStopRecording = useCallback(async () => { if (recordingState === RecorderState.Idle) { await onStartRecording(); - } else if (recordingState === RecorderState.Recording && recordingRef.current) { + } else if (recordingState === RecorderState.Recording) { await onStopRecording(); } }, [recordingState, onStartRecording, onStopRecording]); useEffect(() => () => { - if (recordingRef.current) { - void recordingRef.current?.stopAndUnloadAsync(); - recordingRef.current = null; - void resetAudioMode(); - } - }, []); + if (isRecordingRef.current) { + isRecordingRef.current = false; - return { onStartStopRecording, error, duration, recordingState }; + const stopRecorderOnCleanup = async () => { + try { + await recorder.stop(); + } catch (error) { + logger.warn('Error stopping recorder during cleanup:', error); + } + + await resetAudioMode(); + }; + + void stopRecorderOnCleanup(); + } + }, [recorder]); + + return { onStartStopRecording, error, duration: recorderStatus.durationMillis, recordingState }; }; const AudioRecordingBanner: React.FC = props => { diff --git a/packages/app-mobile/ios/Podfile.lock b/packages/app-mobile/ios/Podfile.lock index d1f36fe49d..80e1677ed7 100644 --- a/packages/app-mobile/ios/Podfile.lock +++ b/packages/app-mobile/ios/Podfile.lock @@ -1,7 +1,4 @@ PODS: - - EXAV (16.0.8): - - ExpoModulesCore - - ReactCommon/turbomodule/core - EXConstants (18.0.13): - ExpoModulesCore - EXImageLoader (6.0.0): @@ -34,6 +31,8 @@ PODS: - Yoga - ExpoAsset (12.0.12): - ExpoModulesCore + - ExpoAudio (1.1.1): + - ExpoModulesCore - ExpoCamera (17.0.10): - ExpoModulesCore - ZXingObjC/OneD @@ -72,9 +71,9 @@ PODS: - ReactNativeDependencies - Yoga - FBLazyVector (0.81.6) - - hermes-engine (0.81.5): - - hermes-engine/Pre-built (= 0.81.5) - - hermes-engine/Pre-built (0.81.5) + - hermes-engine (0.81.6): + - hermes-engine/Pre-built (= 0.81.6) + - hermes-engine/Pre-built (0.81.6) - JoplinCommonShareExtension (1.0.0) - JoplinRNShareExtension (1.0.0): - JoplinCommonShareExtension @@ -154,7 +153,7 @@ PODS: - React-utils - ReactNativeDependencies - Yoga - - React-Core-prebuilt (0.81.5): + - React-Core-prebuilt (0.81.6): - ReactNativeDependencies - React-Core/CoreModulesHeaders (0.81.6): - hermes-engine @@ -2134,7 +2133,7 @@ PODS: - ReactCommon/turbomodule/core - ReactNativeDependencies - Yoga - - SDWebImage/Core (5.21.5) + - SDWebImage/Core (5.21.7) - SDWebImageWebPCoder (0.15.0): - libwebp (~> 1.0) - SDWebImage/Core (~> 5.17) @@ -2169,11 +2168,11 @@ PODS: - ZXingObjC/Core DEPENDENCIES: - - EXAV (from `../node_modules/expo-av/ios`) - EXConstants (from `../node_modules/expo-constants/ios`) - EXImageLoader (from `../node_modules/expo-image-loader/ios`) - Expo (from `../node_modules/expo`) - ExpoAsset (from `../node_modules/expo-asset/ios`) + - ExpoAudio (from `../node_modules/expo-audio/ios`) - ExpoCamera (from `../node_modules/expo-camera/ios`) - ExpoFileSystem (from `../node_modules/expo-file-system/ios`) - ExpoFont (from `../node_modules/expo-font/ios`) @@ -2293,8 +2292,6 @@ SPEC REPOS: - ZXingObjC EXTERNAL SOURCES: - EXAV: - :path: "../node_modules/expo-av/ios" EXConstants: :path: "../node_modules/expo-constants/ios" EXImageLoader: @@ -2303,6 +2300,8 @@ EXTERNAL SOURCES: :path: "../node_modules/expo" ExpoAsset: :path: "../node_modules/expo-asset/ios" + ExpoAudio: + :path: "../node_modules/expo-audio/ios" ExpoCamera: :path: "../node_modules/expo-camera/ios" ExpoFileSystem: @@ -2522,11 +2521,11 @@ EXTERNAL SOURCES: :path: "../node_modules/react-native/ReactCommon/yoga" SPEC CHECKSUMS: - EXAV: b60fcf142fae6684d295bc28cd7cfcb3335570ea EXConstants: fce59a631a06c4151602843667f7cfe35f81e271 EXImageLoader: 189e3476581efe3ad4d1d3fb4735b7179eb26f05 Expo: 04993fbd7b06dc98ffac58da8847298470dc3db1 ExpoAsset: f867e55ceb428aab99e1e8c082b5aee7c159ea18 + ExpoAudio: e4cfe3a2f3317b8487460685385a9867a07fb4fb ExpoCamera: 6a326deb45ba840749652e4c15198317aa78497e ExpoFileSystem: 858a44267a3e6e9057e0888ad7c7cfbf55d52063 ExpoFont: 35ac6191ed86bbf56b3ebd2d9154eda9fad5b509 @@ -2534,7 +2533,7 @@ SPEC CHECKSUMS: ExpoLocalAuthentication: 8a31808565da7af926dd9b595e98594d8b1553b6 ExpoModulesCore: f3da4f1ab5a8375d0beafab763739dbee8446583 FBLazyVector: 14ce6e3675cacb2683ad30272f04274a4ee5b67d - hermes-engine: 9f4dfe93326146a1c99eb535b1cb0b857a3cd172 + hermes-engine: 7219f6e751ad6ec7f3d7ec121830ee34dae40749 JoplinCommonShareExtension: a8b60b02704d85a7305627912c0240e94af78db7 JoplinRNShareExtension: e158a4b53ee0aa9cd3037a16221dc8adbd6f7860 libwebp: 02b23773aedb6ff1fd38cec7a77b81414c6842a8 @@ -2546,7 +2545,7 @@ SPEC CHECKSUMS: React: 348d1689d8686d034c5b7667dc45de86c6319dd1 React-callinvoker: 2c3b664f3482f5bc5560ea1edcbbe69748752f08 React-Core: 346787852200a732b187805344b8a350d464e004 - React-Core-prebuilt: 02f0ad625ddd47463c009c2d0c5dd35c0d982599 + React-Core-prebuilt: 721ab014acfaff1e4b8fc0d2f7d6f41ea9a706ed React-CoreModules: 7e07391a1082d02c37f846a362f7574ab035933c React-cxxreact: c50d278c785792a077a6b357aaabd9e5d09e9c6f React-debug: 1b91785fec02ea76c793ead23bed1528d96b4262 @@ -2635,7 +2634,7 @@ SPEC CHECKSUMS: RNSecureRandom: b64d263529492a6897e236a22a2c4249aa1b53dc RNShare: 0e600372fb35783fe30d413efd28d11de2bf6cf0 RNSVG: cf9ae78f2edf2988242c71a6392d15ff7dd62522 - SDWebImage: e9c98383c7572d713c1a0d7dd2783b10599b9838 + SDWebImage: e9fc87c1aab89a8ab1bbd74eba378c6f53be8abf SDWebImageWebPCoder: 0e06e365080397465cc73a7a9b472d8a3bd0f377 WhisperVoiceTyping: 343ea840cbde2a5f3508f8b016ebcf1c089179ea Yoga: 786fa7d9d2ff6060b4e688062243fa69c323d140 diff --git a/packages/app-mobile/jest.setup.js b/packages/app-mobile/jest.setup.js index 8e71c0cb70..aaad533e7b 100644 --- a/packages/app-mobile/jest.setup.js +++ b/packages/app-mobile/jest.setup.js @@ -76,14 +76,41 @@ jest.mock('@react-native-clipboard/clipboard', () => { return { default: { getString: jest.fn(), setString: jest.fn() } }; }); +jest.doMock('expo-audio', () => { + return { + AudioQuality: { + MIN: 'min', + }, + IOSOutputFormat: { + MPEG4AAC: 'mpeg4aac', + }, + getRecordingPermissionsAsync: jest.fn(async () => ({ + status: 'granted', + granted: true, + })), + requestRecordingPermissionsAsync: jest.fn(async () => ({ + status: 'granted', + granted: true, + })), + setAudioModeAsync: jest.fn(async () => null), + useAudioRecorder: jest.fn(() => ({ + prepareToRecordAsync: jest.fn(async () => null), + record: jest.fn(), + stop: jest.fn(async () => null), + uri: null, + })), + useAudioRecorderState: jest.fn(() => ({ + durationMillis: 0, + })), + }; +}); + const emptyMockPackages = [ 'react-native-share', 'react-native-file-viewer', 'react-native-image-picker', '@react-native-documents/picker', '@joplin/react-native-saf-x', - 'expo-av', - 'expo-av/build/Audio', 'expo-image-manipulator', ]; for (const packageName of emptyMockPackages) { diff --git a/packages/app-mobile/package.json b/packages/app-mobile/package.json index 9bf9e52e0f..366b21b012 100644 --- a/packages/app-mobile/package.json +++ b/packages/app-mobile/package.json @@ -49,7 +49,7 @@ "deprecated-react-native-prop-types": "5.0.0", "events": "3.3.0", "expo": "54.0.31", - "expo-av": "16.0.8", + "expo-audio": "1.1.1", "expo-camera": "17.0.10", "expo-image-manipulator": "14.0.8", "expo-local-authentication": "17.0.8", diff --git a/renovate.json5 b/renovate.json5 index d5febb42f0..4a0211a4ba 100644 --- a/renovate.json5 +++ b/renovate.json5 @@ -88,7 +88,7 @@ "browserify", "codemirror", "cspell", - "expo-av", // Must be updated with expo + "expo-audio", // Must be updated with expo "file-loader", "gradle", "html-webpack-plugin", diff --git a/yarn.lock b/yarn.lock index 2ed448f042..92135508d7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -10827,7 +10827,7 @@ __metadata: esbuild: "npm:0.27.2" events: "npm:3.3.0" expo: "npm:54.0.31" - expo-av: "npm:16.0.8" + expo-audio: "npm:1.1.1" expo-camera: "npm:17.0.10" expo-image-manipulator: "npm:14.0.8" expo-local-authentication: "npm:17.0.8" @@ -28797,18 +28797,15 @@ __metadata: languageName: node linkType: hard -"expo-av@npm:16.0.8": - version: 16.0.8 - resolution: "expo-av@npm:16.0.8" +"expo-audio@npm:1.1.1": + version: 1.1.1 + resolution: "expo-audio@npm:1.1.1" peerDependencies: expo: "*" + expo-asset: "*" react: "*" react-native: "*" - react-native-web: "*" - peerDependenciesMeta: - react-native-web: - optional: true - checksum: 10/c274ae6b98e30b673d2dd03119da703a379c01367466022fa1d2dc1b17ce9475711488132d2c7271a88ecda3800c5d03697371761fa8330e40ca0c02f9b951fa + checksum: 10/b2013e196eb56d6b31f62d82e3918d81626b8d48bd3b9c3b50d8d97b8329de8ec1cb700cb61cc03b637371213aa2de78fec10208e742e8e2a2019dbf25777814 languageName: node linkType: hard