blob: 829c9ca7600afe482721a35a13a54a8a71f7a8d8 [file] [log] [blame]
/*
* 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.Nullable;
import android.hardware.soundtrigger3.ISoundTriggerHw;
import android.hardware.soundtrigger3.ISoundTriggerHwCallback;
import android.hardware.soundtrigger3.ISoundTriggerHwGlobalCallback;
import android.media.soundtrigger.ModelParameter;
import android.media.soundtrigger.ModelParameterRange;
import android.media.soundtrigger.PhraseRecognitionEvent;
import android.media.soundtrigger.PhraseRecognitionExtra;
import android.media.soundtrigger.PhraseSoundModel;
import android.media.soundtrigger.Properties;
import android.media.soundtrigger.RecognitionConfig;
import android.media.soundtrigger.RecognitionEvent;
import android.media.soundtrigger.RecognitionMode;
import android.media.soundtrigger.RecognitionStatus;
import android.media.soundtrigger.SoundModel;
import android.media.soundtrigger.SoundModelType;
import android.media.soundtrigger.Status;
import android.media.soundtrigger_middleware.IAcknowledgeEvent;
import android.media.soundtrigger_middleware.IInjectGlobalEvent;
import android.media.soundtrigger_middleware.IInjectModelEvent;
import android.media.soundtrigger_middleware.IInjectRecognitionEvent;
import android.media.soundtrigger_middleware.ISoundTriggerInjection;
import android.os.DeadObjectException;
import android.os.IBinder;
import android.os.Parcel;
import android.os.RemoteException;
import android.os.ServiceSpecificException;
import android.util.Slog;
import com.android.internal.annotations.GuardedBy;
import com.android.internal.util.FunctionalUtils;
import java.util.HashMap;
import java.util.Map;
import java.util.NoSuchElementException;
import java.util.concurrent.Executor;
import java.util.concurrent.Executors;
/**
* Fake HAL implementation, which offers injection via
* {@link ISoundTriggerInjection}.
* Since this is a test interface, upon unexpected operations from the framework,
* we will abort.
*/
public class FakeSoundTriggerHal extends ISoundTriggerHw.Stub {
private static final String TAG = "FakeSoundTriggerHal";
// Fake values for valid model param range
private static final int THRESHOLD_MIN = -10;
private static final int THRESHOLD_MAX = 10;
// Logically const
private final Object mLock = new Object();
private final Properties mProperties;
// These cannot be injected, since we rely on:
// 1) Serialization
// 2) Running in a different thread
// And there is no Executor interface with these requirements
// These factories clean up the pools on finalizer.
// Package private so the FakeHalFactory can dispatch
static class ExecutorHolder {
static final Executor CALLBACK_EXECUTOR =
Executors.newSingleThreadExecutor();
static final Executor INJECTION_EXECUTOR =
Executors.newSingleThreadExecutor();
}
// Dispatcher interface for callbacks, using the executors above
private final InjectionDispatcher mInjectionDispatcher;
// Created on construction, passed back to clients.
private final IInjectGlobalEvent.Stub mGlobalEventSession;
@GuardedBy("mLock")
private IBinder.DeathRecipient mDeathRecipient;
@GuardedBy("mLock")
private GlobalCallbackDispatcher mGlobalCallbackDispatcher = null;
@GuardedBy("mLock")
private boolean mIsResourceContended = false;
@GuardedBy("mLock")
private final Map<Integer, ModelSession> mModelSessionMap = new HashMap<>();
// Current version of the STHAL relies on integer model session ids.
// Generate them monotonically starting at 101
@GuardedBy("mLock")
private int mModelKeyCounter = 101;
@GuardedBy("mLock")
private boolean mIsDead = false;
private class ModelSession extends IInjectModelEvent.Stub {
// Logically const
private final boolean mIsKeyphrase;
private final CallbackDispatcher mCallbackDispatcher;
private final int mModelHandle;
// Model parameter
@GuardedBy("FakeSoundTriggerHal.this.mLock")
private int mThreshold = 0;
// Mutable
@GuardedBy("FakeSoundTriggerHal.this.mLock")
private boolean mIsUnloaded = false; // Latch
// Only a single recognition session is able to be active for a model
// session at any given time. Null if no recognition is active.
@GuardedBy("FakeSoundTriggerHal.this.mLock")
@Nullable private RecognitionSession mRecognitionSession;
private ModelSession(int modelHandle, CallbackDispatcher callbackDispatcher,
boolean isKeyphrase) {
mModelHandle = modelHandle;
mCallbackDispatcher = callbackDispatcher;
mIsKeyphrase = isKeyphrase;
}
private RecognitionSession startRecognitionForModel() {
synchronized (FakeSoundTriggerHal.this.mLock) {
mRecognitionSession = new RecognitionSession();
return mRecognitionSession;
}
}
private RecognitionSession stopRecognitionForModel() {
synchronized (FakeSoundTriggerHal.this.mLock) {
RecognitionSession session = mRecognitionSession;
mRecognitionSession = null;
return session;
}
}
private void forceRecognitionForModel() {
synchronized (FakeSoundTriggerHal.this.mLock) {
if (mIsKeyphrase) {
PhraseRecognitionEvent phraseEvent =
createDefaultKeyphraseEvent(RecognitionStatus.FORCED);
mCallbackDispatcher.wrap((ISoundTriggerHwCallback cb) ->
cb.phraseRecognitionCallback(mModelHandle, phraseEvent));
} else {
RecognitionEvent event = createDefaultEvent(RecognitionStatus.FORCED);
mCallbackDispatcher.wrap((ISoundTriggerHwCallback cb) ->
cb.recognitionCallback(mModelHandle, event));
}
}
}
private void setThresholdFactor(int value) {
synchronized (FakeSoundTriggerHal.this.mLock) {
mThreshold = value;
}
}
private int getThresholdFactor() {
synchronized (FakeSoundTriggerHal.this.mLock) {
return mThreshold;
}
}
private boolean getIsUnloaded() {
synchronized (FakeSoundTriggerHal.this.mLock) {
return mIsUnloaded;
}
}
private RecognitionSession getRecogSession() {
synchronized (FakeSoundTriggerHal.this.mLock) {
return mRecognitionSession;
}
}
/** oneway **/
@Override
public void triggerUnloadModel() {
synchronized (FakeSoundTriggerHal.this.mLock) {
if (mIsDead || mIsUnloaded) return;
if (mRecognitionSession != null) {
// Must abort model before triggering unload
mRecognitionSession.triggerAbortRecognition();
}
// Invalidate the model session
mIsUnloaded = true;
mCallbackDispatcher.wrap((ISoundTriggerHwCallback cb) ->
cb.modelUnloaded(mModelHandle));
// Don't notify the injection that an unload has occurred, since it is what
// triggered the unload
// Notify if we could have denied a previous model due to contention
if (getNumLoadedModelsLocked() == (mProperties.maxSoundModels - 1)
&& !mIsResourceContended) {
mGlobalCallbackDispatcher.wrap((ISoundTriggerHwGlobalCallback cb) ->
cb.onResourcesAvailable());
}
}
}
private class RecognitionSession extends IInjectRecognitionEvent.Stub {
@Override
/** oneway **/
public void triggerRecognitionEvent(byte[] data,
@Nullable PhraseRecognitionExtra[] phraseExtras) {
synchronized (FakeSoundTriggerHal.this.mLock) {
// Check if our session has already been invalidated
if (mIsDead || mRecognitionSession != this) return;
// Invalidate the recognition session
mRecognitionSession = null;
// Trigger the callback.
if (mIsKeyphrase) {
PhraseRecognitionEvent phraseEvent =
createDefaultKeyphraseEvent(RecognitionStatus.SUCCESS);
phraseEvent.common.data = data;
if (phraseExtras != null) phraseEvent.phraseExtras = phraseExtras;
mCallbackDispatcher.wrap((ISoundTriggerHwCallback cb) ->
cb.phraseRecognitionCallback(mModelHandle, phraseEvent));
} else {
RecognitionEvent event = createDefaultEvent(RecognitionStatus.SUCCESS);
event.data = data;
mCallbackDispatcher.wrap((ISoundTriggerHwCallback cb) ->
cb.recognitionCallback(mModelHandle, event));
}
}
}
@Override
/** oneway **/
public void triggerAbortRecognition() {
synchronized (FakeSoundTriggerHal.this.mLock) {
if (mIsDead || mRecognitionSession != this) return;
// Clear the session state
mRecognitionSession = null;
// Trigger the callback.
if (mIsKeyphrase) {
mCallbackDispatcher.wrap((ISoundTriggerHwCallback cb) ->
cb.phraseRecognitionCallback(mModelHandle,
createDefaultKeyphraseEvent(RecognitionStatus.ABORTED)));
} else {
mCallbackDispatcher.wrap((ISoundTriggerHwCallback cb) ->
cb.recognitionCallback(mModelHandle,
createDefaultEvent(RecognitionStatus.ABORTED)));
}
}
}
}
}
// Since this is always constructed, it needs to be cheap to create.
public FakeSoundTriggerHal(ISoundTriggerInjection injection) {
mProperties = createDefaultProperties();
mInjectionDispatcher = new InjectionDispatcher(injection);
mGlobalCallbackDispatcher = null; // If this NPEs before registration, we want to abort.
// Implement the IInjectGlobalEvent IInterface.
// Since we can't extend multiple IInterface from the same object, instantiate an instance
// for our clients.
mGlobalEventSession = new IInjectGlobalEvent.Stub() {
/**
* Simulate a HAL process restart. This method is not included in regular HAL interface,
* since the entire process is restarted by sending a signal.
* Since we run in-proc, we must offer an explicit restart method.
* oneway
*/
@Override
public void triggerRestart() {
synchronized (FakeSoundTriggerHal.this.mLock) {
if (mIsDead) return;
mIsDead = true;
mInjectionDispatcher.wrap((ISoundTriggerInjection cb) ->
cb.onRestarted(this));
mModelSessionMap.clear();
if (mDeathRecipient != null) {
final DeathRecipient deathRecipient = mDeathRecipient;
ExecutorHolder.CALLBACK_EXECUTOR.execute(() -> {
try {
deathRecipient.binderDied(FakeSoundTriggerHal.this.asBinder());
} catch (Throwable e) {
// We don't expect RemoteException at the moment since we run
// in the same process
Slog.wtf(TAG, "Callback dispatch threw", e);
}
});
}
}
}
// oneway
@Override
public void setResourceContention(boolean isResourcesContended,
IAcknowledgeEvent callback) {
synchronized (FakeSoundTriggerHal.this.mLock) {
// oneway, so don't throw on death
if (mIsDead) {
return;
}
boolean oldIsResourcesContended = mIsResourceContended;
mIsResourceContended = isResourcesContended;
// Introducing contention is the only injection which can't be
// observed by the ST client.
mInjectionDispatcher.wrap((ISoundTriggerInjection unused) ->
callback.eventReceived());
if (!mIsResourceContended && oldIsResourcesContended) {
mGlobalCallbackDispatcher.wrap((ISoundTriggerHwGlobalCallback cb) ->
cb.onResourcesAvailable());
}
}
}
// oneway
@Override
public void triggerOnResourcesAvailable() {
synchronized (FakeSoundTriggerHal.this.mLock) {
// oneway, so don't throw on death
if (mIsDead) return;
mGlobalCallbackDispatcher.wrap((ISoundTriggerHwGlobalCallback cb) ->
cb.onResourcesAvailable());
}
}
};
// Register the global event injection interface
mInjectionDispatcher.wrap((ISoundTriggerInjection cb)
-> cb.registerGlobalEventInjection(mGlobalEventSession));
}
/**
* Get the {@link IInjectGlobalEvent} associated with this instance of the STHAL.
* Used as a session token, valid until restarted.
*/
public IInjectGlobalEvent getGlobalEventInjection() {
return mGlobalEventSession;
}
// TODO(b/274467228) we can remove the next three methods when this HAL is moved out-of-proc,
// so process restart at death notification is appropriately handled by the binder.
@Override
public void linkToDeath(IBinder.DeathRecipient recipient, int flags) {
synchronized (mLock) {
if (mDeathRecipient != null) {
Slog.wtf(TAG, "Received two death recipients concurrently");
}
mDeathRecipient = recipient;
}
}
@Override
public boolean unlinkToDeath(IBinder.DeathRecipient recipient, int flags) {
synchronized (mLock) {
if (mIsDead) return false;
if (mDeathRecipient != recipient) {
throw new NoSuchElementException();
}
mDeathRecipient = null;
return true;
}
}
// STHAL method overrides to follow
@Override
public Properties getProperties() throws RemoteException {
synchronized (mLock) {
if (mIsDead) throw new DeadObjectException();
Parcel parcel = Parcel.obtain();
try {
mProperties.writeToParcel(parcel, 0 /* flags */);
parcel.setDataPosition(0);
return Properties.CREATOR.createFromParcel(parcel);
} finally {
parcel.recycle();
}
}
}
@Override
public void registerGlobalCallback(
ISoundTriggerHwGlobalCallback callback) throws RemoteException {
synchronized (mLock) {
if (mIsDead) throw new DeadObjectException();
mGlobalCallbackDispatcher = new GlobalCallbackDispatcher(callback);
}
}
@Override
public int loadSoundModel(SoundModel soundModel,
ISoundTriggerHwCallback callback) throws RemoteException {
synchronized (mLock) {
if (mIsDead) throw new DeadObjectException();
if (mIsResourceContended || getNumLoadedModelsLocked() == mProperties.maxSoundModels) {
throw new ServiceSpecificException(Status.RESOURCE_CONTENTION);
}
int key = mModelKeyCounter++;
ModelSession session = new ModelSession(key, new CallbackDispatcher(callback), false);
mModelSessionMap.put(key, session);
mInjectionDispatcher.wrap((ISoundTriggerInjection cb) ->
cb.onSoundModelLoaded(soundModel, null, session, mGlobalEventSession));
return key;
}
}
@Override
public int loadPhraseSoundModel(PhraseSoundModel soundModel,
ISoundTriggerHwCallback callback) throws RemoteException {
synchronized (mLock) {
if (mIsDead) throw new DeadObjectException();
if (mIsResourceContended || getNumLoadedModelsLocked() == mProperties.maxSoundModels) {
throw new ServiceSpecificException(Status.RESOURCE_CONTENTION);
}
int key = mModelKeyCounter++;
ModelSession session = new ModelSession(key, new CallbackDispatcher(callback), true);
mModelSessionMap.put(key, session);
mInjectionDispatcher.wrap((ISoundTriggerInjection cb) ->
cb.onSoundModelLoaded(soundModel.common, soundModel.phrases, session,
mGlobalEventSession));
return key;
}
}
@Override
public void unloadSoundModel(int modelHandle) throws RemoteException {
synchronized (mLock) {
if (mIsDead) throw new DeadObjectException();
ModelSession session = mModelSessionMap.get(modelHandle);
if (session == null) {
Slog.wtf(TAG, "Attempted to unload model which was never loaded");
}
if (session.getRecogSession() != null) {
Slog.wtf(TAG, "Session unloaded before recog stopped!");
}
// Session is stale
if (session.getIsUnloaded()) return;
mInjectionDispatcher.wrap((ISoundTriggerInjection cb) ->
cb.onSoundModelUnloaded(session));
// Notify if we could have denied a previous model due to contention
if (getNumLoadedModelsLocked() == (mProperties.maxSoundModels - 1)
&& !mIsResourceContended) {
mGlobalCallbackDispatcher.wrap((ISoundTriggerHwGlobalCallback cb) ->
cb.onResourcesAvailable());
}
}
}
@Override
public void startRecognition(int modelHandle, int deviceHandle, int ioHandle,
RecognitionConfig config) throws RemoteException {
synchronized (mLock) {
if (mIsDead) throw new DeadObjectException();
ModelSession session = mModelSessionMap.get(modelHandle);
if (session == null) {
Slog.wtf(TAG, "Attempted to start recognition with invalid handle");
}
if (mIsResourceContended) {
throw new ServiceSpecificException(Status.RESOURCE_CONTENTION);
}
if (session.getIsUnloaded()) {
// TODO(b/274470274) this is a deficiency in the existing HAL API, there is no way
// to handle this race gracefully
throw new ServiceSpecificException(Status.RESOURCE_CONTENTION);
}
ModelSession.RecognitionSession recogSession = session.startRecognitionForModel();
// TODO(b/274470571) appropriately translate ioHandle to session handle
mInjectionDispatcher.wrap((ISoundTriggerInjection cb) ->
cb.onRecognitionStarted(-1, config, recogSession, session));
}
}
@Override
public void stopRecognition(int modelHandle) throws RemoteException {
synchronized (mLock) {
if (mIsDead) throw new DeadObjectException();
ModelSession session = mModelSessionMap.get(modelHandle);
if (session == null) {
Slog.wtf(TAG, "Attempted to stop recognition with invalid handle");
}
ModelSession.RecognitionSession recogSession = session.stopRecognitionForModel();
if (recogSession != null) {
mInjectionDispatcher.wrap((ISoundTriggerInjection cb) ->
cb.onRecognitionStopped(recogSession));
}
}
}
@Override
public void forceRecognitionEvent(int modelHandle) throws RemoteException {
synchronized (mLock) {
if (mIsDead) throw new DeadObjectException();
ModelSession session = mModelSessionMap.get(modelHandle);
if (session == null) {
Slog.wtf(TAG, "Attempted to force recognition with invalid handle");
}
// TODO(b/274470274) this is a deficiency in the existing HAL API, we could always
// get a force request for an already stopped model. The only thing to do is
// drop such a request.
if (session.getRecogSession() == null) return;
session.forceRecognitionForModel();
}
}
// TODO(b/274470274) this is a deficiency in the existing HAL API, we could always
// get model param API requests after model unload.
// For now, succeed anyway to maintain fidelity to existing HALs.
@Override
public @Nullable ModelParameterRange queryParameter(int modelHandle,
/** ModelParameter **/ int modelParam) throws RemoteException {
synchronized (mLock) {
if (mIsDead) throw new DeadObjectException();
ModelSession session = mModelSessionMap.get(modelHandle);
if (session == null) {
Slog.wtf(TAG, "Attempted to get param with invalid handle");
}
}
if (modelParam == ModelParameter.THRESHOLD_FACTOR) {
ModelParameterRange range = new ModelParameterRange();
range.minInclusive = THRESHOLD_MIN;
range.maxInclusive = THRESHOLD_MAX;
return range;
} else {
return null;
}
}
@Override
public int getParameter(int modelHandle,
/** ModelParameter **/ int modelParam) throws RemoteException {
synchronized (mLock) {
if (mIsDead) throw new DeadObjectException();
ModelSession session = mModelSessionMap.get(modelHandle);
if (session == null) {
Slog.wtf(TAG, "Attempted to get param with invalid handle");
}
if (modelParam != ModelParameter.THRESHOLD_FACTOR) {
throw new IllegalArgumentException();
}
return session.getThresholdFactor();
}
}
@Override
public void setParameter(int modelHandle,
/** ModelParameter **/ int modelParam, int value) throws RemoteException {
synchronized (mLock) {
if (mIsDead) throw new DeadObjectException();
ModelSession session = mModelSessionMap.get(modelHandle);
if (session == null) {
Slog.wtf(TAG, "Attempted to get param with invalid handle");
}
if ((modelParam == ModelParameter.THRESHOLD_FACTOR)
|| (value >= THRESHOLD_MIN && value <= THRESHOLD_MAX)) {
session.setThresholdFactor(value);
} else {
throw new IllegalArgumentException();
}
mInjectionDispatcher.wrap((ISoundTriggerInjection cb) ->
cb.onParamSet(modelParam, value, session));
}
}
@Override
public int getInterfaceVersion() throws RemoteException {
synchronized (mLock) {
if (mIsDead) throw new DeadObjectException();
}
return super.VERSION;
}
@Override
public String getInterfaceHash() throws RemoteException {
synchronized (mLock) {
if (mIsDead) throw new DeadObjectException();
}
return super.HASH;
}
// Helpers to follow.
@GuardedBy("mLock")
private int getNumLoadedModelsLocked() {
int numModels = 0;
for (ModelSession session : mModelSessionMap.values()) {
if (!session.getIsUnloaded()) {
numModels++;
}
}
return numModels;
}
private static Properties createDefaultProperties() {
Properties properties = new Properties();
properties.implementor = "android";
properties.description = "AOSP fake STHAL";
properties.version = 1;
properties.uuid = "00000001-0002-0003-0004-deadbeefabcd";
properties.supportedModelArch = ISoundTriggerInjection.FAKE_HAL_ARCH;
properties.maxSoundModels = 8;
properties.maxKeyPhrases = 2;
properties.maxUsers = 2;
properties.recognitionModes = RecognitionMode.VOICE_TRIGGER
| RecognitionMode.GENERIC_TRIGGER;
properties.captureTransition = true;
// This is actually not respected, since there is no real AudioRecord
properties.maxBufferMs = 5000;
properties.concurrentCapture = true;
properties.triggerInEvent = false;
properties.powerConsumptionMw = 0;
properties.audioCapabilities = 0;
return properties;
}
private static RecognitionEvent createDefaultEvent(
/** RecognitionStatus **/ int status) {
RecognitionEvent event = new RecognitionEvent();
// Overwrite the event appropriately.
event.status = status;
event.type = SoundModelType.GENERIC;
// TODO(b/274466981) make this configurable.
// For now, some plausible defaults
event.captureAvailable = true;
event.captureDelayMs = 50;
event.capturePreambleMs = 200;
event.triggerInData = false;
event.audioConfig = null; // Nullable within AIDL
event.data = new byte[0];
// We don't support recognition restart for now
event.recognitionStillActive = false;
return event;
}
private static PhraseRecognitionEvent createDefaultKeyphraseEvent(
/**RecognitionStatus **/ int status) {
RecognitionEvent event = createDefaultEvent(status);
event.type = SoundModelType.KEYPHRASE;
PhraseRecognitionEvent phraseEvent = new PhraseRecognitionEvent();
phraseEvent.common = event;
phraseEvent.phraseExtras = new PhraseRecognitionExtra[0];
return phraseEvent;
}
// Helper classes to dispatch oneway calls to the appropriate callback interfaces to follow.
private static class CallbackDispatcher {
private CallbackDispatcher(ISoundTriggerHwCallback callback) {
mCallback = callback;
}
private void wrap(FunctionalUtils.ThrowingConsumer<ISoundTriggerHwCallback> command) {
ExecutorHolder.CALLBACK_EXECUTOR.execute(() -> {
try {
command.accept(mCallback);
} catch (Throwable e) {
Slog.wtf(TAG, "Callback dispatch threw", e);
}
});
}
private final ISoundTriggerHwCallback mCallback;
}
private static class GlobalCallbackDispatcher {
private GlobalCallbackDispatcher(ISoundTriggerHwGlobalCallback callback) {
mCallback = callback;
}
private void wrap(FunctionalUtils.ThrowingConsumer<ISoundTriggerHwGlobalCallback> command) {
ExecutorHolder.CALLBACK_EXECUTOR.execute(() -> {
try {
command.accept(mCallback);
} catch (Throwable e) {
// We don't expect RemoteException at the moment since we run
// in the same process
Slog.wtf(TAG, "Callback dispatch threw", e);
}
});
}
private final ISoundTriggerHwGlobalCallback mCallback;
}
private static class InjectionDispatcher {
private InjectionDispatcher(ISoundTriggerInjection injection) {
mInjection = injection;
}
private void wrap(FunctionalUtils.ThrowingConsumer<ISoundTriggerInjection> command) {
ExecutorHolder.INJECTION_EXECUTOR.execute(() -> {
try {
command.accept(mInjection);
} catch (Throwable e) {
// We don't expect RemoteException at the moment since we run
// in the same process
Slog.wtf(TAG, "Callback dispatch threw", e);
}
});
}
private final ISoundTriggerInjection mInjection;
}
}