Add TestApi to trigger the onDetect function of HotwordDetectionService
Bug: 184685043
Test: atest CtsVoiceInteractionTestCases
Test: atest CtsVoiceInteractionTestCases --instant
Change-Id: I531c1229de908c64e29f1976bd2fd1e70e545853
diff --git a/core/api/test-current.txt b/core/api/test-current.txt
index e449728..f3e0f31 100644
--- a/core/api/test-current.txt
+++ b/core/api/test-current.txt
@@ -2321,6 +2321,14 @@
}
+package android.service.voice {
+
+ public class AlwaysOnHotwordDetector implements android.service.voice.HotwordDetector {
+ method @RequiresPermission(allOf={android.Manifest.permission.RECORD_AUDIO, android.Manifest.permission.CAPTURE_AUDIO_HOTWORD}) public void triggerHardwareRecognitionEventForTest(int, int, boolean, int, int, int, boolean, @NonNull android.media.AudioFormat, @Nullable byte[]);
+ }
+
+}
+
package android.service.watchdog {
public abstract class ExplicitHealthCheckService extends android.app.Service {
diff --git a/core/java/android/service/voice/AlwaysOnHotwordDetector.java b/core/java/android/service/voice/AlwaysOnHotwordDetector.java
index 8ca0e7c..0b410a2 100644
--- a/core/java/android/service/voice/AlwaysOnHotwordDetector.java
+++ b/core/java/android/service/voice/AlwaysOnHotwordDetector.java
@@ -24,6 +24,7 @@
import android.annotation.Nullable;
import android.annotation.RequiresPermission;
import android.annotation.SystemApi;
+import android.annotation.TestApi;
import android.app.ActivityThread;
import android.compat.annotation.UnsupportedAppUsage;
import android.content.Context;
@@ -49,6 +50,7 @@
import android.os.RemoteException;
import android.os.SharedMemory;
import android.service.voice.HotwordDetectionService.InitializationStatus;
+import android.util.Log;
import android.util.Slog;
import com.android.internal.app.IHotwordRecognitionStatusCallback;
@@ -628,6 +630,34 @@
}
/**
+ * Test API to simulate to trigger hardware recognition event for test.
+ *
+ * @hide
+ */
+ @TestApi
+ @RequiresPermission(allOf = {RECORD_AUDIO, CAPTURE_AUDIO_HOTWORD})
+ public void triggerHardwareRecognitionEventForTest(int status, int soundModelHandle,
+ boolean captureAvailable, int captureSession, int captureDelayMs, int capturePreambleMs,
+ boolean triggerInData, @NonNull AudioFormat captureFormat, @Nullable byte[] data) {
+ Log.d(TAG, "triggerHardwareRecognitionEventForTest()");
+ synchronized (mLock) {
+ if (mAvailability == STATE_INVALID || mAvailability == STATE_ERROR) {
+ throw new IllegalStateException("triggerHardwareRecognitionEventForTest called on"
+ + " an invalid detector or error state");
+ }
+ try {
+ mModelManagementService.triggerHardwareRecognitionEventForTest(
+ new KeyphraseRecognitionEvent(status, soundModelHandle, captureAvailable,
+ captureSession, captureDelayMs, capturePreambleMs, triggerInData,
+ captureFormat, data, null /* keyphraseExtras */),
+ mInternalCallback);
+ } catch (RemoteException e) {
+ throw e.rethrowFromSystemServer();
+ }
+ }
+ }
+
+ /**
* Gets the recognition modes supported by the associated keyphrase.
*
* @see #RECOGNITION_MODE_USER_IDENTIFICATION
diff --git a/core/java/com/android/internal/app/IVoiceInteractionManagerService.aidl b/core/java/com/android/internal/app/IVoiceInteractionManagerService.aidl
index 5a5e745..dddc08a 100644
--- a/core/java/com/android/internal/app/IVoiceInteractionManagerService.aidl
+++ b/core/java/com/android/internal/app/IVoiceInteractionManagerService.aidl
@@ -261,4 +261,11 @@
in AudioFormat audioFormat,
in PersistableBundle options,
in IMicrophoneHotwordDetectionVoiceInteractionCallback callback);
+
+ /**
+ * Test API to simulate to trigger hardware recognition event for test.
+ */
+ void triggerHardwareRecognitionEventForTest(
+ in SoundTrigger.KeyphraseRecognitionEvent event,
+ in IHotwordRecognitionStatusCallback callback);
}
diff --git a/services/voiceinteraction/java/com/android/server/voiceinteraction/HotwordDetectionConnection.java b/services/voiceinteraction/java/com/android/server/voiceinteraction/HotwordDetectionConnection.java
index 6f701f7..a002bc5 100644
--- a/services/voiceinteraction/java/com/android/server/voiceinteraction/HotwordDetectionConnection.java
+++ b/services/voiceinteraction/java/com/android/server/voiceinteraction/HotwordDetectionConnection.java
@@ -270,6 +270,114 @@
}
}
+ void triggerHardwareRecognitionEventForTestLocked(
+ SoundTrigger.KeyphraseRecognitionEvent event,
+ IHotwordRecognitionStatusCallback callback) {
+ if (DEBUG) {
+ Slog.d(TAG, "triggerHardwareRecognitionEventForTestLocked");
+ }
+ detectFromDspSourceForTest(event, callback);
+ }
+
+ private void detectFromDspSourceForTest(SoundTrigger.KeyphraseRecognitionEvent recognitionEvent,
+ IHotwordRecognitionStatusCallback externalCallback) {
+ if (DEBUG) {
+ Slog.d(TAG, "detectFromDspSourceForTest");
+ }
+
+ AudioRecord record = createFakeAudioRecord();
+ if (record == null) {
+ Slog.d(TAG, "Failed to create fake audio record");
+ return;
+ }
+
+ Pair<ParcelFileDescriptor, ParcelFileDescriptor> clientPipe = createPipe();
+ if (clientPipe == null) {
+ Slog.d(TAG, "Failed to create pipe");
+ return;
+ }
+ ParcelFileDescriptor audioSink = clientPipe.second;
+ ParcelFileDescriptor clientRead = clientPipe.first;
+
+ record.startRecording();
+
+ mAudioCopyExecutor.execute(() -> {
+ try (OutputStream fos =
+ new ParcelFileDescriptor.AutoCloseOutputStream(audioSink)) {
+
+ int remainToRead = 10240;
+ byte[] buffer = new byte[1024];
+ while (remainToRead > 0) {
+ int bytesRead = record.read(buffer, 0, 1024);
+ if (DEBUG) {
+ Slog.d(TAG, "bytesRead = " + bytesRead);
+ }
+ if (bytesRead <= 0) {
+ break;
+ }
+ if (bytesRead > 8) {
+ System.arraycopy(new byte[] {'h', 'o', 't', 'w', 'o', 'r', 'd', '!'}, 0,
+ buffer, 0, 8);
+ }
+
+ fos.write(buffer, 0, bytesRead);
+ remainToRead -= bytesRead;
+ }
+ } catch (IOException e) {
+ Slog.w(TAG, "Failed supplying audio data to validator", e);
+ }
+ });
+
+ Runnable cancellingJob = () -> {
+ Slog.d(TAG, "Timeout for getting callback from HotwordDetectionService");
+ record.stop();
+ record.release();
+ bestEffortClose(audioSink);
+ bestEffortClose(clientRead);
+ };
+
+ ScheduledFuture<?> cancelingFuture =
+ mScheduledExecutorService.schedule(
+ cancellingJob, VALIDATION_TIMEOUT_MILLIS, TimeUnit.MILLISECONDS);
+
+ IDspHotwordDetectionCallback internalCallback = new IDspHotwordDetectionCallback.Stub() {
+ @Override
+ public void onDetected(HotwordDetectedResult result) throws RemoteException {
+ if (DEBUG) {
+ Slog.d(TAG, "onDetected");
+ }
+ cancelingFuture.cancel(true);
+ record.stop();
+ record.release();
+ bestEffortClose(audioSink);
+ bestEffortClose(clientRead);
+
+ externalCallback.onKeyphraseDetected(recognitionEvent);
+ }
+
+ @Override
+ public void onRejected(HotwordRejectedResult result) throws RemoteException {
+ if (DEBUG) {
+ Slog.d(TAG, "onRejected");
+ }
+ cancelingFuture.cancel(true);
+ record.stop();
+ record.release();
+ bestEffortClose(audioSink);
+ bestEffortClose(clientRead);
+
+ externalCallback.onRejected(result);
+ }
+ };
+
+ mRemoteHotwordDetectionService.run(
+ service -> service.detectFromDspSource(
+ clientRead,
+ recognitionEvent.getCaptureFormat(),
+ VALIDATION_TIMEOUT_MILLIS,
+ internalCallback));
+ }
+
private void detectFromDspSource(SoundTrigger.KeyphraseRecognitionEvent recognitionEvent,
IHotwordRecognitionStatusCallback externalCallback) {
if (DEBUG) {
@@ -456,6 +564,37 @@
}
}
+ @Nullable
+ private AudioRecord createFakeAudioRecord() {
+ if (DEBUG) {
+ Slog.i(TAG, "#createFakeAudioRecord");
+ }
+ try {
+ AudioRecord audioRecord = new AudioRecord.Builder()
+ .setAudioFormat(new AudioFormat.Builder()
+ .setSampleRate(32000)
+ .setEncoding(AudioFormat.ENCODING_PCM_16BIT)
+ .setChannelMask(AudioFormat.CHANNEL_IN_MONO).build())
+ .setAudioAttributes(new AudioAttributes.Builder()
+ .setInternalCapturePreset(MediaRecorder.AudioSource.HOTWORD).build())
+ .setBufferSizeInBytes(
+ AudioRecord.getMinBufferSize(32000,
+ AudioFormat.CHANNEL_IN_MONO,
+ AudioFormat.ENCODING_PCM_16BIT) * 2)
+ .build();
+
+ if (audioRecord.getState() != AudioRecord.STATE_INITIALIZED) {
+ Slog.w(TAG, "Failed to initialize AudioRecord");
+ audioRecord.release();
+ return null;
+ }
+ return audioRecord;
+ } catch (IllegalArgumentException e) {
+ Slog.e(TAG, "Failed to create AudioRecord", e);
+ }
+ return null;
+ }
+
/**
* Returns the number of bytes required to store {@code bufferLengthSeconds} of audio sampled at
* {@code sampleRate} Hz, using the format returned by DSP audio capture.
diff --git a/services/voiceinteraction/java/com/android/server/voiceinteraction/VoiceInteractionManagerService.java b/services/voiceinteraction/java/com/android/server/voiceinteraction/VoiceInteractionManagerService.java
index 9aded89..92cfe49 100644
--- a/services/voiceinteraction/java/com/android/server/voiceinteraction/VoiceInteractionManagerService.java
+++ b/services/voiceinteraction/java/com/android/server/voiceinteraction/VoiceInteractionManagerService.java
@@ -1130,6 +1130,29 @@
}
}
+ @Override
+ public void triggerHardwareRecognitionEventForTest(
+ SoundTrigger.KeyphraseRecognitionEvent event,
+ IHotwordRecognitionStatusCallback callback)
+ throws RemoteException {
+ enforceCallingPermission(Manifest.permission.RECORD_AUDIO);
+ enforceCallingPermission(Manifest.permission.CAPTURE_AUDIO_HOTWORD);
+ synchronized (this) {
+ enforceIsCurrentVoiceInteractionService();
+
+ if (mImpl == null) {
+ Slog.w(TAG, "triggerHardwareRecognitionEventForTest without running"
+ + " voice interaction service");
+ return;
+ }
+ final long caller = Binder.clearCallingIdentity();
+ try {
+ mImpl.triggerHardwareRecognitionEventForTestLocked(event, callback);
+ } finally {
+ Binder.restoreCallingIdentity(caller);
+ }
+ }
+ }
//----------------- Model management APIs --------------------------------//
@Override
diff --git a/services/voiceinteraction/java/com/android/server/voiceinteraction/VoiceInteractionManagerServiceImpl.java b/services/voiceinteraction/java/com/android/server/voiceinteraction/VoiceInteractionManagerServiceImpl.java
index 6922ccc..0552841 100644
--- a/services/voiceinteraction/java/com/android/server/voiceinteraction/VoiceInteractionManagerServiceImpl.java
+++ b/services/voiceinteraction/java/com/android/server/voiceinteraction/VoiceInteractionManagerServiceImpl.java
@@ -40,6 +40,7 @@
import android.content.pm.PackageManager;
import android.content.pm.ServiceInfo;
import android.hardware.soundtrigger.IRecognitionStatusCallback;
+import android.hardware.soundtrigger.SoundTrigger;
import android.media.AudioFormat;
import android.os.Bundle;
import android.os.Handler;
@@ -493,6 +494,20 @@
mHotwordDetectionConnection.stopListening();
}
+ public void triggerHardwareRecognitionEventForTestLocked(
+ SoundTrigger.KeyphraseRecognitionEvent event,
+ IHotwordRecognitionStatusCallback callback) {
+ if (DEBUG) {
+ Slog.d(TAG, "triggerHardwareRecognitionEventForTestLocked");
+ }
+ if (mHotwordDetectionConnection == null) {
+ Slog.w(TAG, "triggerHardwareRecognitionEventForTestLocked() called but connection"
+ + " isn't established");
+ return;
+ }
+ mHotwordDetectionConnection.triggerHardwareRecognitionEventForTestLocked(event, callback);
+ }
+
public IRecognitionStatusCallback createSoundTriggerCallbackLocked(
IHotwordRecognitionStatusCallback callback) {
if (DEBUG) {