From d7f42e085f3b7d0ae134a726cc379fffe5ce123a Mon Sep 17 00:00:00 2001 From: Nicholas Ambur Date: Wed, 23 Mar 2022 13:03:48 -0700 Subject: [PATCH] update HotwordDetector exception throws A few updates to throwing exceptions in AOHD: - overrideAvailability throws exception when overriding to ENROLLED state without an enrolled model - APIs no longer throw runtime exceptions when it could be due to system server modifying the detector state without the caller's knowledge. In cases where a RuntimeException was thrown, a checked exception is thrown instead. Bug: 226355112 Test: atest AlwaysOnHotwordDetectorTest Test: atest AlwaysOnHotwordDetectorSystemApiTest Change-Id: Id6884a325a1cd79f1cc880f370a0a97e4ab0ec04 --- core/api/system-current.txt | 37 ++- .../voice/AbstractHotwordDetector.java | 30 +- .../voice/AlwaysOnHotwordDetector.java | 297 +++++++++++++----- .../service/voice/HotwordDetector.java | 72 ++++- .../voice/SoftwareHotwordDetector.java | 4 +- .../MainInteractionService.java | 23 +- 6 files changed, 343 insertions(+), 120 deletions(-) diff --git a/core/api/system-current.txt b/core/api/system-current.txt index 66bd6b3780855..783230ce4e5dd 100644 --- a/core/api/system-current.txt +++ b/core/api/system-current.txt @@ -11903,20 +11903,20 @@ package android.service.trust { package android.service.voice { public class AlwaysOnHotwordDetector implements android.service.voice.HotwordDetector { - method @Nullable public android.content.Intent createEnrollIntent(); - method @Nullable public android.content.Intent createReEnrollIntent(); - method @Nullable public android.content.Intent createUnEnrollIntent(); - method @RequiresPermission(allOf={android.Manifest.permission.RECORD_AUDIO, android.Manifest.permission.CAPTURE_AUDIO_HOTWORD}) public int getParameter(int); + method @Nullable public android.content.Intent createEnrollIntent() throws android.service.voice.HotwordDetector.IllegalDetectorStateException; + method @Nullable public android.content.Intent createReEnrollIntent() throws android.service.voice.HotwordDetector.IllegalDetectorStateException; + method @Nullable public android.content.Intent createUnEnrollIntent() throws android.service.voice.HotwordDetector.IllegalDetectorStateException; + method @RequiresPermission(allOf={android.Manifest.permission.RECORD_AUDIO, android.Manifest.permission.CAPTURE_AUDIO_HOTWORD}) public int getParameter(int) throws android.service.voice.HotwordDetector.IllegalDetectorStateException; method public int getSupportedAudioCapabilities(); - method public int getSupportedRecognitionModes(); - method @Nullable @RequiresPermission(allOf={android.Manifest.permission.RECORD_AUDIO, android.Manifest.permission.CAPTURE_AUDIO_HOTWORD}) public android.service.voice.AlwaysOnHotwordDetector.ModelParamRange queryParameter(int); - method @RequiresPermission(allOf={android.Manifest.permission.RECORD_AUDIO, android.Manifest.permission.CAPTURE_AUDIO_HOTWORD}) public int setParameter(int, int); - method @RequiresPermission(allOf={android.Manifest.permission.RECORD_AUDIO, android.Manifest.permission.CAPTURE_AUDIO_HOTWORD}) public boolean startRecognition(int, @NonNull byte[]); - method @RequiresPermission(allOf={android.Manifest.permission.RECORD_AUDIO, android.Manifest.permission.CAPTURE_AUDIO_HOTWORD}) public boolean startRecognition(int); - method @RequiresPermission(allOf={android.Manifest.permission.RECORD_AUDIO, android.Manifest.permission.CAPTURE_AUDIO_HOTWORD}) public boolean startRecognition(); - method public boolean startRecognition(@NonNull android.os.ParcelFileDescriptor, @NonNull android.media.AudioFormat, @Nullable android.os.PersistableBundle); - method @RequiresPermission(allOf={android.Manifest.permission.RECORD_AUDIO, android.Manifest.permission.CAPTURE_AUDIO_HOTWORD}) public boolean stopRecognition(); - method public final void updateState(@Nullable android.os.PersistableBundle, @Nullable android.os.SharedMemory); + method public int getSupportedRecognitionModes() throws android.service.voice.HotwordDetector.IllegalDetectorStateException; + method @Nullable @RequiresPermission(allOf={android.Manifest.permission.RECORD_AUDIO, android.Manifest.permission.CAPTURE_AUDIO_HOTWORD}) public android.service.voice.AlwaysOnHotwordDetector.ModelParamRange queryParameter(int) throws android.service.voice.HotwordDetector.IllegalDetectorStateException; + method @RequiresPermission(allOf={android.Manifest.permission.RECORD_AUDIO, android.Manifest.permission.CAPTURE_AUDIO_HOTWORD}) public int setParameter(int, int) throws android.service.voice.HotwordDetector.IllegalDetectorStateException; + method @RequiresPermission(allOf={android.Manifest.permission.RECORD_AUDIO, android.Manifest.permission.CAPTURE_AUDIO_HOTWORD}) public boolean startRecognition(int, @NonNull byte[]) throws android.service.voice.HotwordDetector.IllegalDetectorStateException; + method @RequiresPermission(allOf={android.Manifest.permission.RECORD_AUDIO, android.Manifest.permission.CAPTURE_AUDIO_HOTWORD}) public boolean startRecognition(int) throws android.service.voice.HotwordDetector.IllegalDetectorStateException; + method @RequiresPermission(allOf={android.Manifest.permission.RECORD_AUDIO, android.Manifest.permission.CAPTURE_AUDIO_HOTWORD}) public boolean startRecognition() throws android.service.voice.HotwordDetector.IllegalDetectorStateException; + method public boolean startRecognition(@NonNull android.os.ParcelFileDescriptor, @NonNull android.media.AudioFormat, @Nullable android.os.PersistableBundle) throws android.service.voice.HotwordDetector.IllegalDetectorStateException; + method @RequiresPermission(allOf={android.Manifest.permission.RECORD_AUDIO, android.Manifest.permission.CAPTURE_AUDIO_HOTWORD}) public boolean stopRecognition() throws android.service.voice.HotwordDetector.IllegalDetectorStateException; + method public final void updateState(@Nullable android.os.PersistableBundle, @Nullable android.os.SharedMemory) throws android.service.voice.HotwordDetector.IllegalDetectorStateException; field public static final int AUDIO_CAPABILITY_ECHO_CANCELLATION = 1; // 0x1 field public static final int AUDIO_CAPABILITY_NOISE_SUPPRESSION = 2; // 0x2 field public static final int MODEL_PARAM_THRESHOLD_FACTOR = 0; // 0x0 @@ -12023,10 +12023,10 @@ package android.service.voice { public interface HotwordDetector { method public default void destroy(); - method @RequiresPermission(allOf={android.Manifest.permission.RECORD_AUDIO, android.Manifest.permission.CAPTURE_AUDIO_HOTWORD}) public boolean startRecognition(); - method public boolean startRecognition(@NonNull android.os.ParcelFileDescriptor, @NonNull android.media.AudioFormat, @Nullable android.os.PersistableBundle); - method public boolean stopRecognition(); - method public void updateState(@Nullable android.os.PersistableBundle, @Nullable android.os.SharedMemory); + method @RequiresPermission(allOf={android.Manifest.permission.RECORD_AUDIO, android.Manifest.permission.CAPTURE_AUDIO_HOTWORD}) public boolean startRecognition() throws android.service.voice.HotwordDetector.IllegalDetectorStateException; + method public boolean startRecognition(@NonNull android.os.ParcelFileDescriptor, @NonNull android.media.AudioFormat, @Nullable android.os.PersistableBundle) throws android.service.voice.HotwordDetector.IllegalDetectorStateException; + method public boolean stopRecognition() throws android.service.voice.HotwordDetector.IllegalDetectorStateException; + method public void updateState(@Nullable android.os.PersistableBundle, @Nullable android.os.SharedMemory) throws android.service.voice.HotwordDetector.IllegalDetectorStateException; } public static interface HotwordDetector.Callback { @@ -12039,6 +12039,9 @@ package android.service.voice { method public void onRejected(@NonNull android.service.voice.HotwordRejectedResult); } + public static class HotwordDetector.IllegalDetectorStateException extends android.util.AndroidException { + } + public final class HotwordRejectedResult implements android.os.Parcelable { method public int describeContents(); method public int getConfidenceLevel(); diff --git a/core/java/android/service/voice/AbstractHotwordDetector.java b/core/java/android/service/voice/AbstractHotwordDetector.java index 305995a3d2b77..5b3b78b65786e 100644 --- a/core/java/android/service/voice/AbstractHotwordDetector.java +++ b/core/java/android/service/voice/AbstractHotwordDetector.java @@ -22,6 +22,7 @@ import android.annotation.CallSuper; import android.annotation.NonNull; import android.annotation.Nullable; import android.app.ActivityThread; +import android.app.compat.CompatChanges; import android.media.AudioFormat; import android.media.permission.Identity; import android.os.Handler; @@ -80,7 +81,7 @@ abstract class AbstractHotwordDetector implements HotwordDetector { public boolean startRecognition( @NonNull ParcelFileDescriptor audioStream, @NonNull AudioFormat audioFormat, - @Nullable PersistableBundle options) { + @Nullable PersistableBundle options) throws IllegalDetectorStateException { if (DEBUG) { Slog.i(TAG, "#recognizeHotword"); } @@ -105,19 +106,22 @@ abstract class AbstractHotwordDetector implements HotwordDetector { * Set configuration and pass read-only data to hotword detection service. * * @param options Application configuration data to provide to the - * {@link HotwordDetectionService}. PersistableBundle does not allow any remotable objects or - * other contents that can be used to communicate with other processes. + * {@link HotwordDetectionService}. PersistableBundle does not allow any remotable + * objects or other contents that can be used to communicate with other processes. * @param sharedMemory The unrestricted data blob to provide to the - * {@link HotwordDetectionService}. Use this to provide the hotword models data or other - * such data to the trusted process. - * - * @throws IllegalStateException if this AlwaysOnHotwordDetector wasn't specified to use a - * {@link HotwordDetectionService} when it was created. In addition, if this - * AlwaysOnHotwordDetector is in an invalid or error state. + * {@link HotwordDetectionService}. Use this to provide the hotword models data or other + * such data to the trusted process. + * @throws IllegalDetectorStateException Thrown when a caller has a target SDK of + * Android Tiramisu or above and attempts to start a recognition when the detector is + * not able based on the state. Because the caller receives updates via an asynchronous + * callback and the state of the detector can change without caller's knowledge, a + * checked exception is thrown. + * @throws IllegalStateException if this HotwordDetector wasn't specified to use a + * {@link HotwordDetectionService} when it was created. */ @Override public void updateState(@Nullable PersistableBundle options, - @Nullable SharedMemory sharedMemory) { + @Nullable SharedMemory sharedMemory) throws IllegalDetectorStateException { if (DEBUG) { Slog.d(TAG, "updateState()"); } @@ -163,9 +167,13 @@ abstract class AbstractHotwordDetector implements HotwordDetector { } } - protected void throwIfDetectorIsNoLongerActive() { + protected void throwIfDetectorIsNoLongerActive() throws IllegalDetectorStateException { if (!mIsDetectorActive.get()) { Slog.e(TAG, "attempting to use a destroyed detector which is no longer active"); + if (CompatChanges.isChangeEnabled(HOTWORD_DETECTOR_THROW_CHECKED_EXCEPTION)) { + throw new IllegalDetectorStateException( + "attempting to use a destroyed detector which is no longer active"); + } throw new IllegalStateException( "attempting to use a destroyed detector which is no longer active"); } diff --git a/core/java/android/service/voice/AlwaysOnHotwordDetector.java b/core/java/android/service/voice/AlwaysOnHotwordDetector.java index 8fb5b1945f6ec..917d70032d714 100644 --- a/core/java/android/service/voice/AlwaysOnHotwordDetector.java +++ b/core/java/android/service/voice/AlwaysOnHotwordDetector.java @@ -27,6 +27,7 @@ import android.annotation.SuppressLint; import android.annotation.SystemApi; import android.annotation.TestApi; import android.app.ActivityThread; +import android.app.compat.CompatChanges; import android.compat.annotation.UnsupportedAppUsage; import android.content.Context; import android.content.Intent; @@ -834,19 +835,28 @@ public class AlwaysOnHotwordDetector extends AbstractHotwordDetector { /** * {@inheritDoc} * - * @throws IllegalStateException if this AlwaysOnHotwordDetector wasn't specified to use a - * {@link HotwordDetectionService} when it was created. In addition, if this - * AlwaysOnHotwordDetector is in an invalid or error state. + * @throws IllegalDetectorStateException Thrown when a caller has a target SDK of API level 33 + * or above and this AlwaysOnHotwordDetector wasn't specified to use a + * {@link HotwordDetectionService} when it was created. In addition, the exception can + * be thrown if this AlwaysOnHotwordDetector is in an invalid or error state. + * @throws IllegalStateException Thrown when a caller has a target SDK below API level 33 if + * this AlwaysOnHotwordDetector wasn't specified to use a + * {@link HotwordDetectionService} when it was created. In addition, the exception can + * be thrown if this AlwaysOnHotwordDetector is in an invalid or error state. */ @Override public final void updateState(@Nullable PersistableBundle options, - @Nullable SharedMemory sharedMemory) { + @Nullable SharedMemory sharedMemory) throws IllegalDetectorStateException { synchronized (mLock) { if (!mSupportHotwordDetectionService) { throw new IllegalStateException( "updateState called, but it doesn't support hotword detection service"); } if (mAvailability == STATE_INVALID || mAvailability == STATE_ERROR) { + if (CompatChanges.isChangeEnabled(HOTWORD_DETECTOR_THROW_CHECKED_EXCEPTION)) { + throw new IllegalDetectorStateException( + "updateState called on an invalid detector or error state"); + } throw new IllegalStateException( "updateState called on an invalid detector or error state"); } @@ -867,16 +877,17 @@ public class AlwaysOnHotwordDetector extends AbstractHotwordDetector { @TestApi public void overrideAvailability(int availability) { synchronized (mLock) { - mAvailability = availability; - mIsAvailabilityOverriddenByTestApi = true; // ENROLLED state requires there to be metadata about the sound model so a fake one // is created. - if (mKeyphraseMetadata == null && mAvailability == STATE_KEYPHRASE_ENROLLED) { + if (mKeyphraseMetadata == null && availability == STATE_KEYPHRASE_ENROLLED) { Set fakeSupportedLocales = new HashSet<>(); fakeSupportedLocales.add(mLocale); mKeyphraseMetadata = new KeyphraseMetadata(1, mText, fakeSupportedLocales, AlwaysOnHotwordDetector.RECOGNITION_MODE_VOICE_TRIGGER); } + + mAvailability = availability; + mIsAvailabilityOverriddenByTestApi = true; notifyStateChangedLocked(); } } @@ -932,14 +943,23 @@ public class AlwaysOnHotwordDetector extends AbstractHotwordDetector { * @see #RECOGNITION_MODE_USER_IDENTIFICATION * @see #RECOGNITION_MODE_VOICE_TRIGGER * - * @throws UnsupportedOperationException if the keyphrase itself isn't supported. - * Callers should only call this method after a supported state callback on - * {@link Callback#onAvailabilityChanged(int)} to avoid this exception. - * @throws IllegalStateException if the detector is in an invalid or error state. - * This may happen if another detector has been instantiated or the - * {@link VoiceInteractionService} hosting this detector has been shut down. + * @throws IllegalDetectorStateException Thrown when a caller has a target SDK of API level 33 + * or above. Because the caller receives availability updates via an asynchronous + * callback, it may be due to the availability changing while this call is performed. + * - Throws if the detector is in an invalid or error state. + * This may happen if another detector has been instantiated or the + * {@link VoiceInteractionService} hosting this detector has been shut down. + * @throws UnsupportedOperationException Thrown when a caller has a target SDK below API level + * 33 Android if the recognition isn't supported. Callers should only call this method + * after a supported state callback on {@link Callback#onAvailabilityChanged(int)} to + * avoid this exception. + * @throws IllegalStateException Thrown when a caller has a target SDK below Android API level + * 33 if the detector is in an invalid or error state. This may happen if another + * detector has been instantiated or the {@link VoiceInteractionService} hosting this + * detector has been shut down. */ - public @RecognitionModes int getSupportedRecognitionModes() { + public @RecognitionModes + int getSupportedRecognitionModes() throws IllegalDetectorStateException { if (DBG) Slog.d(TAG, "getSupportedRecognitionModes()"); synchronized (mLock) { return getSupportedRecognitionModesLocked(); @@ -947,14 +967,22 @@ public class AlwaysOnHotwordDetector extends AbstractHotwordDetector { } @GuardedBy("mLock") - private int getSupportedRecognitionModesLocked() { + private int getSupportedRecognitionModesLocked() throws IllegalDetectorStateException { if (mAvailability == STATE_INVALID || mAvailability == STATE_ERROR) { + if (CompatChanges.isChangeEnabled(HOTWORD_DETECTOR_THROW_CHECKED_EXCEPTION)) { + throw new IllegalDetectorStateException("getSupportedRecognitionModes called on an" + + " invalid detector or error state"); + } throw new IllegalStateException( "getSupportedRecognitionModes called on an invalid detector or error state"); } // This method only makes sense if we can actually support a recognition. if (mAvailability != STATE_KEYPHRASE_ENROLLED || mKeyphraseMetadata == null) { + if (CompatChanges.isChangeEnabled(HOTWORD_DETECTOR_THROW_CHECKED_EXCEPTION)) { + throw new IllegalDetectorStateException("Getting supported recognition modes for" + + " the keyphrase is not supported"); + } throw new UnsupportedOperationException( "Getting supported recognition modes for the keyphrase is not supported"); } @@ -1009,15 +1037,30 @@ public class AlwaysOnHotwordDetector extends AbstractHotwordDetector { * startRecognition request. This data is intended to provide additional parameters * when starting the opaque sound model. * @return Indicates whether the call succeeded or not. - * @throws UnsupportedOperationException if the recognition isn't supported. - * Callers should only call this method after a supported state callback on - * {@link Callback#onAvailabilityChanged(int)} to avoid this exception. - * @throws IllegalStateException if the detector is in an invalid or error state. - * This may happen if another detector has been instantiated or the - * {@link VoiceInteractionService} hosting this detector has been shut down. + * @throws IllegalDetectorStateException Thrown when a caller has a target SDK of API level 33 + * or above and attempts to start a recognition when the detector is not able based on + * the availability state. This can be thrown even if the state has been checked before + * calling this method because the caller receives availability updates via an + * asynchronous callback, it may be due to the availability changing while this call is + * performed. + * - Throws if the recognition isn't supported. + * Callers should only call this method after a supported state callback on + * {@link Callback#onAvailabilityChanged(int)} to avoid this exception. + * - Also throws if the detector is in an invalid or error state. + * This may happen if another detector has been instantiated or the + * {@link VoiceInteractionService} hosting this detector has been shut down. + * @throws UnsupportedOperationException Thrown when a caller has a target SDK below API level + * 33 Android if the recognition isn't supported. Callers should only call this method + * after a supported state callback on {@link Callback#onAvailabilityChanged(int)} to + * avoid this exception. + * @throws IllegalStateException Thrown when a caller has a target SDK below Android API level + * 33 if the detector is in an invalid or error state. This may happen if another + * detector has been instantiated or the {@link VoiceInteractionService} hosting this + * detector has been shut down. */ @RequiresPermission(allOf = {RECORD_AUDIO, CAPTURE_AUDIO_HOTWORD}) - public boolean startRecognition(@RecognitionFlags int recognitionFlags, @NonNull byte[] data) { + public boolean startRecognition(@RecognitionFlags int recognitionFlags, @NonNull byte[] data) + throws IllegalDetectorStateException { synchronized (mLock) { return startRecognitionLocked(recognitionFlags, data) == STATUS_OK; @@ -1034,15 +1077,30 @@ public class AlwaysOnHotwordDetector extends AbstractHotwordDetector { * * @param recognitionFlags The flags to control the recognition properties. * @return Indicates whether the call succeeded or not. - * @throws UnsupportedOperationException if the recognition isn't supported. - * Callers should only call this method after a supported state callback on - * {@link Callback#onAvailabilityChanged(int)} to avoid this exception. - * @throws IllegalStateException if the detector is in an invalid or error state. - * This may happen if another detector has been instantiated or the - * {@link VoiceInteractionService} hosting this detector has been shut down. + * @throws IllegalDetectorStateException Thrown when a caller has a target SDK of API level 33 + * or above and attempts to start a recognition when the detector is not able based on + * the availability state. This can be thrown even if the state has been checked before + * calling this method because the caller receives availability updates via an + * asynchronous callback, it may be due to the availability changing while this call is + * performed. + * - Throws if the recognition isn't supported. + * Callers should only call this method after a supported state callback on + * {@link Callback#onAvailabilityChanged(int)} to avoid this exception. + * - Also throws if the detector is in an invalid or error state. + * This may happen if another detector has been instantiated or the + * {@link VoiceInteractionService} hosting this detector has been shut down. + * @throws UnsupportedOperationException Thrown when a caller has a target SDK below API level + * 33 if the recognition isn't supported. Callers should only call this method after a + * supported state callback on {@link Callback#onAvailabilityChanged(int)} to avoid this + * exception. + * @throws IllegalStateException Thrown when a caller has a target SDK below API level 33 if the + * detector is in an invalid or error state. This may happen if another detector has + * been instantiated or the {@link VoiceInteractionService} hosting this detector has + * been shut down. */ @RequiresPermission(allOf = {RECORD_AUDIO, CAPTURE_AUDIO_HOTWORD}) - public boolean startRecognition(@RecognitionFlags int recognitionFlags) { + public boolean startRecognition(@RecognitionFlags int recognitionFlags) + throws IllegalDetectorStateException { if (DBG) Slog.d(TAG, "startRecognition(" + recognitionFlags + ")"); synchronized (mLock) { return startRecognitionLocked(recognitionFlags, null /* data */) == STATUS_OK; @@ -1056,7 +1114,8 @@ public class AlwaysOnHotwordDetector extends AbstractHotwordDetector { */ @RequiresPermission(allOf = {RECORD_AUDIO, CAPTURE_AUDIO_HOTWORD}) @Override - public boolean startRecognition() { + public boolean startRecognition() + throws IllegalDetectorStateException { return startRecognition(0); } @@ -1066,28 +1125,44 @@ public class AlwaysOnHotwordDetector extends AbstractHotwordDetector { * Settings.Secure.VOICE_INTERACTION_SERVICE. * * @return Indicates whether the call succeeded or not. - * @throws UnsupportedOperationException if the recognition isn't supported. - * Callers should only call this method after a supported state callback on - * {@link Callback#onAvailabilityChanged(int)} to avoid this exception. - * @throws IllegalStateException if the detector is in an invalid or error state. - * This may happen if another detector has been instantiated or the - * {@link VoiceInteractionService} hosting this detector has been shut down. + * @throws IllegalDetectorStateException Thrown when a caller has a target SDK of + * API level 33 or above and attempts to stop a recognition when the detector is + * not able based on the state. This can be thrown even if the state has been checked + * before calling this method because the caller receives availability updates via an + * asynchronous callback, it may be due to the availability changing while this call is + * performed. + * @throws UnsupportedOperationException Thrown when a caller has a target SDK below API level + * 33 if the recognition isn't supported. Callers should only call this method after a + * supported state callback on {@link Callback#onAvailabilityChanged(int)} to avoid this + * exception. + * @throws IllegalStateException Thrown when a caller has a target SDK below API level 33 if the + * detector is in an invalid or error state. This may happen if another detector has + * been instantiated or the {@link VoiceInteractionService} hosting this detector has + * been shut down. */ // TODO: Remove this RequiresPermission since it isn't actually enforced. Also fix the javadoc // about permissions enforcement (when it throws vs when it just returns false) for other // methods in this class. @RequiresPermission(allOf = {RECORD_AUDIO, CAPTURE_AUDIO_HOTWORD}) @Override - public boolean stopRecognition() { + public boolean stopRecognition() throws IllegalDetectorStateException { if (DBG) Slog.d(TAG, "stopRecognition()"); synchronized (mLock) { if (mAvailability == STATE_INVALID || mAvailability == STATE_ERROR) { + if (CompatChanges.isChangeEnabled(HOTWORD_DETECTOR_THROW_CHECKED_EXCEPTION)) { + throw new IllegalDetectorStateException( + "stopRecognition called on an invalid detector or error state"); + } throw new IllegalStateException( "stopRecognition called on an invalid detector or error state"); } // Check if we can start/stop a recognition. if (mAvailability != STATE_KEYPHRASE_ENROLLED) { + if (CompatChanges.isChangeEnabled(HOTWORD_DETECTOR_THROW_CHECKED_EXCEPTION)) { + throw new IllegalDetectorStateException( + "Recognition for the given keyphrase is not supported"); + } throw new UnsupportedOperationException( "Recognition for the given keyphrase is not supported"); } @@ -1112,18 +1187,28 @@ public class AlwaysOnHotwordDetector extends AbstractHotwordDetector { * - {@link SoundTrigger#STATUS_BAD_VALUE} invalid input parameter * - {@link SoundTrigger#STATUS_INVALID_OPERATION} if the call is out of sequence or * if API is not supported by HAL - * @throws IllegalStateException if the detector is in an invalid or error state. - * This may happen if another detector has been instantiated or the - * {@link VoiceInteractionService} hosting this detector has been shut down. + * @throws IllegalDetectorStateException Thrown when a caller has a target SDK of API level 33 + * if the detector is in an invalid or error state. This may happen if another detector + * has been instantiated or the {@link VoiceInteractionService} hosting this detector + * has been shut down. + * @throws IllegalStateException Thrown when a caller has a target SDK below API level 33 if the + * detector is in an invalid or error state. This may happen if another detector has + * been instantiated or the {@link VoiceInteractionService} hosting this detector has + * been shut down. */ @RequiresPermission(allOf = {RECORD_AUDIO, CAPTURE_AUDIO_HOTWORD}) - public int setParameter(@ModelParams int modelParam, int value) { + public int setParameter(@ModelParams int modelParam, int value) + throws IllegalDetectorStateException { if (DBG) { Slog.d(TAG, "setParameter(" + modelParam + ", " + value + ")"); } synchronized (mLock) { if (mAvailability == STATE_INVALID || mAvailability == STATE_ERROR) { + if (CompatChanges.isChangeEnabled(HOTWORD_DETECTOR_THROW_CHECKED_EXCEPTION)) { + throw new IllegalDetectorStateException( + "setParameter called on an invalid detector or error state"); + } throw new IllegalStateException( "setParameter called on an invalid detector or error state"); } @@ -1144,18 +1229,27 @@ public class AlwaysOnHotwordDetector extends AbstractHotwordDetector { * * @param modelParam {@link ModelParams} * @return value of parameter - * @throws IllegalStateException if the detector is in an invalid or error state. - * This may happen if another detector has been instantiated or the - * {@link VoiceInteractionService} hosting this detector has been shut down. + * @throws IllegalDetectorStateException Thrown when a caller has a target SDK of API level 33 + * if the detector is in an invalid or error state. This may happen if another detector + * has been instantiated or the {@link VoiceInteractionService} hosting this detector + * has been shut down. + * @throws IllegalStateException Thrown when a caller has a target SDK below API level 33 if + * the detector is in an invalid or error state. This may happen if another detector has + * been instantiated or the {@link VoiceInteractionService} hosting this detector has + * been shut down. */ @RequiresPermission(allOf = {RECORD_AUDIO, CAPTURE_AUDIO_HOTWORD}) - public int getParameter(@ModelParams int modelParam) { + public int getParameter(@ModelParams int modelParam) throws IllegalDetectorStateException { if (DBG) { Slog.d(TAG, "getParameter(" + modelParam + ")"); } synchronized (mLock) { if (mAvailability == STATE_INVALID || mAvailability == STATE_ERROR) { + if (CompatChanges.isChangeEnabled(HOTWORD_DETECTOR_THROW_CHECKED_EXCEPTION)) { + throw new IllegalDetectorStateException( + "getParameter called on an invalid detector or error state"); + } throw new IllegalStateException( "getParameter called on an invalid detector or error state"); } @@ -1173,19 +1267,29 @@ public class AlwaysOnHotwordDetector extends AbstractHotwordDetector { * * @param modelParam {@link ModelParams} * @return supported range of parameter, null if not supported - * @throws IllegalStateException if the detector is in an invalid or error state. - * This may happen if another detector has been instantiated or the - * {@link VoiceInteractionService} hosting this detector has been shut down. + * @throws IllegalDetectorStateException Thrown when a caller has a target SDK of API level 33 + * if the detector is in an invalid or error state. This may happen if another detector + * has been instantiated or the {@link VoiceInteractionService} hosting this detector + * has been shut down. + * @throws IllegalStateException Thrown when a caller has a target SDK below API level 33 if + * the detector is in an invalid or error state. This may happen if another detector has + * been instantiated or the {@link VoiceInteractionService} hosting this detector has + * been shut down. */ @RequiresPermission(allOf = {RECORD_AUDIO, CAPTURE_AUDIO_HOTWORD}) @Nullable - public ModelParamRange queryParameter(@ModelParams int modelParam) { + public ModelParamRange queryParameter(@ModelParams int modelParam) + throws IllegalDetectorStateException { if (DBG) { Slog.d(TAG, "queryParameter(" + modelParam + ")"); } synchronized (mLock) { if (mAvailability == STATE_INVALID || mAvailability == STATE_ERROR) { + if (CompatChanges.isChangeEnabled(HOTWORD_DETECTOR_THROW_CHECKED_EXCEPTION)) { + throw new IllegalDetectorStateException( + "queryParameter called on an invalid detector or error state"); + } throw new IllegalStateException( "queryParameter called on an invalid detector or error state"); } @@ -1202,15 +1306,25 @@ public class AlwaysOnHotwordDetector extends AbstractHotwordDetector { * otherwise {@link #createReEnrollIntent()} should be preferred. * * @return An {@link Intent} to start enrollment for the given keyphrase. - * @throws UnsupportedOperationException if managing they keyphrase isn't supported. - * Callers should only call this method after a supported state callback on - * {@link Callback#onAvailabilityChanged(int)} to avoid this exception. - * @throws IllegalStateException if the detector is in an invalid state. - * This may happen if another detector has been instantiated or the - * {@link VoiceInteractionService} hosting this detector has been shut down. + * @throws IllegalDetectorStateException Thrown when a caller has a target SDK of API level 33 + * or above. + * - Thrown if managing they keyphrase isn't supported. Callers should only call this + * method after a supported state callback on + * {@link Callback#onAvailabilityChanged(int)} to avoid this exception. + * - Thrown if the detector is in an invalid state. This may happen if another detector + * has been instantiated or the {@link VoiceInteractionService} hosting this detector + * has been shut down. + * @throws UnsupportedOperationException Thrown when a caller has a target SDK below API level + * 33 if managing they keyphrase isn't supported. Callers should only call this method + * after a supported state callback on {@link Callback#onAvailabilityChanged(int)} to + * avoid this exception. + * @throws IllegalStateException Thrown when a caller has a target SDK below API level 33 if the + * detector is in an invalid state. This may happen if another detector has been + * instantiated or the {@link VoiceInteractionService} hosting this detector has been + * shut down. */ @Nullable - public Intent createEnrollIntent() { + public Intent createEnrollIntent() throws IllegalDetectorStateException { if (DBG) Slog.d(TAG, "createEnrollIntent"); synchronized (mLock) { return getManageIntentLocked(KeyphraseEnrollmentInfo.MANAGE_ACTION_ENROLL); @@ -1224,15 +1338,25 @@ public class AlwaysOnHotwordDetector extends AbstractHotwordDetector { * i.e. {@link #STATE_KEYPHRASE_ENROLLED}, otherwise invoking this may result in an error. * * @return An {@link Intent} to start un-enrollment for the given keyphrase. - * @throws UnsupportedOperationException if managing they keyphrase isn't supported. - * Callers should only call this method after a supported state callback on - * {@link Callback#onAvailabilityChanged(int)} to avoid this exception. - * @throws IllegalStateException if the detector is in an invalid state. - * This may happen if another detector has been instantiated or the - * {@link VoiceInteractionService} hosting this detector has been shut down. + * @throws IllegalDetectorStateException Thrown when a caller has a target SDK of API level 33 + * or above. + * - Thrown if managing they keyphrase isn't supported. Callers should only call this + * method after a supported state callback on + * {@link Callback#onAvailabilityChanged(int)} to avoid this exception. + * - Thrown if the detector is in an invalid state. This may happen if another detector + * has been instantiated or the {@link VoiceInteractionService} hosting this detector + * has been shut down. + * @throws UnsupportedOperationException Thrown when a caller has a target SDK below API level + * 33 if managing they keyphrase isn't supported. Callers should only call this method + * after a supported state callback on {@link Callback#onAvailabilityChanged(int)} to + * avoid this exception. + * @throws IllegalStateException Thrown when a caller has a target SDK below API level 33 if the + * detector is in an invalid state. This may happen if another detector has been + * instantiated or the {@link VoiceInteractionService} hosting this detector has been + * shut down. */ @Nullable - public Intent createUnEnrollIntent() { + public Intent createUnEnrollIntent() throws IllegalDetectorStateException { if (DBG) Slog.d(TAG, "createUnEnrollIntent"); synchronized (mLock) { return getManageIntentLocked(KeyphraseEnrollmentInfo.MANAGE_ACTION_UN_ENROLL); @@ -1246,15 +1370,25 @@ public class AlwaysOnHotwordDetector extends AbstractHotwordDetector { * i.e. {@link #STATE_KEYPHRASE_ENROLLED}, otherwise invoking this may result in an error. * * @return An {@link Intent} to start re-enrollment for the given keyphrase. - * @throws UnsupportedOperationException if managing they keyphrase isn't supported. - * Callers should only call this method after a supported state callback on - * {@link Callback#onAvailabilityChanged(int)} to avoid this exception. - * @throws IllegalStateException if the detector is in an invalid or error state. - * This may happen if another detector has been instantiated or the - * {@link VoiceInteractionService} hosting this detector has been shut down. + * @throws IllegalDetectorStateException Thrown when a caller has a target SDK of API level 33 + * or above. + * - Thrown if managing they keyphrase isn't supported. Callers should only call this + * method after a supported state callback on + * {@link Callback#onAvailabilityChanged(int)} to avoid this exception. + * - Thrown if the detector is in an invalid state. This may happen if another detector + * has been instantiated or the {@link VoiceInteractionService} hosting this detector + * has been shut down. + * @throws UnsupportedOperationException Thrown when a caller has a target SDK below API level + * 33 if managing they keyphrase isn't supported. Callers should only call this method + * after a supported state callback on {@link Callback#onAvailabilityChanged(int)} to + * avoid this exception. + * @throws IllegalStateException Thrown when a caller has a target SDK below API level 33 if the + * detector is in an invalid state. This may happen if another detector has been + * instantiated or the {@link VoiceInteractionService} hosting this detector has been + * shut down. */ @Nullable - public Intent createReEnrollIntent() { + public Intent createReEnrollIntent() throws IllegalDetectorStateException { if (DBG) Slog.d(TAG, "createReEnrollIntent"); synchronized (mLock) { return getManageIntentLocked(KeyphraseEnrollmentInfo.MANAGE_ACTION_RE_ENROLL); @@ -1262,15 +1396,24 @@ public class AlwaysOnHotwordDetector extends AbstractHotwordDetector { } @GuardedBy("mLock") - private Intent getManageIntentLocked(@KeyphraseEnrollmentInfo.ManageActions int action) { + private Intent getManageIntentLocked(@KeyphraseEnrollmentInfo.ManageActions int action) + throws IllegalDetectorStateException { if (mAvailability == STATE_INVALID || mAvailability == STATE_ERROR) { + if (CompatChanges.isChangeEnabled(HOTWORD_DETECTOR_THROW_CHECKED_EXCEPTION)) { + throw new IllegalDetectorStateException( + "getManageIntent called on an invalid detector or error state"); + } throw new IllegalStateException( - "getManageIntent called on an invalid detector or error state"); + "getManageIntent called on an invalid detector or error state"); } // This method only makes sense if we can actually support a recognition. if (mAvailability != STATE_KEYPHRASE_ENROLLED && mAvailability != STATE_KEYPHRASE_UNENROLLED) { + if (CompatChanges.isChangeEnabled(HOTWORD_DETECTOR_THROW_CHECKED_EXCEPTION)) { + throw new IllegalDetectorStateException( + "Managing the given keyphrase is not supported"); + } throw new UnsupportedOperationException( "Managing the given keyphrase is not supported"); } @@ -1357,19 +1500,27 @@ public class AlwaysOnHotwordDetector extends AbstractHotwordDetector { @GuardedBy("mLock") private int startRecognitionLocked(int recognitionFlags, - @Nullable byte[] data) { + @Nullable byte[] data) throws IllegalDetectorStateException { if (DBG) { Slog.d(TAG, "startRecognition(" + recognitionFlags + ", " + Arrays.toString(data) + ")"); } if (mAvailability == STATE_INVALID || mAvailability == STATE_ERROR) { + if (CompatChanges.isChangeEnabled(HOTWORD_DETECTOR_THROW_CHECKED_EXCEPTION)) { + throw new IllegalDetectorStateException( + "startRecognition called on an invalid detector or error state"); + } throw new IllegalStateException( "startRecognition called on an invalid detector or error state"); } // Check if we can start/stop a recognition. if (mAvailability != STATE_KEYPHRASE_ENROLLED) { + if (CompatChanges.isChangeEnabled(HOTWORD_DETECTOR_THROW_CHECKED_EXCEPTION)) { + throw new IllegalDetectorStateException( + "Recognition for the given keyphrase is not supported"); + } throw new UnsupportedOperationException( "Recognition for the given keyphrase is not supported"); } diff --git a/core/java/android/service/voice/HotwordDetector.java b/core/java/android/service/voice/HotwordDetector.java index 96fd8bbda0160..38d9589333e12 100644 --- a/core/java/android/service/voice/HotwordDetector.java +++ b/core/java/android/service/voice/HotwordDetector.java @@ -23,10 +23,14 @@ import android.annotation.NonNull; import android.annotation.Nullable; import android.annotation.RequiresPermission; import android.annotation.SystemApi; +import android.compat.annotation.ChangeId; +import android.compat.annotation.EnabledSince; import android.media.AudioFormat; +import android.os.Build; import android.os.ParcelFileDescriptor; import android.os.PersistableBundle; import android.os.SharedMemory; +import android.util.AndroidException; /** * Basic functionality for hotword detectors. @@ -36,6 +40,23 @@ import android.os.SharedMemory; @SystemApi public interface HotwordDetector { + /** + * Prior to API level 33, API calls of {@link android.service.voice.HotwordDetector} could + * return both {@link java.lang.IllegalStateException} or + * {@link java.lang.UnsupportedOperationException} depending on the detector's underlying state. + * This lead to confusing behavior as the underlying state of the detector can be modified + * without the knowledge of the caller via system service layer updates. + * + * This change ID, when enabled, changes the API calls to only throw checked exception + * {@link android.service.voice.HotwordDetector.IllegalDetectorStateException} when checking + * against state information modified by both the caller and the system services. + * + * @hide + */ + @ChangeId + @EnabledSince(targetSdkVersion = Build.VERSION_CODES.TIRAMISU) + long HOTWORD_DETECTOR_THROW_CHECKED_EXCEPTION = 226355112L; + /** * Indicates that it is a non-trusted hotword detector. * @@ -74,16 +95,26 @@ public interface HotwordDetector { * Calling this again while recognition is active does nothing. * * @return true if the request to start recognition succeeded + * @throws IllegalDetectorStateException Thrown when a caller has a target SDK of API level 33 + * or above and attempts to start a recognition when the detector is not able based on + * the state. This can be thrown even if the state has been checked before calling this + * method because the caller receives updates via an asynchronous callback, and the + * state of the detector can change concurrently to the caller calling this method. */ @RequiresPermission(allOf = {RECORD_AUDIO, CAPTURE_AUDIO_HOTWORD}) - boolean startRecognition(); + boolean startRecognition() throws IllegalDetectorStateException; /** * Stops hotword recognition. * * @return true if the request to stop recognition succeeded + * @throws IllegalDetectorStateException Thrown when a caller has a target SDK of API level 33 + * or above and attempts to stop a recognition when the detector is not able based on + * the state. This can be thrown even if the state has been checked before calling this + * method because the caller receives updates via an asynchronous callback, and the + * state of the detector can change concurrently to the caller calling this method. */ - boolean stopRecognition(); + boolean stopRecognition() throws IllegalDetectorStateException; /** * Starts hotword recognition on audio coming from an external connected microphone. @@ -97,26 +128,37 @@ public interface HotwordDetector { * PersistableBundle does not allow any remotable objects or other contents that can be * used to communicate with other processes. * @return true if the request to start recognition succeeded + * @throws IllegalDetectorStateException Thrown when a caller has a target SDK of API level 33 + * or above and attempts to start a recognition when the detector is not able based on + * the state. This can be thrown even if the state has been checked before calling this + * method because the caller receives updates via an asynchronous callback, and the + * state of the detector can change concurrently to the caller calling this method. */ boolean startRecognition( @NonNull ParcelFileDescriptor audioStream, @NonNull AudioFormat audioFormat, - @Nullable PersistableBundle options); + @Nullable PersistableBundle options) throws IllegalDetectorStateException; /** * Set configuration and pass read-only data to hotword detection service. * * @param options Application configuration data to provide to the - * {@link HotwordDetectionService}. PersistableBundle does not allow any remotable objects or - * other contents that can be used to communicate with other processes. + * {@link HotwordDetectionService}. PersistableBundle does not allow any remotable + * objects or other contents that can be used to communicate with other processes. * @param sharedMemory The unrestricted data blob to provide to the - * {@link HotwordDetectionService}. Use this to provide the hotword models data or other - * such data to the trusted process. - * + * {@link HotwordDetectionService}. Use this to provide the hotword models data or other + * such data to the trusted process. + * @throws IllegalDetectorStateException Thrown when a caller has a target SDK of API level 33 + * or above and the detector is not able to perform the operation based on the + * underlying state. This can be thrown even if the state has been checked before + * calling this method because the caller receives updates via an asynchronous callback, + * and the state of the detector can change concurrently to the caller calling this + * method. * @throws IllegalStateException if this HotwordDetector wasn't specified to use a - * {@link HotwordDetectionService} when it was created. + * {@link HotwordDetectionService} when it was created. */ - void updateState(@Nullable PersistableBundle options, @Nullable SharedMemory sharedMemory); + void updateState(@Nullable PersistableBundle options, @Nullable SharedMemory sharedMemory) + throws IllegalDetectorStateException; /** * Invalidates this hotword detector so that any future calls to this result @@ -205,4 +247,14 @@ public interface HotwordDetector { */ void onHotwordDetectionServiceRestarted(); } + + /** + * {@link HotwordDetector} specific exception thrown when the underlying state of the detector + * is invalid for the given action. + */ + class IllegalDetectorStateException extends AndroidException { + IllegalDetectorStateException(String message) { + super(message); + } + } } diff --git a/core/java/android/service/voice/SoftwareHotwordDetector.java b/core/java/android/service/voice/SoftwareHotwordDetector.java index d4c58d281a51e..2ab7898059580 100644 --- a/core/java/android/service/voice/SoftwareHotwordDetector.java +++ b/core/java/android/service/voice/SoftwareHotwordDetector.java @@ -78,7 +78,7 @@ class SoftwareHotwordDetector extends AbstractHotwordDetector { @RequiresPermission(RECORD_AUDIO) @Override - public boolean startRecognition() { + public boolean startRecognition() throws IllegalDetectorStateException { if (DEBUG) { Slog.i(TAG, "#startRecognition"); } @@ -101,7 +101,7 @@ class SoftwareHotwordDetector extends AbstractHotwordDetector { /** TODO: stopRecognition */ @RequiresPermission(RECORD_AUDIO) @Override - public boolean stopRecognition() { + public boolean stopRecognition() throws IllegalDetectorStateException { if (DEBUG) { Slog.i(TAG, "#stopRecognition"); } diff --git a/tests/VoiceInteraction/src/com/android/test/voiceinteraction/MainInteractionService.java b/tests/VoiceInteraction/src/com/android/test/voiceinteraction/MainInteractionService.java index b7f5b15f72ace..f183a9b1d46c8 100644 --- a/tests/VoiceInteraction/src/com/android/test/voiceinteraction/MainInteractionService.java +++ b/tests/VoiceInteraction/src/com/android/test/voiceinteraction/MainInteractionService.java @@ -20,6 +20,7 @@ import android.content.Intent; import android.service.voice.AlwaysOnHotwordDetector; import android.service.voice.AlwaysOnHotwordDetector.Callback; import android.service.voice.AlwaysOnHotwordDetector.EventPayload; +import android.service.voice.HotwordDetector; import android.service.voice.VoiceInteractionService; import android.util.Log; @@ -83,16 +84,24 @@ public class MainInteractionService extends VoiceInteractionService { break; case AlwaysOnHotwordDetector.STATE_KEYPHRASE_UNENROLLED: Log.i(TAG, "STATE_KEYPHRASE_UNENROLLED"); - Intent enroll = mHotwordDetector.createEnrollIntent(); - Log.i(TAG, "Need to enroll with " + enroll); + try { + Intent enroll = mHotwordDetector.createEnrollIntent(); + Log.i(TAG, "Need to enroll with " + enroll); + } catch (HotwordDetector.IllegalDetectorStateException e) { + Log.e(TAG, "createEnrollIntent failed", e); + } break; case AlwaysOnHotwordDetector.STATE_KEYPHRASE_ENROLLED: Log.i(TAG, "STATE_KEYPHRASE_ENROLLED - starting recognition"); - if (mHotwordDetector.startRecognition( - AlwaysOnHotwordDetector.RECOGNITION_FLAG_NONE)) { - Log.i(TAG, "startRecognition succeeded"); - } else { - Log.i(TAG, "startRecognition failed"); + try { + if (mHotwordDetector.startRecognition( + AlwaysOnHotwordDetector.RECOGNITION_FLAG_NONE)) { + Log.i(TAG, "startRecognition succeeded"); + } else { + Log.i(TAG, "startRecognition failed"); + } + } catch (HotwordDetector.IllegalDetectorStateException e) { + Log.e(TAG, "startRecognition failed", e); } break; }