From d3d3d636bca1ab49f75f4ff2ae7c5b0e95f2b8bf Mon Sep 17 00:00:00 2001 From: Michael Schwartz Date: Tue, 19 Nov 2024 04:45:51 -0500 Subject: [PATCH] Separate microphone and recognizer permissions (#55) * skip permission if not needed * separate permissions * dev * Update App.tsx * Update Podfile.lock * Update App.tsx * moving permission check * deprecated * Update example/App.tsx Co-authored-by: jamsch <12927717+jamsch@users.noreply.github.com> * return granted response on web * Update src/ExpoSpeechRecognitionModule.types.ts Co-authored-by: jamsch <12927717+jamsch@users.noreply.github.com> * More docs update and removing deprecated * Adding Android permission functions --------- Co-authored-by: jamsch <12927717+jamsch@users.noreply.github.com> --- .../ExpoSpeechRecognitionModule.kt | 42 +++++++++++ example/App.tsx | 69 +++++++++++++++++-- ios/ExpoSpeechRecognitionModule.swift | 56 ++++++++++++++- ios/ExpoSpeechRecognizer.swift | 8 --- ios/MicrophoneRequester.swift | 33 +++++++++ ios/SpeechRecognizerRequester.swift | 34 +++++++++ src/ExpoSpeechRecognitionModule.types.ts | 32 ++++++++- src/ExpoSpeechRecognitionModule.web.ts | 57 ++++++++++++++- 8 files changed, 310 insertions(+), 21 deletions(-) create mode 100644 ios/MicrophoneRequester.swift create mode 100644 ios/SpeechRecognizerRequester.swift diff --git a/android/src/main/java/expo/modules/speechrecognition/ExpoSpeechRecognitionModule.kt b/android/src/main/java/expo/modules/speechrecognition/ExpoSpeechRecognitionModule.kt index 570c8a3..06b6fd0 100644 --- a/android/src/main/java/expo/modules/speechrecognition/ExpoSpeechRecognitionModule.kt +++ b/android/src/main/java/expo/modules/speechrecognition/ExpoSpeechRecognitionModule.kt @@ -6,6 +6,7 @@ import android.content.ComponentName import android.content.Context import android.content.Intent import android.os.Build +import android.os.Bundle import android.os.Handler import android.provider.Settings import android.speech.ModelDownloadListener @@ -16,6 +17,7 @@ import android.speech.RecognizerIntent import android.speech.SpeechRecognizer import android.util.Log import androidx.annotation.RequiresApi +import expo.modules.interfaces.permissions.PermissionsResponse import expo.modules.interfaces.permissions.Permissions.askForPermissionsWithPermissionsManager import expo.modules.interfaces.permissions.Permissions.getPermissionsWithPermissionsManager import expo.modules.kotlin.Promise @@ -143,6 +145,46 @@ class ExpoSpeechRecognitionModule : Module() { ) } + AsyncFunction("requestMicrophonePermissionsAsync") { promise: Promise -> + askForPermissionsWithPermissionsManager( + appContext.permissions, + promise, + RECORD_AUDIO, + ) + } + + AsyncFunction("getMicrophonePermissionsAsync") { promise: Promise -> + getPermissionsWithPermissionsManager( + appContext.permissions, + promise, + RECORD_AUDIO, + ) + } + + AsyncFunction("getSpeechRecognizerPermissionsAsync") { promise: Promise -> + Log.w("ExpoSpeechRecognitionModule", "getSpeechRecognizerPermissionsAsync is not supported on Android. Returning a granted permission response.") + promise.resolve( + Bundle().apply { + putString(PermissionsResponse.EXPIRES_KEY, "never") + putString(PermissionsResponse.STATUS_KEY, "granted") + putBoolean(PermissionsResponse.CAN_ASK_AGAIN_KEY, false) + putBoolean(PermissionsResponse.GRANTED_KEY, true) + } + ) + } + + AsyncFunction("requestSpeechRecognizerPermissionsAsync") { promise: Promise -> + Log.w("ExpoSpeechRecognitionModule", "requestSpeechRecognizerPermissionsAsync is not supported on Android. Returning a granted permission response.") + promise.resolve( + Bundle().apply { + putString(PermissionsResponse.EXPIRES_KEY, "never") + putString(PermissionsResponse.STATUS_KEY, "granted") + putBoolean(PermissionsResponse.CAN_ASK_AGAIN_KEY, false) + putBoolean(PermissionsResponse.GRANTED_KEY, true) + } + ) + } + AsyncFunction("getStateAsync") { promise: Promise -> val state = when (expoSpeechService.recognitionState) { diff --git a/example/App.tsx b/example/App.tsx index 75e587a..78cdf7f 100644 --- a/example/App.tsx +++ b/example/App.tsx @@ -128,7 +128,7 @@ export default function App() { console.log("[event]: languagedetection", ev); }); - const startListening = () => { + const startListening = async () => { if (status !== "idle") { return; } @@ -136,16 +136,27 @@ export default function App() { setError(null); setStatus("starting"); - ExpoSpeechRecognitionModule.requestPermissionsAsync().then((result) => { - console.log("Permissions", result); - if (!result.granted) { - console.log("Permissions not granted", result); + const microphonePermissions = + await ExpoSpeechRecognitionModule.requestMicrophonePermissionsAsync(); + console.log("Microphone permissions", microphonePermissions); + if (!microphonePermissions.granted) { + setError({ error: "not-allowed", message: "Permissions not granted" }); + setStatus("idle"); + return; + } + + if (!settings.requiresOnDeviceRecognition && Platform.OS === "ios") { + const speechRecognizerPermissions = + await ExpoSpeechRecognitionModule.requestSpeechRecognizerPermissionsAsync(); + console.log("Speech recognizer permissions", speechRecognizerPermissions); + if (!speechRecognizerPermissions.granted) { setError({ error: "not-allowed", message: "Permissions not granted" }); setStatus("idle"); return; } - ExpoSpeechRecognitionModule.start(settings); - }); + } + + ExpoSpeechRecognitionModule.start(settings); }; return ( @@ -811,6 +822,50 @@ function OtherSettings(props: { ); }} /> + { + ExpoSpeechRecognitionModule.getMicrophonePermissionsAsync().then( + (result) => { + Alert.alert("Result", JSON.stringify(result)); + }, + ); + }} + /> + { + ExpoSpeechRecognitionModule.requestMicrophonePermissionsAsync().then( + (result) => { + Alert.alert("Result", JSON.stringify(result)); + }, + ); + }} + /> + { + ExpoSpeechRecognitionModule.getSpeechRecognizerPermissionsAsync().then( + (result) => { + Alert.alert("Result", JSON.stringify(result)); + }, + ); + }} + /> + { + ExpoSpeechRecognitionModule.requestSpeechRecognizerPermissionsAsync().then( + (result) => { + Alert.alert("Result", JSON.stringify(result)); + }, + ); + }} + /> String { + return "microphone" + } + + public func requestPermissions( + resolver resolve: @escaping EXPromiseResolveBlock, rejecter reject: EXPromiseRejectBlock + ) { + AVAudioSession.sharedInstance().requestRecordPermission { authorized in + resolve(self.getPermissions()) + } + } + + public func getPermissions() -> [AnyHashable: Any] { + var status: EXPermissionStatus + + let recordPermission = AVAudioSession.sharedInstance().recordPermission + + if recordPermission == .granted { + status = EXPermissionStatusGranted + } else if recordPermission == .denied { + status = EXPermissionStatusDenied + } else { + status = EXPermissionStatusUndetermined + } + + return [ + "status": status.rawValue + ] + } +} diff --git a/ios/SpeechRecognizerRequester.swift b/ios/SpeechRecognizerRequester.swift new file mode 100644 index 0000000..e18c802 --- /dev/null +++ b/ios/SpeechRecognizerRequester.swift @@ -0,0 +1,34 @@ +import ExpoModulesCore +import Speech + +public class SpeechRecognizerRequester: NSObject, EXPermissionsRequester { + static public func permissionType() -> String { + return "speechRecognizer" + } + + public func requestPermissions( + resolver resolve: @escaping EXPromiseResolveBlock, rejecter reject: EXPromiseRejectBlock + ) { + SFSpeechRecognizer.requestAuthorization { status in + resolve(self.getPermissions()) + } + } + + public func getPermissions() -> [AnyHashable: Any] { + var status: EXPermissionStatus + + let speechPermission = SFSpeechRecognizer.authorizationStatus() + + if speechPermission == .authorized { + status = EXPermissionStatusGranted + } else if speechPermission == .denied { + status = EXPermissionStatusDenied + } else { + status = EXPermissionStatusUndetermined + } + + return [ + "status": status.rawValue + ] + } +} diff --git a/src/ExpoSpeechRecognitionModule.types.ts b/src/ExpoSpeechRecognitionModule.types.ts index 21eeb3f..c5342e0 100644 --- a/src/ExpoSpeechRecognitionModule.types.ts +++ b/src/ExpoSpeechRecognitionModule.types.ts @@ -563,14 +563,42 @@ export declare class ExpoSpeechRecognitionModuleType extends NativeModule; /** - * Returns the current permission status for the microphone and speech recognition. + * Returns the current permission status for speech recognition and the microphone. + * + * You may also use `getMicrophonePermissionsAsync` and `getSpeechRecognizerPermissionsAsync` to get the permissions separately. */ getPermissionsAsync(): Promise; + /** + * Returns the current permission status for the microphone. + */ + getMicrophonePermissionsAsync(): Promise; + /** + * Presents a dialog to the user to request permissions for using the microphone. + * + * For iOS, once a user has granted (or denied) permissions by responding to the original permission request dialog, + * the only way that the permissions can be changed is by the user themselves using the device settings app. + */ + requestMicrophonePermissionsAsync(): Promise; + /** + * Returns the current permission status for speech recognition. + */ + getSpeechRecognizerPermissionsAsync(): Promise; + /** + * [iOS only] Presents a dialog to the user to request permissions for using the speech recognizer. + * This permission is required when `requiresOnDeviceRecognition` is disabled (i.e. network-based recognition) + * + * For iOS, once a user has granted (or denied) permissions by responding to the original permission request dialog, + * the only way that the permissions can be changed is by the user themselves using the device settings app. + */ + requestSpeechRecognizerPermissionsAsync(): Promise; /** * Returns an array of locales supported by the speech recognizer. * diff --git a/src/ExpoSpeechRecognitionModule.web.ts b/src/ExpoSpeechRecognitionModule.web.ts index 3e55f5d..f3186bc 100644 --- a/src/ExpoSpeechRecognitionModule.web.ts +++ b/src/ExpoSpeechRecognitionModule.web.ts @@ -152,6 +152,9 @@ class ExpoSpeechRecognitionModuleWeb extends NativeModule