/* * Copyright (C) 2020 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.annotation.Nullable; import android.media.permission.Identity; import android.media.permission.IdentityContext; import android.media.soundtrigger.ModelParameterRange; import android.media.soundtrigger.PhraseSoundModel; import android.media.soundtrigger.Properties; import android.media.soundtrigger.RecognitionConfig; import android.media.soundtrigger.RecognitionStatus; import android.media.soundtrigger.SoundModel; import android.media.soundtrigger.Status; import android.media.soundtrigger_middleware.ISoundTriggerCallback; import android.media.soundtrigger_middleware.ISoundTriggerMiddlewareService; import android.media.soundtrigger_middleware.ISoundTriggerModule; import android.media.soundtrigger_middleware.PhraseRecognitionEventSys; import android.media.soundtrigger_middleware.RecognitionEventSys; import android.media.soundtrigger_middleware.SoundTriggerModuleDescriptor; import android.os.IBinder; import android.os.RemoteException; import android.os.ServiceSpecificException; import android.util.Slog; import android.util.SparseArray; import com.android.internal.util.Preconditions; import java.io.PrintWriter; import java.util.HashMap; import java.util.HashSet; import java.util.Map; import java.util.Objects; import java.util.Set; /** * This is a decorator of an {@link ISoundTriggerMiddlewareService}, which enforces correct usage by * the client, as well as makes sure that exceptions representing a server malfunction get sent to * the client in a consistent manner, which cannot be confused with a client fault. *

* This is intended to extract the non-business logic out of the underlying implementation and thus * make it easier to maintain each one of those separate aspects. A design trade-off is being made * here, in that this class would need to essentially eavesdrop on all the client-server * communication and retain all state known to the client, while the client doesn't necessarily care * about all of it, and while the server has its own representation of this information. However, * in this case, this is a small amount of data, and the benefits in code elegance seem worth it. * There is also some additional cost in employing a simplistic locking mechanism here, but * following the same line of reasoning, the benefits in code simplicity outweigh it. *

* Every public method in this class, overriding an interface method, must follow the following * pattern: *

 * @Override public T method(S arg) {
 *     // Input validation.
 *     ValidationUtil.validateS(arg);
 *     synchronized (this) {
 *         // State validation.
 *         if (...state is not valid for this call...) {
 *             throw new IllegalStateException("State is invalid because...");
 *         }
 *         // From here on, every exception isn't client's fault.
 *         try {
 *             T result = mDelegate.method(arg);
 *             // Update state.;
 *             ...
 *             return result;
 *         } catch (Exception e) {
 *             throw handleException(e);
 *         }
 *     }
 * }
 * 
* Following this patterns ensures a consistent and rigorous handling of all aspects associated * with client-server separation. Notable exceptions are stopRecognition() and unloadModel(), which * follow slightly more complicated rules for synchronization (see README.md for details). *

* Exception handling approach:
* We make sure all client faults (argument and state validation) happen first, and * would throw {@link IllegalArgumentException}/{@link NullPointerException} or {@link * IllegalStateException}, respectively. All those exceptions are treated specially by Binder and * will get sent back to the client.
* Once this is done, any subsequent fault is considered either a recoverable (expected) or * unexpected server fault. Those will be delivered to the client as a * {@link ServiceSpecificException}. {@link RecoverableException}s thrown by the implementation are * considered recoverable and will include a specific error code to indicate the problem. Any other * exceptions will use the INTERNAL_ERROR code. They may also cause the module to become invalid * asynchronously, and the client would be notified via the moduleDied() callback. * * {@hide} */ public class SoundTriggerMiddlewareValidation implements ISoundTriggerMiddlewareInternal, Dumpable { private static final String TAG = "SoundTriggerMiddlewareValidation"; private enum ModuleStatus { ALIVE, DETACHED, DEAD } private class ModuleState { public @NonNull Properties properties; public Set sessions = new HashSet<>(); private ModuleState(@NonNull Properties properties) { this.properties = properties; } } private final @NonNull ISoundTriggerMiddlewareInternal mDelegate; private Map mModules; public SoundTriggerMiddlewareValidation(@NonNull ISoundTriggerMiddlewareInternal delegate) { mDelegate = delegate; } /** * Generic exception handling for exceptions thrown by the underlying implementation. * * Would throw any {@link RecoverableException} as a {@link ServiceSpecificException} (passed * by Binder to the caller) and any other exception as a {@link ServiceSpecificException} * with a {@link Status#INTERNAL_ERROR} code. *

* Typical usage: *

     * try {
     *     ... Do server operations ...
     * } catch (Exception e) {
     *     throw handleException(e);
     * }
     * 
*/ static @NonNull RuntimeException handleException(@NonNull Exception e) { if (e instanceof RecoverableException) { throw new ServiceSpecificException(((RecoverableException) e).errorCode, e.getMessage()); } Slog.wtf(TAG, "Unexpected exception", e); throw new ServiceSpecificException(Status.INTERNAL_ERROR, e.getMessage()); } @Override public @NonNull SoundTriggerModuleDescriptor[] listModules() { // Input validation (always valid). synchronized (this) { // State validation (always valid). // From here on, every exception isn't client's fault. try { SoundTriggerModuleDescriptor[] result = mDelegate.listModules(); if (mModules == null) { mModules = new HashMap<>(result.length); for (SoundTriggerModuleDescriptor desc : result) { mModules.put(desc.handle, new ModuleState(desc.properties)); } } else { if (result.length != mModules.size()) { throw new RuntimeException( "listModules must always return the same result."); } for (SoundTriggerModuleDescriptor desc : result) { if (!mModules.containsKey(desc.handle)) { throw new RuntimeException( "listModules must always return the same result."); } mModules.get(desc.handle).properties = desc.properties; } } return result; } catch (Exception e) { throw handleException(e); } } } @Override public @NonNull ISoundTriggerModule attach(int handle, @NonNull ISoundTriggerCallback callback, boolean isTrusted) { // Input validation. Objects.requireNonNull(callback); Objects.requireNonNull(callback.asBinder()); synchronized (this) { // State validation. if (mModules == null) { throw new IllegalStateException( "Client must call listModules() prior to attaching."); } if (!mModules.containsKey(handle)) { throw new IllegalArgumentException("Invalid handle: " + handle); } // From here on, every exception isn't client's fault. try { Session session = new Session(handle, callback); session.attach(mDelegate.attach(handle, session.getCallbackWrapper(), isTrusted)); return session; } catch (Exception e) { throw handleException(e); } } } // Override toString() in order to have the delegate's ID in it. @Override public String toString() { return mDelegate.toString(); } @Override public void dump(PrintWriter pw) { synchronized (this) { if (mModules != null) { for (int handle : mModules.keySet()) { final ModuleState module = mModules.get(handle); pw.println("========================================="); pw.printf("Module %d\n%s\n", handle, ObjectPrinter.print(module.properties, 16)); pw.println("========================================="); for (Session session : module.sessions) { session.dump(pw); } } } else { pw.println("Modules have not yet been enumerated."); } } pw.println(); if (mDelegate instanceof Dumpable) { ((Dumpable) mDelegate).dump(pw); } } /** State of a sound model. */ static class ModelState { ModelState(SoundModel model) { this.description = ObjectPrinter.print(model, 16); } ModelState(PhraseSoundModel model) { this.description = ObjectPrinter.print(model, 16); } /** Activity state of a sound model. */ enum Activity { /** Model is loaded, recognition is inactive. */ LOADED, /** Model is loaded, recognition is active. */ ACTIVE, /** * Model has been preemptively unloaded by the HAL. */ PREEMPTED, } /** Activity state. */ Activity activityState = Activity.LOADED; /** Recognition config, used to start the model. */ RecognitionConfig config; /** Human-readable description of the model. */ final String description; /** * A map of known parameter support. A missing key means we don't know yet whether the * parameter is supported. A null value means it is known to not be supported. A non-null * value indicates the valid value range. */ private final Map parameterSupport = new HashMap<>(); /** * Check that the given parameter is known to be supported for this model. * * @param modelParam The parameter key. */ void checkSupported(int modelParam) { if (!parameterSupport.containsKey(modelParam)) { throw new IllegalStateException("Parameter has not been checked for support."); } ModelParameterRange range = parameterSupport.get(modelParam); if (range == null) { throw new IllegalArgumentException("Paramater is not supported."); } } /** * Check that the given parameter is known to be supported for this model and that the given * value is a valid value for it. * * @param modelParam The parameter key. * @param value The value. */ void checkSupported(int modelParam, int value) { if (!parameterSupport.containsKey(modelParam)) { throw new IllegalStateException("Parameter has not been checked for support."); } ModelParameterRange range = parameterSupport.get(modelParam); if (range == null) { throw new IllegalArgumentException("Paramater is not supported."); } Preconditions.checkArgumentInRange(value, range.minInclusive, range.maxInclusive, "value"); } } /** * A wrapper around an {@link ISoundTriggerModule} implementation, to address the same aspects * mentioned in {@link SoundTriggerModule} above. This class follows the same conventions. */ private class Session extends ISoundTriggerModule.Stub { private ISoundTriggerModule mDelegate; private final @NonNull Map mLoadedModels = new HashMap<>(); private final int mHandle; private ModuleStatus mState = ModuleStatus.ALIVE; private final CallbackWrapper mCallbackWrapper; private final Identity mOriginatorIdentity; Session(int handle, @NonNull ISoundTriggerCallback callback) { mCallbackWrapper = new CallbackWrapper(callback); mHandle = handle; mOriginatorIdentity = IdentityContext.get(); } ISoundTriggerCallback getCallbackWrapper() { return mCallbackWrapper; } void attach(@NonNull ISoundTriggerModule delegate) { mDelegate = delegate; mModules.get(mHandle).sessions.add(this); } @Override public int loadModel(@NonNull SoundModel model) { // Input validation. ValidationUtil.validateGenericModel(model); synchronized (SoundTriggerMiddlewareValidation.this) { // State validation. if (mState == ModuleStatus.DETACHED) { throw new IllegalStateException("Module has been detached."); } // From here on, every exception isn't client's fault. try { int handle = mDelegate.loadModel(model); mLoadedModels.put(handle, new ModelState(model)); return handle; } catch (Exception e) { throw handleException(e); } } } @Override public int loadPhraseModel(@NonNull PhraseSoundModel model) { // Input validation. ValidationUtil.validatePhraseModel(model); synchronized (SoundTriggerMiddlewareValidation.this) { // State validation. if (mState == ModuleStatus.DETACHED) { throw new IllegalStateException("Module has been detached."); } // From here on, every exception isn't client's fault. try { int handle = mDelegate.loadPhraseModel(model); mLoadedModels.put(handle, new ModelState(model)); return handle; } catch (Exception e) { throw handleException(e); } } } @Override public void unloadModel(int modelHandle) { // Input validation (always valid). synchronized (SoundTriggerMiddlewareValidation.this) { // State validation. if (mState == ModuleStatus.DETACHED) { throw new IllegalStateException("Module has been detached."); } ModelState modelState = mLoadedModels.get( modelHandle); if (modelState == null) { throw new IllegalStateException("Invalid handle: " + modelHandle); } // To avoid race conditions, we treat LOADED and PREEMPTED exactly the same. if (modelState.activityState != ModelState.Activity.LOADED && modelState.activityState != ModelState.Activity.PREEMPTED) { throw new IllegalStateException("Model with handle: " + modelHandle + " has invalid state for unloading"); } } // From here on, every exception isn't client's fault. try { // Calling the delegate must be done outside the lock. mDelegate.unloadModel(modelHandle); } catch (Exception e) { throw handleException(e); } synchronized (SoundTriggerMiddlewareValidation.this) { mLoadedModels.remove(modelHandle); } } @Override public IBinder startRecognition(int modelHandle, @NonNull RecognitionConfig config) { // Input validation. ValidationUtil.validateRecognitionConfig(config); synchronized (SoundTriggerMiddlewareValidation.this) { // State validation. if (mState == ModuleStatus.DETACHED) { throw new IllegalStateException("Module has been detached."); } ModelState modelState = mLoadedModels.get( modelHandle); if (modelState == null) { throw new IllegalStateException("Invalid handle: " + modelHandle); } ModelState.Activity activityState = modelState.activityState; // To avoid race conditions, we treat LOADED and PREEMPTED exactly the same. if (activityState != ModelState.Activity.LOADED && activityState != ModelState.Activity.PREEMPTED) { throw new IllegalStateException("Model with handle: " + modelHandle + " has invalid state for starting recognition"); } // From here on, every exception isn't client's fault. try { var result = mDelegate.startRecognition(modelHandle, config); modelState.config = config; modelState.activityState = ModelState.Activity.ACTIVE; return result; } catch (Exception e) { throw handleException(e); } } } @Override public void stopRecognition(int modelHandle) { // Input validation (always valid). synchronized (SoundTriggerMiddlewareValidation.this) { // State validation. if (mState == ModuleStatus.DETACHED) { throw new IllegalStateException("Module has been detached."); } ModelState modelState = mLoadedModels.get( modelHandle); if (modelState == null) { throw new IllegalStateException("Invalid handle: " + modelHandle); } // stopRecognition is idempotent - no need to check model state. } // Calling the delegate's stop must be done without the lock. try { mDelegate.stopRecognition(modelHandle); } catch (Exception e) { throw handleException(e); } synchronized (SoundTriggerMiddlewareValidation.this) { ModelState modelState = mLoadedModels.get(modelHandle); if (modelState == null) { // The model was unloaded while we let go of the lock. return; } // After the call, the state is LOADED, unless it has been first preempted. if (modelState.activityState != ModelState.Activity.PREEMPTED) { modelState.activityState = ModelState.Activity.LOADED; } } } @Override public void forceRecognitionEvent(int modelHandle) { // Input validation (always valid). synchronized (SoundTriggerMiddlewareValidation.this) { // State validation. if (mState == ModuleStatus.DETACHED) { throw new IllegalStateException("Module has been detached."); } ModelState modelState = mLoadedModels.get( modelHandle); if (modelState == null) { throw new IllegalStateException("Invalid handle: " + modelHandle); } // forceRecognitionEvent is idempotent - no need to check model state. // From here on, every exception isn't client's fault. try { // If the activity state is LOADED or INTERCEPTED, we skip delegating the // command, but still consider the call valid. if (modelState.activityState == ModelState.Activity.ACTIVE) { mDelegate.forceRecognitionEvent(modelHandle); } } catch (Exception e) { throw handleException(e); } } } @Override public void setModelParameter(int modelHandle, int modelParam, int value) { // Input validation. ValidationUtil.validateModelParameter(modelParam); synchronized (SoundTriggerMiddlewareValidation.this) { // State validation. if (mState == ModuleStatus.DETACHED) { throw new IllegalStateException("Module has been detached."); } ModelState modelState = mLoadedModels.get( modelHandle); if (modelState == null) { throw new IllegalStateException("Invalid handle: " + modelHandle); } modelState.checkSupported(modelParam, value); // From here on, every exception isn't client's fault. try { mDelegate.setModelParameter(modelHandle, modelParam, value); } catch (Exception e) { throw handleException(e); } } } @Override public int getModelParameter(int modelHandle, int modelParam) { // Input validation. ValidationUtil.validateModelParameter(modelParam); synchronized (SoundTriggerMiddlewareValidation.this) { // State validation. if (mState == ModuleStatus.DETACHED) { throw new IllegalStateException("Module has been detached."); } ModelState modelState = mLoadedModels.get( modelHandle); if (modelState == null) { throw new IllegalStateException("Invalid handle: " + modelHandle); } modelState.checkSupported(modelParam); // From here on, every exception isn't client's fault. try { return mDelegate.getModelParameter(modelHandle, modelParam); } catch (Exception e) { throw handleException(e); } } } @Override @Nullable public ModelParameterRange queryModelParameterSupport(int modelHandle, int modelParam) { // Input validation. ValidationUtil.validateModelParameter(modelParam); synchronized (SoundTriggerMiddlewareValidation.this) { // State validation. if (mState == ModuleStatus.DETACHED) { throw new IllegalStateException("Module has been detached."); } ModelState modelState = mLoadedModels.get( modelHandle); if (modelState == null) { throw new IllegalStateException("Invalid handle: " + modelHandle); } // From here on, every exception isn't client's fault. try { ModelParameterRange result = mDelegate.queryModelParameterSupport(modelHandle, modelParam); modelState.parameterSupport.put(modelParam, result); return result; } catch (Exception e) { throw handleException(e); } } } @Override public void detach() { // Input validation (always valid). synchronized (SoundTriggerMiddlewareValidation.this) { // State validation. if (mState == ModuleStatus.DETACHED) { throw new IllegalStateException("Module has already been detached."); } if (mState == ModuleStatus.ALIVE && !mLoadedModels.isEmpty()) { throw new IllegalStateException("Cannot detach while models are loaded."); } // From here on, every exception isn't client's fault. try { detachInternal(); } catch (Exception e) { throw handleException(e); } } } // Override toString() in order to have the delegate's ID in it. @Override public String toString() { return Objects.toString(mDelegate); } private void detachInternal() { try { mDelegate.detach(); mState = ModuleStatus.DETACHED; mCallbackWrapper.detached(); mModules.get(mHandle).sessions.remove(this); } catch (RemoteException e) { throw e.rethrowAsRuntimeException(); } } void dump(PrintWriter pw) { if (mState == ModuleStatus.ALIVE) { pw.println("-------------------------------"); pw.printf("Session %s, client: %s\n", toString(), ObjectPrinter.print(mOriginatorIdentity, 16)); pw.println("Loaded models (handle, active, description):"); pw.println(); pw.println("-------------------------------"); for (Map.Entry entry : mLoadedModels.entrySet()) { pw.print(entry.getKey()); pw.print('\t'); pw.print(entry.getValue().activityState.name()); pw.print('\t'); pw.print(entry.getValue().description); pw.println(); } pw.println(); } else { pw.printf("Session %s is dead", toString()); pw.println(); } } class CallbackWrapper implements ISoundTriggerCallback, IBinder.DeathRecipient { private final ISoundTriggerCallback mCallback; CallbackWrapper(ISoundTriggerCallback callback) { mCallback = callback; try { mCallback.asBinder().linkToDeath(this, 0); } catch (RemoteException e) { throw e.rethrowAsRuntimeException(); } } void detached() { mCallback.asBinder().unlinkToDeath(this, 0); } @Override public void onRecognition(int modelHandle, @NonNull RecognitionEventSys event, int captureSession) { synchronized (SoundTriggerMiddlewareValidation.this) { ModelState modelState = mLoadedModels.get(modelHandle); if (!event.recognitionEvent.recognitionStillActive) { modelState.activityState = ModelState.Activity.LOADED; } } // Calling the delegate callback must be done outside the lock. try { mCallback.onRecognition(modelHandle, event, captureSession); } catch (Exception e) { Slog.w(TAG, "Client callback exception.", e); } } @Override public void onPhraseRecognition(int modelHandle, @NonNull PhraseRecognitionEventSys event, int captureSession) { synchronized (SoundTriggerMiddlewareValidation.this) { ModelState modelState = mLoadedModels.get(modelHandle); if (!event.phraseRecognitionEvent.common.recognitionStillActive) { modelState.activityState = ModelState.Activity.LOADED; } } // Calling the delegate callback must be done outside the lock. try { mCallback.onPhraseRecognition(modelHandle, event, captureSession); } catch (Exception e) { Slog.w(TAG, "Client callback exception.", e); } } @Override public void onModelUnloaded(int modelHandle) { synchronized (SoundTriggerMiddlewareValidation.this) { ModelState modelState = mLoadedModels.get(modelHandle); modelState.activityState = ModelState.Activity.PREEMPTED; } // Calling the delegate callback must be done outside the lock. try { mCallback.onModelUnloaded(modelHandle); } catch (Exception e) { Slog.w(TAG, "Client callback exception.", e); } } @Override public void onResourcesAvailable() { // Not locking to avoid deadlocks (not affecting any state). try { mCallback.onResourcesAvailable(); } catch (RemoteException e) { // Dead client will be handled by binderDied() - no need to handle here. // In any case, client callbacks are considered best effort. Slog.e(TAG, "Client callback exception.", e); } } @Override public void onModuleDied() { synchronized (SoundTriggerMiddlewareValidation.this) { mState = ModuleStatus.DEAD; } // Trigger the callback outside of the lock to avoid deadlocks. try { mCallback.onModuleDied(); } catch (RemoteException e) { // Dead client will be handled by binderDied() - no need to handle here. // In any case, client callbacks are considered best effort. Slog.e(TAG, "Client callback exception.", e); } } @Override public void binderDied() { // This is called whenever our client process dies. SparseArray cachedMap = new SparseArray(); synchronized (SoundTriggerMiddlewareValidation.this) { // Copy the relevant state under the lock, so we can call back without // holding a lock. This exposes us to a potential race, but the client is // dead so we don't expect one. // TODO(240613068) A more resilient fix for this. for (Map.Entry entry : mLoadedModels.entrySet()) { cachedMap.put(entry.getKey(), entry.getValue().activityState); } } try { // Gracefully stop all active recognitions and unload the models. for (int i = 0; i < cachedMap.size(); i++) { if (cachedMap.valueAt(i) == ModelState.Activity.ACTIVE) { mDelegate.stopRecognition(cachedMap.keyAt(i)); } mDelegate.unloadModel(cachedMap.keyAt(i)); } } catch (Exception e) { throw handleException(e); } synchronized (SoundTriggerMiddlewareValidation.this) { // Check if state updated unexpectedly to log race conditions. for (Map.Entry entry : mLoadedModels.entrySet()) { if (cachedMap.get(entry.getKey()) != entry.getValue().activityState) { Slog.e(TAG, "Unexpected state update in binderDied. Race occurred!"); } } if (mLoadedModels.size() != cachedMap.size()) { Slog.e(TAG, "Unexpected state update in binderDied. Race occurred!"); } try { // Detach detachInternal(); } catch (Exception e) { throw handleException(e); } } } @Override public IBinder asBinder() { return mCallback.asBinder(); } // Override toString() in order to have the delegate's ID in it. @Override public String toString() { return Objects.toString(mDelegate); } } } }