Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Separate microphone and recognizer permissions #55

Merged
merged 14 commits into from
Nov 19, 2024
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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) {
Expand Down
69 changes: 62 additions & 7 deletions example/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -128,24 +128,35 @@ export default function App() {
console.log("[event]: languagedetection", ev);
});

const startListening = () => {
const startListening = async () => {
if (status !== "idle") {
return;
}
setTranscription(null);
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 (
Expand Down Expand Up @@ -811,6 +822,50 @@ function OtherSettings(props: {
);
}}
/>
<BigButton
title="Get microphone permissions"
color="#7C90DB"
onPress={() => {
ExpoSpeechRecognitionModule.getMicrophonePermissionsAsync().then(
(result) => {
Alert.alert("Result", JSON.stringify(result));
},
);
}}
/>
<BigButton
title="Request microphone permissions"
color="#7C90DB"
onPress={() => {
ExpoSpeechRecognitionModule.requestMicrophonePermissionsAsync().then(
(result) => {
Alert.alert("Result", JSON.stringify(result));
},
);
}}
/>
<BigButton
title="Get speech recognizer permissions"
color="#7C90DB"
onPress={() => {
ExpoSpeechRecognitionModule.getSpeechRecognizerPermissionsAsync().then(
(result) => {
Alert.alert("Result", JSON.stringify(result));
},
);
}}
/>
<BigButton
title="Request speech recognizer permissions"
color="#7C90DB"
onPress={() => {
ExpoSpeechRecognitionModule.requestSpeechRecognizerPermissionsAsync().then(
(result) => {
Alert.alert("Result", JSON.stringify(result));
},
);
}}
/>
<BigButton
title="Get speech recognizer state"
color="#7C90DB"
Expand Down
56 changes: 55 additions & 1 deletion ios/ExpoSpeechRecognitionModule.swift
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,11 @@ public class ExpoSpeechRecognitionModule: Module {
guard let permissionsManager = appContext?.permissions else {
return
}
permissionsManager.register([EXSpeechRecognitionPermissionRequester()])
permissionsManager.register([
EXSpeechRecognitionPermissionRequester(),
MicrophoneRequester(),
SpeechRecognizerRequester()
])
}

AsyncFunction("requestPermissionsAsync") { (promise: Promise) in
Expand All @@ -129,6 +133,38 @@ public class ExpoSpeechRecognitionModule: Module {
)
}

AsyncFunction("getMicrophonePermissionsAsync") { (promise: Promise) in
appContext?.permissions?.getPermissionUsingRequesterClass(
MicrophoneRequester.self,
resolve: promise.resolver,
reject: promise.legacyRejecter
)
}

AsyncFunction("requestMicrophonePermissionsAsync") { (promise: Promise) in
appContext?.permissions?.askForPermission(
usingRequesterClass: MicrophoneRequester.self,
resolve: promise.resolver,
reject: promise.legacyRejecter
)
}

AsyncFunction("getSpeechRecognizerPermissionsAsync") { (promise: Promise) in
appContext?.permissions?.getPermissionUsingRequesterClass(
SpeechRecognizerRequester.self,
resolve: promise.resolver,
reject: promise.legacyRejecter
)
}

AsyncFunction("requestSpeechRecognizerPermissionsAsync") { (promise: Promise) in
appContext?.permissions?.askForPermission(
usingRequesterClass: SpeechRecognizerRequester.self,
resolve: promise.resolver,
reject: promise.legacyRejecter
)
}

AsyncFunction("getStateAsync") { (promise: Promise) in
Task {
let state = await speechRecognizer?.getState()
Expand Down Expand Up @@ -163,6 +199,24 @@ public class ExpoSpeechRecognitionModule: Module {
locale: locale
)
}

if !options.requiresOnDeviceRecognition {
guard await SFSpeechRecognizer.hasAuthorizationToRecognize() else {
sendErrorAndStop(
error: "not-allowed",
message: RecognizerError.notAuthorizedToRecognize.message
)
return
}
}

guard await AVAudioSession.sharedInstance().hasPermissionToRecord() else {
sendErrorAndStop(
error: "not-allowed",
message: RecognizerError.notPermittedToRecord.message
)
return
}

// Start recognition!
await speechRecognizer?.start(
Expand Down
8 changes: 0 additions & 8 deletions ios/ExpoSpeechRecognizer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -56,14 +56,6 @@ actor ExpoSpeechRecognizer: ObservableObject {
guard recognizer != nil else {
throw RecognizerError.nilRecognizer
}

guard await SFSpeechRecognizer.hasAuthorizationToRecognize() else {
throw RecognizerError.notAuthorizedToRecognize
}

guard await AVAudioSession.sharedInstance().hasPermissionToRecord() else {
throw RecognizerError.notPermittedToRecord
}
}

/// Returns a suitable audio format to use for the speech recognition task and audio file recording.
Expand Down
33 changes: 33 additions & 0 deletions ios/MicrophoneRequester.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import ExpoModulesCore

public class MicrophoneRequester: NSObject, EXPermissionsRequester {
static public func permissionType() -> 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
]
}
}
34 changes: 34 additions & 0 deletions ios/SpeechRecognizerRequester.swift
Original file line number Diff line number Diff line change
@@ -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
]
}
}
32 changes: 30 additions & 2 deletions src/ExpoSpeechRecognitionModule.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -563,14 +563,42 @@ export declare class ExpoSpeechRecognitionModuleType extends NativeModule<ExpoSp
/**
* Presents a dialog to the user to request permissions for using speech recognition and the microphone.
*
* For iOS, once a user has granted (or denied) location permissions by responding to the original permission request dialog,
* For Android, this will request RECORD_AUDIO permission.
*
* For iOS, this will request microphone and speech recognition permissions.
* 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.
*/
requestPermissionsAsync(): Promise<PermissionResponse>;
/**
* 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<PermissionResponse>;
/**
* Returns the current permission status for the microphone.
*/
getMicrophonePermissionsAsync(): Promise<PermissionResponse>;
/**
* 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<PermissionResponse>;
/**
* Returns the current permission status for speech recognition.
*/
msschwartz marked this conversation as resolved.
Show resolved Hide resolved
getSpeechRecognizerPermissionsAsync(): Promise<PermissionResponse>;
/**
* [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<PermissionResponse>;
/**
* Returns an array of locales supported by the speech recognizer.
*
Expand Down
Loading