1 /* 2 * Copyright (C) 2023 The Android Open Source Project 3 * 4 * Licensed under the Apache License, Version 2.0 (the "License"); 5 * you may not use this file except in compliance with the License. 6 * You may obtain a copy of the License at 7 * 8 * http://www.apache.org/licenses/LICENSE-2.0 9 * 10 * Unless required by applicable law or agreed to in writing, software 11 * distributed under the License is distributed on an "AS IS" BASIS, 12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 * See the License for the specific language governing permissions and 14 * limitations under the License. 15 */ 16 17 package android.soundtrigger.cts; 18 19 import static android.Manifest.permission.CAPTURE_AUDIO_HOTWORD; 20 import static android.Manifest.permission.MANAGE_SOUND_TRIGGER; 21 import static android.Manifest.permission.RECORD_AUDIO; 22 import static android.content.pm.PackageManager.FEATURE_MICROPHONE; 23 24 import static androidx.test.platform.app.InstrumentationRegistry.getInstrumentation; 25 26 import static com.android.compatibility.common.util.SystemUtil.runWithShellPermissionIdentity; 27 28 import static com.google.common.truth.Truth.assertThat; 29 30 import static org.junit.Assert.assertThrows; 31 import static org.junit.Assume.assumeTrue; 32 33 import android.app.AppOpsManager; 34 import android.content.Context; 35 import android.media.soundtrigger.SoundTriggerDetector; 36 import android.media.soundtrigger.SoundTriggerDetector.Callback; 37 import android.media.soundtrigger.SoundTriggerDetector.EventPayload; 38 import android.media.soundtrigger.SoundTriggerInstrumentation; 39 import android.media.soundtrigger.SoundTriggerInstrumentation.RecognitionSession; 40 import android.media.soundtrigger.SoundTriggerManager; 41 import android.media.soundtrigger.SoundTriggerManager.Model; 42 import android.os.Handler; 43 import android.os.HandlerThread; 44 import android.os.SystemClock; 45 import android.soundtrigger.cts.instrumentation.SoundTriggerInstrumentationObserver; 46 import android.soundtrigger.cts.instrumentation.SoundTriggerInstrumentationObserver.ModelSessionObserver; 47 import android.util.Log; 48 49 import androidx.test.ext.junit.runners.AndroidJUnit4; 50 import androidx.test.filters.FlakyTest; 51 52 import com.android.compatibility.common.util.RequiredFeatureRule; 53 54 import com.google.common.util.concurrent.Futures; 55 import com.google.common.util.concurrent.ListenableFuture; 56 import com.google.common.util.concurrent.SettableFuture; 57 58 import org.junit.After; 59 import org.junit.Before; 60 import org.junit.Rule; 61 import org.junit.Test; 62 import org.junit.runner.RunWith; 63 64 import java.util.Objects; 65 import java.util.UUID; 66 import java.util.concurrent.ExecutionException; 67 import java.util.concurrent.Future; 68 import java.util.concurrent.TimeUnit; 69 import java.util.concurrent.TimeoutException; 70 71 72 @RunWith(AndroidJUnit4.class) 73 public class SoundTriggerManagerTest { 74 private static final String TAG = "SoundTriggerManagerTest"; 75 private static final Context sContext = getInstrumentation().getTargetContext(); 76 private static final int TIMEOUT = 5000; 77 private static final UUID MODEL_UUID = new UUID(5, 7); 78 private static final Model sModel = Model.create(MODEL_UUID, new UUID(7, 5), new byte[0], 1); 79 80 private SoundTriggerManager mRealManager = null; 81 private SoundTriggerManager mManager = null; 82 private SoundTriggerInstrumentation mInstrumentation = null; 83 private SoundTriggerDetector mDetector = null; 84 85 private final SoundTriggerInstrumentationObserver mInstrumentationObserver = 86 new SoundTriggerInstrumentationObserver(); 87 88 private final Object mDetectedLock = new Object(); 89 private SettableFuture<Void> mDetectedFuture; 90 private boolean mDroppedCallback = false; 91 92 private Handler mHandler = null; 93 private boolean mIsModelLoaded = false; 94 95 @Rule 96 public RequiredFeatureRule REQUIRES_MIC_RULE = new RequiredFeatureRule(FEATURE_MICROPHONE); 97 98 @Before setup()99 public void setup() { 100 getInstrumentation().getUiAutomation().adoptShellPermissionIdentity(); 101 mRealManager = sContext.getSystemService(SoundTriggerManager.class); 102 mInstrumentationObserver.attachInstrumentation(); 103 mManager = mRealManager.createManagerForTestModule(); 104 getInstrumentation().getUiAutomation().dropShellPermissionIdentity(); 105 } 106 107 @After tearDown()108 public void tearDown() { 109 getInstrumentation().getUiAutomation().adoptShellPermissionIdentity(); 110 try { 111 if (mIsModelLoaded) { 112 mManager.deleteModel(MODEL_UUID); 113 } 114 } catch (Exception e) { 115 } 116 if (mHandler != null) { 117 mHandler.getLooper().quit(); 118 } 119 120 // Clean up any unexpected HAL state 121 // Wait for stray callbacks and to disambiguate the logs 122 SystemClock.sleep(50); 123 try { 124 mInstrumentationObserver.close(); 125 } catch (Exception e) { 126 throw new RuntimeException(e); 127 } 128 // Wait for the mock HAL to reboot 129 SystemClock.sleep(100); 130 getInstrumentation().getUiAutomation().dropShellPermissionIdentity(); 131 } 132 waitForFutureDoneAndAssertSuccessful(Future<V> future)133 public static <V> V waitForFutureDoneAndAssertSuccessful(Future<V> future) { 134 try { 135 return future.get(TIMEOUT, TimeUnit.MILLISECONDS); 136 } catch (InterruptedException | ExecutionException | TimeoutException e) { 137 throw new AssertionError("future failed to complete", e); 138 } 139 } 140 loadModelForRecognition()141 private void loadModelForRecognition() { 142 mManager.updateModel(sModel); 143 assertThat(mManager.loadSoundModel(sModel.getSoundModel())).isEqualTo(0); 144 mIsModelLoaded = true; 145 } 146 listenForDetection()147 private ListenableFuture<Void> listenForDetection() { 148 synchronized (mDetectedLock) { 149 if (mDetectedFuture != null) { 150 assertThat(mDetectedFuture.isDone()).isTrue(); 151 assertThat(mDroppedCallback).isFalse(); 152 } 153 mDetectedFuture = SettableFuture.create(); 154 Log.d(TAG, "Begin listen for detection" + mDetectedFuture); 155 return mDetectedFuture; 156 } 157 } 158 setUpDetector()159 private void setUpDetector() { 160 var thread = new HandlerThread("SoundTriggerDetectorHandler"); 161 thread.start(); 162 mHandler = new Handler(thread.getLooper()); 163 mDetector = 164 mManager.createSoundTriggerDetector( 165 MODEL_UUID, 166 new Callback() { 167 @Override 168 public void onAvailabilityChanged(int status) {} 169 170 @Override 171 public void onDetected(EventPayload eventPayload) { 172 synchronized (mDetectedLock) { 173 mDroppedCallback = !mDetectedFuture.set(null); 174 if (mDroppedCallback) { 175 Log.e(TAG, "Dropped detection" + mDetectedFuture); 176 } else { 177 Log.d(TAG, "Detection" + mDetectedFuture); 178 } 179 } 180 } 181 182 @Override 183 public void onError() {} 184 185 @Override 186 public void onRecognitionPaused() {} 187 188 @Override 189 public void onRecognitionResumed() {} 190 }, 191 mHandler); 192 } 193 getSoundTriggerPermissions()194 private void getSoundTriggerPermissions() { 195 getInstrumentation() 196 .getUiAutomation() 197 .adoptShellPermissionIdentity( 198 RECORD_AUDIO, CAPTURE_AUDIO_HOTWORD, MANAGE_SOUND_TRIGGER); 199 } 200 201 @Test testStartRecognitionFails_whenMissingRecordPermission()202 public void testStartRecognitionFails_whenMissingRecordPermission() { 203 getSoundTriggerPermissions(); 204 loadModelForRecognition(); 205 getInstrumentation() 206 .getUiAutomation() 207 .adoptShellPermissionIdentity(CAPTURE_AUDIO_HOTWORD, MANAGE_SOUND_TRIGGER); 208 setUpDetector(); 209 assertThat(mDetector.startRecognition(0)).isFalse(); 210 } 211 212 @Test testStartRecognitionFails_whenMissingHotwordPermission()213 public void testStartRecognitionFails_whenMissingHotwordPermission() { 214 getSoundTriggerPermissions(); 215 loadModelForRecognition(); 216 getInstrumentation() 217 .getUiAutomation() 218 .adoptShellPermissionIdentity(RECORD_AUDIO, MANAGE_SOUND_TRIGGER); 219 setUpDetector(); 220 assertThat(mDetector.startRecognition(0)).isFalse(); 221 } 222 223 @Test testStartRecognitionSucceeds_whenHoldingPermissions()224 public void testStartRecognitionSucceeds_whenHoldingPermissions() throws Exception { 225 getSoundTriggerPermissions(); 226 var detectedFuture = listenForDetection(); 227 loadModelForRecognition(); 228 setUpDetector(); 229 assertThat(mDetector.startRecognition(0)).isTrue(); 230 231 RecognitionSession recognitionSession = waitForFutureDoneAndAssertSuccessful( 232 mInstrumentationObserver.getOnRecognitionStartedFuture()); 233 assertThat(recognitionSession).isNotNull(); 234 235 recognitionSession.triggerRecognitionEvent(new byte[] {0x11}, null); 236 waitForFutureDoneAndAssertSuccessful(detectedFuture); 237 } 238 239 @Test testRecognitionEvent_notesAppOps()240 public void testRecognitionEvent_notesAppOps() throws Exception { 241 getSoundTriggerPermissions(); 242 243 loadModelForRecognition(); 244 setUpDetector(); 245 assertThat(mDetector.startRecognition(0)).isTrue(); 246 RecognitionSession recognitionSession = waitForFutureDoneAndAssertSuccessful( 247 mInstrumentationObserver.getOnRecognitionStartedFuture()); 248 249 assertThat(recognitionSession).isNotNull(); 250 251 final SettableFuture<Void> ambientOpFuture = SettableFuture.create(); 252 var detectedFuture = listenForDetection(); 253 254 AppOpsManager appOpsManager = 255 sContext.getSystemService(AppOpsManager.class); 256 final String[] OPS_TO_WATCH = 257 new String[] { 258 AppOpsManager.OPSTR_RECEIVE_AMBIENT_TRIGGER_AUDIO 259 }; 260 261 runWithShellPermissionIdentity(() -> 262 appOpsManager.startWatchingNoted( 263 OPS_TO_WATCH, 264 (op, uid, pkgName, attributionTag, flags, result) -> { 265 if (Objects.equals( 266 op, AppOpsManager.OPSTR_RECEIVE_AMBIENT_TRIGGER_AUDIO)) { 267 ambientOpFuture.set(null); 268 } 269 })); 270 271 // Grab permissions again since we transitioned out of shell identity 272 getSoundTriggerPermissions(); 273 274 recognitionSession.triggerRecognitionEvent(new byte[] {0x11}, null); 275 waitForFutureDoneAndAssertSuccessful(ambientOpFuture); 276 waitForFutureDoneAndAssertSuccessful(detectedFuture); 277 } 278 279 @Test testAttachInvalidSession_whenNoDspAvailable()280 public void testAttachInvalidSession_whenNoDspAvailable() { 281 getSoundTriggerPermissions(); 282 if (mManager.listModuleProperties().size() == 1) { 283 assertThrows(IllegalStateException.class, 284 () -> mRealManager.loadSoundModel(sModel.getSoundModel())); 285 } 286 } 287 288 @Test testNullModuleProperties_whenNoDspAvailable()289 public void testNullModuleProperties_whenNoDspAvailable() { 290 getSoundTriggerPermissions(); 291 if (mManager.listModuleProperties().size() == 1) { 292 assertThat(mRealManager.getModuleProperties()).isNull(); 293 } 294 } 295 296 @Test testAttachThrows_whenMissingRecordPermission()297 public void testAttachThrows_whenMissingRecordPermission() { 298 getInstrumentation() 299 .getUiAutomation() 300 .adoptShellPermissionIdentity(CAPTURE_AUDIO_HOTWORD, MANAGE_SOUND_TRIGGER); 301 assertThrows( 302 SecurityException.class, 303 () -> mRealManager.createManagerForTestModule()); 304 } 305 306 @Test testAttachThrows_whenMissingCaptureHotwordPermission()307 public void testAttachThrows_whenMissingCaptureHotwordPermission() { 308 getInstrumentation() 309 .getUiAutomation() 310 .adoptShellPermissionIdentity(RECORD_AUDIO, MANAGE_SOUND_TRIGGER); 311 assertThrows( 312 SecurityException.class, 313 () -> mRealManager.createManagerForTestModule()); 314 } 315 316 // This test is inherently flaky, since the raciness it tests isn't totally solved. 317 @FlakyTest 318 @Test testStartTriggerStopRecognitionRace_doesNotFail()319 public void testStartTriggerStopRecognitionRace_doesNotFail() throws Exception { 320 // Disable this test for now since it is flaky 321 assumeTrue(false); 322 final int ITERATIONS = 20; 323 getSoundTriggerPermissions(); 324 final ListenableFuture<ModelSessionObserver> modelSessionFuture = 325 mInstrumentationObserver.getGlobalCallbackObserver().getOnModelLoadedFuture(); 326 327 loadModelForRecognition(); 328 setUpDetector(); 329 assertThat(mDetector.startRecognition(0)).isTrue(); 330 final ModelSessionObserver modelSessionObserver = waitForFutureDoneAndAssertSuccessful( 331 modelSessionFuture); 332 333 for (int i = 0; i < ITERATIONS; i++) { 334 var detectedFuture = listenForDetection(); 335 RecognitionSession recognitionSession = waitForFutureDoneAndAssertSuccessful( 336 modelSessionObserver.getOnRecognitionStartedFuture()); 337 assertThat(recognitionSession).isNotNull(); 338 modelSessionObserver.resetOnRecognitionStartedFuture(); 339 // Attempt to interleave a stopRecognition + startRecognition and an upward 340 // recognition event 341 recognitionSession.triggerRecognitionEvent(new byte[] {0x11}, null); 342 mDetector.stopRecognition(); 343 // Due to limitations in the STHAL API, we are still vulnerable to raciness, and 344 // we could receive the recognition event for this startRecognition 345 assertThat(mDetector.startRecognition(0)).isTrue(); 346 // Get the new recognition session 347 recognitionSession = waitForFutureDoneAndAssertSuccessful( 348 modelSessionObserver.getOnRecognitionStartedFuture()); 349 assertThat(recognitionSession).isNotNull(); 350 // Wait a bit to receive a recognition event which we *may* have gotten 351 SystemClock.sleep(50); 352 if (detectedFuture.isDone()) { 353 detectedFuture = listenForDetection(); 354 } 355 // Check that the validation layer doesn't think that the new recognition session is 356 // stopped 357 recognitionSession.triggerRecognitionEvent(new byte[] {0x11}, null); 358 waitForFutureDoneAndAssertSuccessful(detectedFuture); 359 modelSessionObserver.resetOnRecognitionStartedFuture(); 360 assertThat(mDetector.startRecognition(0)).isTrue(); 361 } 362 } 363 } 364