diff --git a/services/tests/voiceinteractiontests/Android.bp b/services/tests/voiceinteractiontests/Android.bp new file mode 100644 index 0000000000000..9ca287686758b --- /dev/null +++ b/services/tests/voiceinteractiontests/Android.bp @@ -0,0 +1,60 @@ +// Copyright (C) 2022 The Android Open Source Project +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package { + // See: http://go/android-license-faq + // A large-scale-change added 'default_applicable_licenses' to import + // all of the 'license_kinds' from "frameworks_base_license" + // to get the below license kinds: + // SPDX-license-identifier-Apache-2.0 + default_applicable_licenses: ["frameworks_base_license"], +} + +android_test { + name: "FrameworksVoiceInteractionTests", + defaults: [ + "modules-utils-testable-device-config-defaults", + ], + + srcs: [ + "src/**/*.java", + ], + + static_libs: [ + "androidx.test.core", + "androidx.test.runner", + "androidx.test.ext.truth", + "mockito-target-extended-minus-junit4", + "platform-test-annotations", + "services.core", + "services.voiceinteraction", + "servicestests-core-utils", + "servicestests-utils-mockito-extended", + "truth-prebuilt", + ], + + libs: [ + "android.test.mock", + "android.test.base", + "android.test.runner", + ], + + certificate: "platform", + platform_apis: true, + test_suites: ["device-tests"], + + optimize: { + enabled: false, + }, +} diff --git a/services/tests/voiceinteractiontests/AndroidManifest.xml b/services/tests/voiceinteractiontests/AndroidManifest.xml new file mode 100644 index 0000000000000..ce76a517d3b7c --- /dev/null +++ b/services/tests/voiceinteractiontests/AndroidManifest.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + + diff --git a/services/tests/voiceinteractiontests/AndroidTest.xml b/services/tests/voiceinteractiontests/AndroidTest.xml new file mode 100644 index 0000000000000..ce486332a02b4 --- /dev/null +++ b/services/tests/voiceinteractiontests/AndroidTest.xml @@ -0,0 +1,30 @@ + + + + + + + diff --git a/services/tests/voiceinteractiontests/OWNERS b/services/tests/voiceinteractiontests/OWNERS new file mode 100644 index 0000000000000..86d392e3bfa5a --- /dev/null +++ b/services/tests/voiceinteractiontests/OWNERS @@ -0,0 +1 @@ +include /services/voiceinteraction/OWNERS diff --git a/services/tests/voiceinteractiontests/src/com/android/server/voiceinteraction/HotwordAudioStreamCopierTest.java b/services/tests/voiceinteractiontests/src/com/android/server/voiceinteraction/HotwordAudioStreamCopierTest.java new file mode 100644 index 0000000000000..8f35c117b1ee3 --- /dev/null +++ b/services/tests/voiceinteractiontests/src/com/android/server/voiceinteraction/HotwordAudioStreamCopierTest.java @@ -0,0 +1,296 @@ +/* + * Copyright (C) 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.server.voiceinteraction; + +import static android.service.voice.HotwordAudioStream.KEY_AUDIO_STREAM_COPY_BUFFER_LENGTH_BYTES; + +import static com.android.server.voiceinteraction.HotwordAudioStreamCopier.DEFAULT_COPY_BUFFER_LENGTH_BYTES; +import static com.android.server.voiceinteraction.HotwordAudioStreamCopier.MAX_COPY_BUFFER_LENGTH_BYTES; + +import static com.google.common.truth.Truth.assertThat; + +import static org.mockito.Mockito.mock; + +import android.app.AppOpsManager; +import android.media.AudioFormat; +import android.media.MediaSyncEvent; +import android.os.ParcelFileDescriptor; +import android.os.PersistableBundle; +import android.platform.test.annotations.Presubmit; +import android.service.voice.HotwordAudioStream; +import android.service.voice.HotwordDetectedResult; + +import androidx.test.ext.junit.runners.AndroidJUnit4; +import androidx.test.filters.SmallTest; + +import org.jetbrains.annotations.NotNull; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.util.ArrayList; +import java.util.List; + +@SmallTest +@Presubmit +@RunWith(AndroidJUnit4.class) +public class HotwordAudioStreamCopierTest { + + private static final int DETECTOR_TYPE = 1; + private static final int VOICE_INTERACTOR_UID = 999; + private static final String VOICE_INTERACTOR_PACKAGE_NAME = "VIPackageName"; + private static final String VOICE_INTERACTOR_ATTRIBUTION_TAG = "VIAttributionTag"; + private static final AudioFormat FAKE_AUDIO_FORMAT = + new AudioFormat.Builder() + .setSampleRate(32000) + .setEncoding(AudioFormat.ENCODING_PCM_16BIT) + .setChannelMask(AudioFormat.CHANNEL_IN_MONO).build(); + + private HotwordAudioStreamCopier mCopier; + private AppOpsManager mAppOpsManager; + + @Before + public void setUp() { + mAppOpsManager = mock(AppOpsManager.class); + mCopier = new HotwordAudioStreamCopier(mAppOpsManager, DETECTOR_TYPE, VOICE_INTERACTOR_UID, + VOICE_INTERACTOR_PACKAGE_NAME, VOICE_INTERACTOR_ATTRIBUTION_TAG); + } + + @Test + public void testDefaultCopyBufferLength() throws Exception { + ParcelFileDescriptor[] fakeAudioStreamPipe = ParcelFileDescriptor.createPipe(); + try { + // There is no copy buffer length is specified in the metadata. + // HotwordAudioStreamCopier should use the default copy buffer length. + List originalAudioStreams = new ArrayList<>(); + HotwordAudioStream audioStream = new HotwordAudioStream.Builder(FAKE_AUDIO_FORMAT, + fakeAudioStreamPipe[0]).build(); + originalAudioStreams.add(audioStream); + HotwordDetectedResult originalResult = buildHotwordDetectedResultWithStreams( + originalAudioStreams); + + HotwordDetectedResult managedResult = mCopier.startCopyingAudioStreams( + originalResult); + List managedAudioStreams = managedResult.getAudioStreams(); + assertThat(managedAudioStreams.size()).isEqualTo(1); + + ParcelFileDescriptor readFd = + managedAudioStreams.get(0).getAudioStreamParcelFileDescriptor(); + ParcelFileDescriptor writeFd = fakeAudioStreamPipe[1]; + verifyCopyBufferLength(DEFAULT_COPY_BUFFER_LENGTH_BYTES, readFd, writeFd); + } finally { + closeAudioStreamPipe(fakeAudioStreamPipe); + } + } + + @Test + public void testCustomCopyBufferLength() throws Exception { + List fakeAudioStreamPipes = new ArrayList<>(); + try { + // We create 4 audio streams, with various small prime values specified in the metadata. + // HotwordAudioStreamCopier reads data in chunks the size of the buffer. In + // verifyCopyBufferLength(), we check if the number of bytes read from the copied stream + // is a multiple of the buffer length. + // + // By using prime numbers, this ensures that HotwordAudioStreamCopier is reading the + // correct buffer length for the corresponding stream, since multiples of different + // primes cannot be equal. A small number helps ensure that the test reads the copied + // stream before HotwordAudioStreamCopier can copy the entire source stream (which has + // a large size). + int[] copyBufferLengths = new int[]{2, 3, 5, 7}; + List originalAudioStreams = new ArrayList<>(); + for (int i = 0; i < copyBufferLengths.length; i++) { + ParcelFileDescriptor[] fakeAudioStreamPipe = ParcelFileDescriptor.createPipe(); + fakeAudioStreamPipes.add(fakeAudioStreamPipe); + HotwordAudioStream audioStream = new HotwordAudioStream.Builder(FAKE_AUDIO_FORMAT, + fakeAudioStreamPipe[0]).build(); + audioStream.getMetadata().putInt(KEY_AUDIO_STREAM_COPY_BUFFER_LENGTH_BYTES, + copyBufferLengths[i]); + originalAudioStreams.add(audioStream); + } + HotwordDetectedResult originalResult = buildHotwordDetectedResultWithStreams( + originalAudioStreams); + + HotwordDetectedResult managedResult = mCopier.startCopyingAudioStreams( + originalResult); + List managedAudioStreams = managedResult.getAudioStreams(); + assertThat(managedAudioStreams.size()).isEqualTo(copyBufferLengths.length); + + for (int i = 0; i < copyBufferLengths.length; i++) { + ParcelFileDescriptor readFd = + managedAudioStreams.get(i).getAudioStreamParcelFileDescriptor(); + ParcelFileDescriptor writeFd = fakeAudioStreamPipes.get(i)[1]; + verifyCopyBufferLength(copyBufferLengths[i], readFd, writeFd); + } + } finally { + for (ParcelFileDescriptor[] fakeAudioStreamPipe : fakeAudioStreamPipes) { + closeAudioStreamPipe(fakeAudioStreamPipe); + } + } + } + + @Test + public void testInvalidCopyBufferLength_NonPositive() throws Exception { + ParcelFileDescriptor[] fakeAudioStreamPipe = ParcelFileDescriptor.createPipe(); + try { + // An invalid copy buffer length (non-positive) is specified in the metadata. + // HotwordAudioStreamCopier should use the default copy buffer length. + List originalAudioStreams = new ArrayList<>(); + HotwordAudioStream audioStream = new HotwordAudioStream.Builder(FAKE_AUDIO_FORMAT, + fakeAudioStreamPipe[0]).build(); + audioStream.getMetadata().putInt(KEY_AUDIO_STREAM_COPY_BUFFER_LENGTH_BYTES, 0); + originalAudioStreams.add(audioStream); + HotwordDetectedResult originalResult = buildHotwordDetectedResultWithStreams( + originalAudioStreams); + + HotwordDetectedResult managedResult = mCopier.startCopyingAudioStreams( + originalResult); + List managedAudioStreams = managedResult.getAudioStreams(); + assertThat(managedAudioStreams.size()).isEqualTo(1); + + ParcelFileDescriptor readFd = + managedAudioStreams.get(0).getAudioStreamParcelFileDescriptor(); + ParcelFileDescriptor writeFd = fakeAudioStreamPipe[1]; + verifyCopyBufferLength(DEFAULT_COPY_BUFFER_LENGTH_BYTES, readFd, writeFd); + } finally { + closeAudioStreamPipe(fakeAudioStreamPipe); + } + } + + @Test + public void testInvalidCopyBufferLength_ExceedsMaximum() throws Exception { + ParcelFileDescriptor[] fakeAudioStreamPipe = ParcelFileDescriptor.createPipe(); + try { + // An invalid copy buffer length (exceeds the maximum) is specified in the metadata. + // HotwordAudioStreamCopier should use the default copy buffer length. + List originalAudioStreams = new ArrayList<>(); + HotwordAudioStream audioStream = new HotwordAudioStream.Builder(FAKE_AUDIO_FORMAT, + fakeAudioStreamPipe[0]).build(); + audioStream.getMetadata().putInt(KEY_AUDIO_STREAM_COPY_BUFFER_LENGTH_BYTES, + MAX_COPY_BUFFER_LENGTH_BYTES + 1); + originalAudioStreams.add(audioStream); + HotwordDetectedResult originalResult = buildHotwordDetectedResultWithStreams( + originalAudioStreams); + + HotwordDetectedResult managedResult = mCopier.startCopyingAudioStreams( + originalResult); + List managedAudioStreams = managedResult.getAudioStreams(); + assertThat(managedAudioStreams.size()).isEqualTo(1); + + ParcelFileDescriptor readFd = + managedAudioStreams.get(0).getAudioStreamParcelFileDescriptor(); + ParcelFileDescriptor writeFd = fakeAudioStreamPipe[1]; + verifyCopyBufferLength(DEFAULT_COPY_BUFFER_LENGTH_BYTES, readFd, writeFd); + } finally { + closeAudioStreamPipe(fakeAudioStreamPipe); + } + } + + @Test + public void testInvalidCopyBufferLength_NotAnInt() throws Exception { + ParcelFileDescriptor[] fakeAudioStreamPipe = ParcelFileDescriptor.createPipe(); + try { + // An invalid copy buffer length (not an int) is specified in the metadata. + // HotwordAudioStreamCopier should use the default copy buffer length. + List originalAudioStreams = new ArrayList<>(); + HotwordAudioStream audioStream = new HotwordAudioStream.Builder(FAKE_AUDIO_FORMAT, + fakeAudioStreamPipe[0]).build(); + audioStream.getMetadata().putString(KEY_AUDIO_STREAM_COPY_BUFFER_LENGTH_BYTES, + "Not an int"); + originalAudioStreams.add(audioStream); + HotwordDetectedResult originalResult = buildHotwordDetectedResultWithStreams( + originalAudioStreams); + + HotwordDetectedResult managedResult = mCopier.startCopyingAudioStreams( + originalResult); + List managedAudioStreams = managedResult.getAudioStreams(); + assertThat(managedAudioStreams.size()).isEqualTo(1); + + ParcelFileDescriptor readFd = + managedAudioStreams.get(0).getAudioStreamParcelFileDescriptor(); + ParcelFileDescriptor writeFd = fakeAudioStreamPipe[1]; + verifyCopyBufferLength(DEFAULT_COPY_BUFFER_LENGTH_BYTES, readFd, writeFd); + } finally { + closeAudioStreamPipe(fakeAudioStreamPipe); + } + } + + private void verifyCopyBufferLength(int expectedCopyBufferLength, ParcelFileDescriptor readFd, + ParcelFileDescriptor writeFd) throws IOException { + byte[] bytesToRepeat = new byte[]{99}; + try (InputStream is = new ParcelFileDescriptor.AutoCloseInputStream(readFd); + OutputStream os = new ParcelFileDescriptor.AutoCloseOutputStream(writeFd)) { + writeToFakeAudioStreamPipe(os, bytesToRepeat, MAX_COPY_BUFFER_LENGTH_BYTES); + byte[] actualBytesRead = new byte[MAX_COPY_BUFFER_LENGTH_BYTES]; + int numBytesRead = is.read(actualBytesRead); + + // HotwordAudioStreamCopier reads data in chunks the size of the buffer. We write MAX + // bytes but the actual number of bytes read from the copied stream should be a + // multiple of the buffer length. + assertThat(numBytesRead % expectedCopyBufferLength).isEqualTo(0); + } + } + + @NotNull + private static HotwordDetectedResult buildHotwordDetectedResultWithStreams( + List audioStreams) { + return new HotwordDetectedResult.Builder() + .setConfidenceLevel(HotwordDetectedResult.CONFIDENCE_LEVEL_LOW) + .setMediaSyncEvent(MediaSyncEvent.createEvent( + MediaSyncEvent.SYNC_EVENT_PRESENTATION_COMPLETE)) + .setHotwordOffsetMillis(100) + .setHotwordDurationMillis(1000) + .setAudioChannel(1) + .setHotwordDetectionPersonalized(true) + .setScore(100) + .setPersonalizedScore(100) + .setHotwordPhraseId(1) + .setAudioStreams(audioStreams) + .setExtras(new PersistableBundle()) + .build(); + } + + private static void writeToFakeAudioStreamPipe(OutputStream writeOutputStream, + byte[] bytesToRepeat, int totalBytesToWrite) throws IOException { + // Create the fake stream buffer, consisting of bytesToRepeat, repeated as many times as + // needed to get to totalBytesToWrite. + byte[] fakeAudioData = new byte[totalBytesToWrite]; + int bytesWritten = 0; + while (bytesWritten + bytesToRepeat.length <= totalBytesToWrite) { + System.arraycopy(bytesToRepeat, 0, fakeAudioData, bytesWritten, bytesToRepeat.length); + bytesWritten += bytesToRepeat.length; + } + if (bytesWritten < totalBytesToWrite) { + int bytesLeft = totalBytesToWrite - bytesWritten; + System.arraycopy(bytesToRepeat, 0, fakeAudioData, bytesWritten, bytesLeft); + } + + writeOutputStream.write(fakeAudioData); + } + + private static void closeAudioStreamPipe(ParcelFileDescriptor[] parcelFileDescriptors) + throws IOException { + if (parcelFileDescriptors != null) { + parcelFileDescriptors[0].close(); + parcelFileDescriptors[1].close(); + } + } + +} diff --git a/services/voiceinteraction/TEST_MAPPING b/services/voiceinteraction/TEST_MAPPING index af67637c3898e..d6c69647d7541 100644 --- a/services/voiceinteraction/TEST_MAPPING +++ b/services/voiceinteraction/TEST_MAPPING @@ -31,6 +31,14 @@ "exclude-annotation": "androidx.test.filters.FlakyTest" } ] + }, + { + "name": "FrameworksVoiceInteractionTests", + "options": [ + { + "exclude-annotation": "androidx.test.filters.FlakyTest" + } + ] } ] } diff --git a/services/voiceinteraction/java/com/android/server/voiceinteraction/HotwordAudioStreamCopier.java b/services/voiceinteraction/java/com/android/server/voiceinteraction/HotwordAudioStreamCopier.java index 2bddd74f98624..2413072d036d7 100644 --- a/services/voiceinteraction/java/com/android/server/voiceinteraction/HotwordAudioStreamCopier.java +++ b/services/voiceinteraction/java/com/android/server/voiceinteraction/HotwordAudioStreamCopier.java @@ -36,6 +36,8 @@ import android.service.voice.HotwordAudioStream; import android.service.voice.HotwordDetectedResult; import android.util.Slog; +import com.android.internal.annotations.VisibleForTesting; + import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; @@ -60,10 +62,12 @@ final class HotwordAudioStreamCopier { private static final String OP_MESSAGE = "Streaming hotword audio to VoiceInteractionService"; private static final String TASK_ID_PREFIX = "HotwordDetectedResult@"; private static final String THREAD_NAME_PREFIX = "Copy-"; + @VisibleForTesting + static final int DEFAULT_COPY_BUFFER_LENGTH_BYTES = 32_768; // Corresponds to the OS pipe capacity in bytes - private static final int MAX_COPY_BUFFER_LENGTH_BYTES = 65_536; - private static final int DEFAULT_COPY_BUFFER_LENGTH_BYTES = 32_768; + @VisibleForTesting + static final int MAX_COPY_BUFFER_LENGTH_BYTES = 65_536; private final AppOpsManager mAppOpsManager; private final int mDetectorType;