blob: 0b9ed8c20e8e3f363527c1e847d2906cc797e64a [file] [log] [blame]
/*
* 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.
* <p>
* 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.
* <p>
* Every public method in this class, overriding an interface method, must follow the following
* pattern:
* <code><pre>
* @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);
* }
* }
* }
* </pre></code>
* 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).
* <p>
* <b>Exception handling approach:</b><br>
* 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.<br>
* 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<Session> sessions = new HashSet<>();
private ModuleState(@NonNull Properties properties) {
this.properties = properties;
}
}
private final @NonNull ISoundTriggerMiddlewareInternal mDelegate;
private Map<Integer, ModuleState> 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 <i>any other</i> exception as a {@link ServiceSpecificException}
* with a {@link Status#INTERNAL_ERROR} code.
* <p>
* Typical usage:
* <code><pre>
* try {
* ... Do server operations ...
* } catch (Exception e) {
* throw handleException(e);
* }
* </pre></code>
*/
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<Integer, ModelParameterRange> 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<Integer, ModelState> 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<Integer, ModelState> 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<ModelState.Activity> cachedMap =
new SparseArray<ModelState.Activity>();
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<Integer, ModelState> 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<Integer, ModelState> 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);
}
}
}
}