diff options
| author | 2023-04-03 11:29:29 -0700 | |
|---|---|---|
| committer | 2023-04-11 12:12:53 -0700 | |
| commit | 552c327b60752f28e5ee5fcca89af65ffb8a6c5e (patch) | |
| tree | 7eaf18aeb29f38d718ef094188af2a750c644b84 | |
| parent | ca7c149247e029b159b089c5abe5f583bb640227 (diff) | |
Protect STHAL from duplicate UUID model loads
The STHAL aborts when duplicate UUID model loads are received.
To protect against this, we add a handler above both v2, v3 hals,
which rejects duplicate UUID model loads with RESOURCE_CONTENTION.
Fixes: 275058053
Test: atest SoundTriggerDuplicateModelHandlerTest
Test: manual verification of assistant/now playing
Change-Id: I53b249856fba49b3b33d5668660f4beb8611a20c
4 files changed, 543 insertions, 2 deletions
diff --git a/services/tests/voiceinteractiontests/src/com/android/server/soundtrigger_middleware/SoundTriggerDuplicateModelHandlerTest.java b/services/tests/voiceinteractiontests/src/com/android/server/soundtrigger_middleware/SoundTriggerDuplicateModelHandlerTest.java new file mode 100644 index 000000000000..bc94dacea324 --- /dev/null +++ b/services/tests/voiceinteractiontests/src/com/android/server/soundtrigger_middleware/SoundTriggerDuplicateModelHandlerTest.java @@ -0,0 +1,335 @@ +/* + * 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.soundtrigger_middleware; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertThrows; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.any; +import static org.mockito.Mockito.anyInt; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoMoreInteractions; + +import android.media.soundtrigger.ModelParameterRange; +import android.media.soundtrigger.Phrase; +import android.media.soundtrigger.PhraseSoundModel; +import android.media.soundtrigger.Properties; +import android.media.soundtrigger.RecognitionConfig; +import android.media.soundtrigger.RecognitionMode; +import android.media.soundtrigger.SoundModel; +import android.media.soundtrigger.SoundModelType; +import android.media.soundtrigger.Status; +import android.os.IBinder; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +import org.mockito.Mock; +import org.mockito.Mockito; +import org.mockito.InOrder; +import org.mockito.MockitoAnnotations; + +@RunWith(JUnit4.class) +public final class SoundTriggerDuplicateModelHandlerTest { + // Component under test + private SoundTriggerDuplicateModelHandler mComponent; + + private static final String DUPLICATE_UUID = "abcddead-beef-0123-3210-0123456789ab"; + private static final String DIFFERENT_UUID = "0000dead-beef-0123-3210-0123456789ab"; + + @Mock private ISoundTriggerHal mUnderlying; + @Mock private ISoundTriggerHal.GlobalCallback mGlobalCallback; + @Mock private ISoundTriggerHal.ModelCallback mModelCallback; + + @Before + public void setUp() { + MockitoAnnotations.initMocks(this); + mComponent = new SoundTriggerDuplicateModelHandler(mUnderlying); + doNothing().when(mUnderlying).registerCallback(any()); + mComponent.registerCallback(mGlobalCallback); + verify(mUnderlying).registerCallback(eq(mGlobalCallback)); + } + + @Test + public void loadSoundModel_throwsResourceContention_whenDuplicateUuid() { + final var soundModel = createSoundModelOne(); + final var soundModelSameUuid = createSoundModelTwo(); + // First sound model load should complete successfully + mComponent.loadSoundModel(soundModel, mModelCallback); + verify(mUnderlying).loadSoundModel(eq(soundModel), eq(mModelCallback)); + assertEquals( + assertThrows( + RecoverableException.class, + () -> mComponent.loadSoundModel(soundModelSameUuid, mModelCallback)) + .errorCode, + Status.RESOURCE_CONTENTION); + // Model has not been unloaded, so we don't get a callback + verify(mGlobalCallback, never()).onResourcesAvailable(); + verifyNoMoreInteractions(mUnderlying); + verifyNoMoreInteractions(mGlobalCallback); + } + + @Test + public void loadSoundModel_doesNotThrowResourceContention_whenDifferentUuid() { + final var soundModel = createSoundModelOne(); + // Make all other fields the same + final var soundModelDifferentUuid = createSoundModelOne(); + soundModelDifferentUuid.uuid = DIFFERENT_UUID; + InOrder inOrder = Mockito.inOrder(mUnderlying); + // First sound model load should complete successfully + mComponent.loadSoundModel(soundModel, mModelCallback); + inOrder.verify(mUnderlying).loadSoundModel(eq(soundModel), eq(mModelCallback)); + mComponent.loadSoundModel(soundModelDifferentUuid, mModelCallback); + inOrder.verify(mUnderlying).loadSoundModel(eq(soundModelDifferentUuid), eq(mModelCallback)); + // No contention, so we don't get a callback + verify(mGlobalCallback, never()).onResourcesAvailable(); + verifyNoMoreInteractions(mUnderlying); + verifyNoMoreInteractions(mGlobalCallback); + } + + @Test + public void loadSoundModel_doesNotThrow_afterDuplicateUuidHasBeenUnloaded() { + final var soundModel = createSoundModelOne(); + // First sound model load should complete successfully + int handle = mComponent.loadSoundModel(soundModel, mModelCallback); + verify(mUnderlying).loadSoundModel(eq(soundModel), eq(mModelCallback)); + // Unload model should complete successfully + mComponent.unloadSoundModel(handle); + verify(mUnderlying).unloadSoundModel(eq(handle)); + // Since the model with the same UUID was unloaded, the subsequent load model + // should succeed. + mComponent.loadSoundModel(soundModel, mModelCallback); + verify(mUnderlying, times(2)).loadSoundModel(eq(soundModel), eq(mModelCallback)); + verifyNoMoreInteractions(mUnderlying); + verifyNoMoreInteractions(mGlobalCallback); + } + + @Test + public void unloadSoundModel_triggersResourceCallback_afterDuplicateUuidRejected() { + final var soundModel = createSoundModelOne(); + final var soundModelSameUuid = createSoundModelTwo(); + // First sound model load should complete successfully + int handle = mComponent.loadSoundModel(soundModel, mModelCallback); + verify(mUnderlying).loadSoundModel(eq(soundModel), eq(mModelCallback)); + assertEquals( + assertThrows( + RecoverableException.class, + () -> mComponent.loadSoundModel(soundModelSameUuid, mModelCallback)) + .errorCode, + Status.RESOURCE_CONTENTION); + mComponent.unloadSoundModel(handle); + verify(mUnderlying).unloadSoundModel(eq(handle)); + verify(mGlobalCallback).onResourcesAvailable(); + verifyNoMoreInteractions(mUnderlying); + verifyNoMoreInteractions(mGlobalCallback); + } + + // Next tests are same as above, but for phrase sound model. + @Test + public void loadPhraseSoundModel_throwsResourceContention_whenDuplicateUuid() { + final var soundModel = createPhraseSoundModelOne(); + final var soundModelSameUuid = createPhraseSoundModelTwo(); + // First sound model load should complete successfully + mComponent.loadPhraseSoundModel(soundModel, mModelCallback); + verify(mUnderlying).loadPhraseSoundModel(eq(soundModel), eq(mModelCallback)); + assertEquals( + assertThrows( + RecoverableException.class, + () -> + mComponent.loadPhraseSoundModel( + soundModelSameUuid, mModelCallback)) + .errorCode, + Status.RESOURCE_CONTENTION); + // Model has not been unloaded, so we don't get a callback + verify(mGlobalCallback, never()).onResourcesAvailable(); + verifyNoMoreInteractions(mUnderlying); + verifyNoMoreInteractions(mGlobalCallback); + } + + @Test + public void loadPhraseSoundModel_doesNotThrowResourceContention_whenDifferentUuid() { + final var soundModel = createPhraseSoundModelOne(); + // Make all other fields the same + final var soundModelDifferentUuid = createPhraseSoundModelOne(); + soundModelDifferentUuid.common.uuid = DIFFERENT_UUID; + InOrder inOrder = Mockito.inOrder(mUnderlying); + // First sound model load should complete successfully + mComponent.loadPhraseSoundModel(soundModel, mModelCallback); + inOrder.verify(mUnderlying).loadPhraseSoundModel(eq(soundModel), eq(mModelCallback)); + mComponent.loadPhraseSoundModel(soundModelDifferentUuid, mModelCallback); + inOrder.verify(mUnderlying).loadPhraseSoundModel(eq(soundModelDifferentUuid), + eq(mModelCallback)); + // No contention, so we don't get a callback + verify(mGlobalCallback, never()).onResourcesAvailable(); + verifyNoMoreInteractions(mUnderlying); + verifyNoMoreInteractions(mGlobalCallback); + } + + @Test + public void loadPhraseSoundModel_doesNotThrow_afterDuplicateUuidHasBeenUnloaded() { + final var soundModel = createPhraseSoundModelOne(); + // First sound model load should complete successfully + int handle = mComponent.loadPhraseSoundModel(soundModel, mModelCallback); + verify(mUnderlying).loadPhraseSoundModel(eq(soundModel), eq(mModelCallback)); + // Unload model should complete successfully + mComponent.unloadSoundModel(handle); + verify(mUnderlying).unloadSoundModel(eq(handle)); + // Since the model with the same UUID was unloaded, the subsequent load model + // should succeed. + mComponent.loadPhraseSoundModel(soundModel, mModelCallback); + verify(mUnderlying, times(2)).loadPhraseSoundModel(eq(soundModel), eq(mModelCallback)); + verifyNoMoreInteractions(mUnderlying); + verifyNoMoreInteractions(mGlobalCallback); + } + + @Test + public void unloadSoundModel_triggersResourceCallback_afterDuplicateUuidRejectedPhrase() { + final var soundModel = createPhraseSoundModelOne(); + final var soundModelSameUuid = createPhraseSoundModelTwo(); + // First sound model load should complete successfully + int handle = mComponent.loadPhraseSoundModel(soundModel, mModelCallback); + verify(mUnderlying).loadPhraseSoundModel(eq(soundModel), eq(mModelCallback)); + assertEquals( + assertThrows( + RecoverableException.class, + () -> + mComponent.loadPhraseSoundModel( + soundModelSameUuid, mModelCallback)) + .errorCode, + Status.RESOURCE_CONTENTION); + mComponent.unloadSoundModel(handle); + verify(mUnderlying).unloadSoundModel(eq(handle)); + verify(mGlobalCallback).onResourcesAvailable(); + verifyNoMoreInteractions(mUnderlying); + verifyNoMoreInteractions(mGlobalCallback); + } + + @Test + public void testDelegation() { + // Test that the rest of the interface delegates its calls to the underlying object + // appropriately. + // This test method does not test load/unloadSoundModel + var properties = new Properties(); + InOrder inOrder = Mockito.inOrder(mUnderlying); + doReturn(properties).when(mUnderlying).getProperties(); + assertEquals(mComponent.getProperties(), properties); + inOrder.verify(mUnderlying).getProperties(); + var mockGlobalCallback = mock(ISoundTriggerHal.GlobalCallback.class); + mComponent.registerCallback(mockGlobalCallback); + inOrder.verify(mUnderlying).registerCallback(eq(mockGlobalCallback)); + int modelId = 5; + int deviceHandle = 2; + int ioHandle = 3; + var config = mock(RecognitionConfig.class); + mComponent.startRecognition(modelId, deviceHandle, ioHandle, config); + inOrder.verify(mUnderlying) + .startRecognition(eq(modelId), eq(deviceHandle), eq(ioHandle), eq(config)); + + mComponent.stopRecognition(modelId); + inOrder.verify(mUnderlying).stopRecognition(eq(modelId)); + mComponent.forceRecognitionEvent(modelId); + inOrder.verify(mUnderlying).forceRecognitionEvent(eq(modelId)); + int param = 10; + int value = 50; + var modelParamRange = new ModelParameterRange(); + doReturn(modelParamRange).when(mUnderlying).queryParameter(anyInt(), anyInt()); + assertEquals(mComponent.queryParameter(param, value), modelParamRange); + inOrder.verify(mUnderlying).queryParameter(param, value); + doReturn(value).when(mUnderlying).getModelParameter(anyInt(), anyInt()); + assertEquals(mComponent.getModelParameter(modelId, param), value); + inOrder.verify(mUnderlying).getModelParameter(eq(modelId), eq(param)); + mComponent.setModelParameter(modelId, param, value); + inOrder.verify(mUnderlying).setModelParameter(eq(modelId), eq(param), eq(value)); + var recipient = mock(IBinder.DeathRecipient.class); + mComponent.linkToDeath(recipient); + inOrder.verify(mUnderlying).linkToDeath(eq(recipient)); + mComponent.unlinkToDeath(recipient); + inOrder.verify(mUnderlying).unlinkToDeath(eq(recipient)); + mComponent.flushCallbacks(); + inOrder.verify(mUnderlying).flushCallbacks(); + var token = mock(IBinder.class); + mComponent.clientAttached(token); + inOrder.verify(mUnderlying).clientAttached(eq(token)); + mComponent.clientDetached(token); + inOrder.verify(mUnderlying).clientDetached(eq(token)); + mComponent.reboot(); + inOrder.verify(mUnderlying).reboot(); + mComponent.detach(); + inOrder.verify(mUnderlying).detach(); + verifyNoMoreInteractions(mUnderlying); + verifyNoMoreInteractions(mGlobalCallback); + } + + private static SoundModel createSoundModelOne() { + SoundModel model = new SoundModel(); + model.type = SoundModelType.GENERIC; + model.uuid = DUPLICATE_UUID; + model.vendorUuid = "87654321-5432-6543-7654-456789fedcba"; + byte[] data = new byte[] {91, 92, 93, 94, 95}; + model.data = TestUtil.byteArrayToParcelFileDescriptor(data); + model.dataSize = data.length; + return model; + } + + // Different except for the same UUID + private static SoundModel createSoundModelTwo() { + SoundModel model = new SoundModel(); + model.type = SoundModelType.GENERIC; + model.uuid = DUPLICATE_UUID; + model.vendorUuid = "12345678-9876-5432-1012-345678901234"; + byte[] data = new byte[] {19, 18, 17, 16}; + model.data = TestUtil.byteArrayToParcelFileDescriptor(data); + model.dataSize = data.length; + return model; + } + + private static PhraseSoundModel createPhraseSoundModelOne() { + PhraseSoundModel model = new PhraseSoundModel(); + model.common = createSoundModelOne(); + model.common.type = SoundModelType.KEYPHRASE; + model.phrases = new Phrase[1]; + model.phrases[0] = new Phrase(); + model.phrases[0].id = 123; + model.phrases[0].users = new int[] {5, 6, 7}; + model.phrases[0].locale = "locale"; + model.phrases[0].text = "text"; + model.phrases[0].recognitionModes = + RecognitionMode.USER_AUTHENTICATION | RecognitionMode.USER_IDENTIFICATION; + return model; + } + + private static PhraseSoundModel createPhraseSoundModelTwo() { + PhraseSoundModel model = new PhraseSoundModel(); + model.common = createSoundModelTwo(); + model.common.type = SoundModelType.KEYPHRASE; + model.phrases = new Phrase[1]; + model.phrases[0] = new Phrase(); + model.phrases[0].id = 321; + model.phrases[0].users = new int[] {4, 3, 2, 1}; + model.phrases[0].locale = "differentLocale"; + model.phrases[0].text = "differentText"; + model.phrases[0].recognitionModes = 0; + return model; + } +} diff --git a/services/tests/voiceinteractiontests/src/com/android/server/soundtrigger_middleware/TestUtil.java b/services/tests/voiceinteractiontests/src/com/android/server/soundtrigger_middleware/TestUtil.java index 39561f74d7ed..3b7bc952639e 100644 --- a/services/tests/voiceinteractiontests/src/com/android/server/soundtrigger_middleware/TestUtil.java +++ b/services/tests/voiceinteractiontests/src/com/android/server/soundtrigger_middleware/TestUtil.java @@ -466,7 +466,7 @@ class TestUtil { assertEquals(43, event.phraseExtras[0].levels[0].levelPercent); } - private static ParcelFileDescriptor byteArrayToParcelFileDescriptor(byte[] data) { + static ParcelFileDescriptor byteArrayToParcelFileDescriptor(byte[] data) { try (SharedMemory shmem = SharedMemory.create("", data.length)) { ByteBuffer buffer = shmem.mapReadWrite(); buffer.put(data); diff --git a/services/voiceinteraction/java/com/android/server/soundtrigger_middleware/SoundTriggerDuplicateModelHandler.java b/services/voiceinteraction/java/com/android/server/soundtrigger_middleware/SoundTriggerDuplicateModelHandler.java new file mode 100644 index 000000000000..01041932567c --- /dev/null +++ b/services/voiceinteraction/java/com/android/server/soundtrigger_middleware/SoundTriggerDuplicateModelHandler.java @@ -0,0 +1,205 @@ +/* + * Copyright (C) 2023 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.soundtrigger_middleware; + +import android.annotation.NonNull; +import android.media.soundtrigger.ModelParameterRange; +import android.media.soundtrigger.PhraseSoundModel; +import android.media.soundtrigger.Properties; +import android.media.soundtrigger.RecognitionConfig; +import android.media.soundtrigger.SoundModel; +import android.media.soundtrigger.Status; +import android.os.IBinder; + +import java.util.ArrayList; +import java.util.List; + +/** + * This wrapper prevents a models with the same UUID from being loaded concurrently. This is used to + * protect STHAL implementations, which don't support concurrent loads of the same model. We reject + * the duplicate load with {@link Status#RESOURCE_CONTENTION}. + */ +public class SoundTriggerDuplicateModelHandler implements ISoundTriggerHal { + private final @NonNull ISoundTriggerHal mDelegate; + + private GlobalCallback mGlobalCallback; + // There are rarely more than two models loaded. + private final List<ModelData> mModelList = new ArrayList<>(); + + private static final class ModelData { + ModelData(int modelId, String uuid) { + mModelId = modelId; + mUuid = uuid; + } + + int getModelId() { + return mModelId; + } + + String getUuid() { + return mUuid; + } + + boolean getWasContended() { + return mWasContended; + } + + void setWasContended() { + mWasContended = true; + } + + private int mModelId; + private String mUuid; + private boolean mWasContended = false; + } + + public SoundTriggerDuplicateModelHandler(@NonNull ISoundTriggerHal delegate) { + mDelegate = delegate; + } + + @Override + public void reboot() { + mDelegate.reboot(); + } + + @Override + public void detach() { + mDelegate.detach(); + } + + @Override + public Properties getProperties() { + return mDelegate.getProperties(); + } + + @Override + public void registerCallback(GlobalCallback callback) { + mGlobalCallback = callback; + mDelegate.registerCallback(mGlobalCallback); + } + + @Override + public int loadSoundModel(SoundModel soundModel, ModelCallback callback) { + synchronized (this) { + checkDuplicateModelUuid(soundModel.uuid); + var result = mDelegate.loadSoundModel(soundModel, callback); + mModelList.add(new ModelData(result, soundModel.uuid)); + return result; + } + } + + @Override + public int loadPhraseSoundModel(PhraseSoundModel soundModel, ModelCallback callback) { + synchronized (this) { + checkDuplicateModelUuid(soundModel.common.uuid); + var result = mDelegate.loadPhraseSoundModel(soundModel, callback); + mModelList.add(new ModelData(result, soundModel.common.uuid)); + return result; + } + } + + @Override + public void unloadSoundModel(int modelHandle) { + mDelegate.unloadSoundModel(modelHandle); + for (int i = 0; i < mModelList.size(); i++) { + if (mModelList.get(i).getModelId() == modelHandle) { + var modelData = mModelList.remove(i); + if (modelData.getWasContended()) { + mGlobalCallback.onResourcesAvailable(); + } + // Model ID is unique + return; + } + } + } + + // Uninteresting delegation calls to follow. + @Override + public void stopRecognition(int modelHandle) { + mDelegate.stopRecognition(modelHandle); + } + + @Override + public void startRecognition( + int modelHandle, int deviceHandle, int ioHandle, RecognitionConfig config) { + mDelegate.startRecognition(modelHandle, deviceHandle, ioHandle, config); + } + + @Override + public void forceRecognitionEvent(int modelHandle) { + mDelegate.forceRecognitionEvent(modelHandle); + } + + @Override + public int getModelParameter(int modelHandle, int param) { + return mDelegate.getModelParameter(modelHandle, param); + } + + @Override + public void setModelParameter(int modelHandle, int param, int value) { + mDelegate.setModelParameter(modelHandle, param, value); + } + + @Override + public ModelParameterRange queryParameter(int modelHandle, int param) { + return mDelegate.queryParameter(modelHandle, param); + } + + @Override + public void linkToDeath(IBinder.DeathRecipient recipient) { + mDelegate.linkToDeath(recipient); + } + + @Override + public void unlinkToDeath(IBinder.DeathRecipient recipient) { + mDelegate.unlinkToDeath(recipient); + } + + @Override + public String interfaceDescriptor() { + return mDelegate.interfaceDescriptor(); + } + + @Override + public void flushCallbacks() { + mDelegate.flushCallbacks(); + } + + @Override + public void clientAttached(IBinder binder) { + mDelegate.clientAttached(binder); + } + + @Override + public void clientDetached(IBinder binder) { + mDelegate.clientDetached(binder); + } + + /** + * Helper for handling duplicate model. If there is a load attempt for a model with a UUID which + * is already loaded: 1) Reject with {@link Status.RESOURCE_CONTENTION} 2) Mark the already + * loaded model as contended, as we need to dispatch a resource available callback following the + * original model being unloaded. + */ + private void checkDuplicateModelUuid(String uuid) { + var model = mModelList.stream().filter(x -> x.getUuid().equals(uuid)).findFirst(); + if (model.isPresent()) { + model.get().setWasContended(); + throw new RecoverableException(Status.RESOURCE_CONTENTION); + } + } +} diff --git a/services/voiceinteraction/java/com/android/server/soundtrigger_middleware/SoundTriggerModule.java b/services/voiceinteraction/java/com/android/server/soundtrigger_middleware/SoundTriggerModule.java index d2d8f1ad7a71..6223b2e8ace4 100644 --- a/services/voiceinteraction/java/com/android/server/soundtrigger_middleware/SoundTriggerModule.java +++ b/services/voiceinteraction/java/com/android/server/soundtrigger_middleware/SoundTriggerModule.java @@ -170,7 +170,8 @@ class SoundTriggerModule implements IBinder.DeathRecipient, ISoundTriggerHal.Glo */ private void attachToHal() { mHalService = new SoundTriggerHalEnforcer( - new SoundTriggerHalWatchdog(mHalFactory.create())); + new SoundTriggerHalWatchdog( + new SoundTriggerDuplicateModelHandler(mHalFactory.create()))); mHalService.linkToDeath(this); mHalService.registerCallback(this); mProperties = mHalService.getProperties(); |