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) {