| /* |
| * Copyright (C) 2014 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; |
| |
| import static android.Manifest.permission.BIND_SOUND_TRIGGER_DETECTION_SERVICE; |
| import static android.Manifest.permission.SOUNDTRIGGER_DELEGATE_IDENTITY; |
| import static android.content.Context.BIND_AUTO_CREATE; |
| import static android.content.Context.BIND_FOREGROUND_SERVICE; |
| import static android.content.Context.BIND_INCLUDE_CAPABILITIES; |
| import static android.content.pm.PackageManager.GET_META_DATA; |
| import static android.content.pm.PackageManager.GET_SERVICES; |
| import static android.content.pm.PackageManager.MATCH_DEBUG_TRIAGED_MISSING; |
| import static android.hardware.soundtrigger.SoundTrigger.STATUS_BAD_VALUE; |
| import static android.hardware.soundtrigger.SoundTrigger.STATUS_DEAD_OBJECT; |
| import static android.hardware.soundtrigger.SoundTrigger.STATUS_ERROR; |
| import static android.hardware.soundtrigger.SoundTrigger.STATUS_OK; |
| import static android.provider.Settings.Global.MAX_SOUND_TRIGGER_DETECTION_SERVICE_OPS_PER_DAY; |
| import static android.provider.Settings.Global.SOUND_TRIGGER_DETECTION_SERVICE_OP_TIMEOUT; |
| |
| import static com.android.server.soundtrigger.SoundTriggerEvent.SessionEvent.Type; |
| import static com.android.server.utils.EventLogger.Event.ALOGW; |
| |
| import static com.android.internal.util.function.pooled.PooledLambda.obtainMessage; |
| import static com.android.server.soundtrigger.DeviceStateHandler.DeviceStateListener; |
| import static com.android.server.soundtrigger.DeviceStateHandler.SoundTriggerDeviceState; |
| |
| import android.Manifest; |
| import android.annotation.NonNull; |
| import android.annotation.Nullable; |
| import android.app.ActivityThread; |
| import android.app.AppOpsManager; |
| import android.content.BroadcastReceiver; |
| import android.content.ComponentName; |
| import android.content.Context; |
| import android.content.Intent; |
| import android.content.IntentFilter; |
| import android.content.PermissionChecker; |
| import android.content.ServiceConnection; |
| import android.content.pm.PackageManager; |
| import android.content.pm.ResolveInfo; |
| import android.hardware.soundtrigger.ConversionUtil; |
| import android.hardware.soundtrigger.IRecognitionStatusCallback; |
| import android.hardware.soundtrigger.ModelParams; |
| import android.hardware.soundtrigger.SoundTrigger; |
| import android.hardware.soundtrigger.SoundTrigger.GenericSoundModel; |
| import android.hardware.soundtrigger.SoundTrigger.KeyphraseSoundModel; |
| import android.hardware.soundtrigger.SoundTrigger.ModelParamRange; |
| import android.hardware.soundtrigger.SoundTrigger.ModuleProperties; |
| import android.hardware.soundtrigger.SoundTrigger.RecognitionConfig; |
| import android.hardware.soundtrigger.SoundTrigger.SoundModel; |
| import android.hardware.soundtrigger.SoundTriggerModule; |
| import android.media.AudioAttributes; |
| import android.media.AudioFormat; |
| import android.media.AudioRecord; |
| import android.media.MediaRecorder; |
| import android.media.permission.ClearCallingIdentityContext; |
| import android.media.permission.Identity; |
| import android.media.permission.IdentityContext; |
| import android.media.permission.PermissionUtil; |
| import android.media.permission.SafeCloseable; |
| import android.media.soundtrigger.ISoundTriggerDetectionService; |
| import android.media.soundtrigger.ISoundTriggerDetectionServiceClient; |
| import android.media.soundtrigger.SoundTriggerDetectionService; |
| import android.media.soundtrigger_middleware.ISoundTriggerInjection; |
| import android.media.soundtrigger_middleware.ISoundTriggerMiddlewareService; |
| import android.os.Binder; |
| import android.os.Bundle; |
| import android.os.Handler; |
| import android.os.IBinder; |
| import android.os.Looper; |
| import android.os.ParcelUuid; |
| import android.os.PowerManager; |
| import android.os.RemoteException; |
| import android.os.ServiceManager; |
| import android.os.ServiceSpecificException; |
| import android.os.SystemClock; |
| import android.os.UserHandle; |
| import android.provider.Settings; |
| import android.telephony.SubscriptionManager; |
| import android.telephony.TelephonyManager; |
| import android.util.ArrayMap; |
| import android.util.ArraySet; |
| import android.util.SparseArray; |
| import android.util.Slog; |
| |
| import com.android.internal.annotations.GuardedBy; |
| import com.android.internal.app.ISoundTriggerService; |
| import com.android.internal.app.ISoundTriggerSession; |
| import com.android.internal.util.DumpUtils; |
| import com.android.server.SoundTriggerInternal; |
| import com.android.server.SystemService; |
| import com.android.server.soundtrigger.SoundTriggerEvent.ServiceEvent; |
| import com.android.server.soundtrigger.SoundTriggerEvent.SessionEvent; |
| import com.android.server.utils.EventLogger.Event; |
| import com.android.server.utils.EventLogger; |
| |
| import java.io.FileDescriptor; |
| import java.io.PrintWriter; |
| import java.util.ArrayList; |
| import java.util.Arrays; |
| import java.util.function.Consumer; |
| import java.util.List; |
| import java.util.Set; |
| import java.util.Deque; |
| import java.util.Map; |
| import java.util.Objects; |
| import java.util.TreeMap; |
| import java.util.UUID; |
| import java.util.concurrent.ConcurrentHashMap; |
| import java.util.concurrent.Executor; |
| import java.util.concurrent.Executors; |
| import java.util.concurrent.LinkedBlockingDeque; |
| import java.util.concurrent.TimeUnit; |
| import java.util.concurrent.atomic.AtomicInteger; |
| import java.util.stream.Collectors; |
| |
| /** |
| * A single SystemService to manage all sound/voice-based sound models on the DSP. |
| * This services provides apis to manage sound trigger-based sound models via |
| * the ISoundTriggerService interface. This class also publishes a local interface encapsulating |
| * the functionality provided by {@link SoundTriggerHelper} for use by |
| * {@link VoiceInteractionManagerService}. |
| * |
| * @hide |
| */ |
| public class SoundTriggerService extends SystemService { |
| private static final String TAG = "SoundTriggerService"; |
| private static final boolean DEBUG = true; |
| private static final int SESSION_MAX_EVENT_SIZE = 128; |
| |
| private final Context mContext; |
| private final Object mLock = new Object(); |
| private final SoundTriggerServiceStub mServiceStub; |
| private final LocalSoundTriggerService mLocalSoundTriggerService; |
| |
| private ISoundTriggerMiddlewareService mMiddlewareService; |
| private SoundTriggerDbHelper mDbHelper; |
| |
| private final EventLogger mServiceEventLogger = new EventLogger(256, "Service"); |
| private final EventLogger mDeviceEventLogger = new EventLogger(256, "Device Event"); |
| |
| private final Set<EventLogger> mSessionEventLoggers = ConcurrentHashMap.newKeySet(4); |
| private final Deque<EventLogger> mDetachedSessionEventLoggers = new LinkedBlockingDeque<>(4); |
| private AtomicInteger mSessionIdCounter = new AtomicInteger(0); |
| |
| class SoundModelStatTracker { |
| private class SoundModelStat { |
| SoundModelStat() { |
| mStartCount = 0; |
| mTotalTimeMsec = 0; |
| mLastStartTimestampMsec = 0; |
| mLastStopTimestampMsec = 0; |
| mIsStarted = false; |
| } |
| long mStartCount; // Number of times that given model started |
| long mTotalTimeMsec; // Total time (msec) that given model was running since boot |
| long mLastStartTimestampMsec; // SystemClock.elapsedRealtime model was last started |
| long mLastStopTimestampMsec; // SystemClock.elapsedRealtime model was last stopped |
| boolean mIsStarted; // true if model is currently running |
| } |
| private final TreeMap<UUID, SoundModelStat> mModelStats; |
| |
| SoundModelStatTracker() { |
| mModelStats = new TreeMap<UUID, SoundModelStat>(); |
| } |
| |
| public synchronized void onStart(UUID id) { |
| SoundModelStat stat = mModelStats.get(id); |
| if (stat == null) { |
| stat = new SoundModelStat(); |
| mModelStats.put(id, stat); |
| } |
| |
| if (stat.mIsStarted) { |
| Slog.w(TAG, "error onStart(): Model " + id + " already started"); |
| return; |
| } |
| |
| stat.mStartCount++; |
| stat.mLastStartTimestampMsec = SystemClock.elapsedRealtime(); |
| stat.mIsStarted = true; |
| } |
| |
| public synchronized void onStop(UUID id) { |
| SoundModelStat stat = mModelStats.get(id); |
| if (stat == null) { |
| Slog.i(TAG, "error onStop(): Model " + id + " has no stats available"); |
| return; |
| } |
| |
| if (!stat.mIsStarted) { |
| Slog.w(TAG, "error onStop(): Model " + id + " already stopped"); |
| return; |
| } |
| |
| stat.mLastStopTimestampMsec = SystemClock.elapsedRealtime(); |
| stat.mTotalTimeMsec += stat.mLastStopTimestampMsec - stat.mLastStartTimestampMsec; |
| stat.mIsStarted = false; |
| } |
| |
| public synchronized void dump(PrintWriter pw) { |
| long curTime = SystemClock.elapsedRealtime(); |
| pw.println("Model Stats:"); |
| for (Map.Entry<UUID, SoundModelStat> entry : mModelStats.entrySet()) { |
| UUID uuid = entry.getKey(); |
| SoundModelStat stat = entry.getValue(); |
| long totalTimeMsec = stat.mTotalTimeMsec; |
| if (stat.mIsStarted) { |
| totalTimeMsec += curTime - stat.mLastStartTimestampMsec; |
| } |
| pw.println(uuid + ", total_time(msec)=" + totalTimeMsec |
| + ", total_count=" + stat.mStartCount |
| + ", last_start=" + stat.mLastStartTimestampMsec |
| + ", last_stop=" + stat.mLastStopTimestampMsec); |
| } |
| } |
| } |
| |
| private final SoundModelStatTracker mSoundModelStatTracker; |
| /** Number of ops run by the {@link RemoteSoundTriggerDetectionService} per package name */ |
| @GuardedBy("mLock") |
| private final ArrayMap<String, NumOps> mNumOpsPerPackage = new ArrayMap<>(); |
| |
| private final DeviceStateHandler mDeviceStateHandler; |
| private final Executor mDeviceStateHandlerExecutor = Executors.newSingleThreadExecutor(); |
| private PhoneCallStateHandler mPhoneCallStateHandler; |
| private AppOpsManager mAppOpsManager; |
| private PackageManager mPackageManager; |
| |
| public SoundTriggerService(Context context) { |
| super(context); |
| mContext = context; |
| mServiceStub = new SoundTriggerServiceStub(); |
| mLocalSoundTriggerService = new LocalSoundTriggerService(context); |
| mSoundModelStatTracker = new SoundModelStatTracker(); |
| mDeviceStateHandler = new DeviceStateHandler(mDeviceStateHandlerExecutor, |
| mDeviceEventLogger); |
| } |
| |
| @Override |
| public void onStart() { |
| publishBinderService(Context.SOUND_TRIGGER_SERVICE, mServiceStub); |
| publishLocalService(SoundTriggerInternal.class, mLocalSoundTriggerService); |
| } |
| |
| @Override |
| public void onBootPhase(int phase) { |
| Slog.d(TAG, "onBootPhase: " + phase + " : " + isSafeMode()); |
| if (PHASE_THIRD_PARTY_APPS_CAN_START == phase) { |
| mDbHelper = new SoundTriggerDbHelper(mContext); |
| mAppOpsManager = mContext.getSystemService(AppOpsManager.class); |
| mPackageManager = mContext.getPackageManager(); |
| final PowerManager powerManager = mContext.getSystemService(PowerManager.class); |
| // Hook up power state listener |
| mContext.registerReceiver( |
| new BroadcastReceiver() { |
| @Override |
| public void onReceive(Context context, Intent intent) { |
| if (!PowerManager.ACTION_POWER_SAVE_MODE_CHANGED |
| .equals(intent.getAction())) { |
| return; |
| } |
| mDeviceStateHandler.onPowerModeChanged( |
| powerManager.getSoundTriggerPowerSaveMode()); |
| } |
| }, new IntentFilter(PowerManager.ACTION_POWER_SAVE_MODE_CHANGED)); |
| // Initialize the initial power state |
| // Do so after registering the listener so we ensure that we don't drop any events |
| mDeviceStateHandler.onPowerModeChanged(powerManager.getSoundTriggerPowerSaveMode()); |
| |
| // PhoneCallStateHandler initializes the original call state |
| mPhoneCallStateHandler = new PhoneCallStateHandler( |
| mContext.getSystemService(SubscriptionManager.class), |
| mContext.getSystemService(TelephonyManager.class), |
| mDeviceStateHandler); |
| } |
| mMiddlewareService = ISoundTriggerMiddlewareService.Stub.asInterface( |
| ServiceManager.waitForService(Context.SOUND_TRIGGER_MIDDLEWARE_SERVICE)); |
| |
| } |
| |
| // Must be called with cleared binder context. |
| private List<ModuleProperties> listUnderlyingModuleProperties( |
| Identity originatorIdentity) { |
| Identity middlemanIdentity = new Identity(); |
| middlemanIdentity.packageName = ActivityThread.currentOpPackageName(); |
| try { |
| return Arrays.stream(mMiddlewareService.listModulesAsMiddleman(middlemanIdentity, |
| originatorIdentity)) |
| .map(desc -> ConversionUtil.aidl2apiModuleDescriptor(desc)) |
| .collect(Collectors.toList()); |
| } catch (RemoteException e) { |
| throw new ServiceSpecificException(SoundTrigger.STATUS_DEAD_OBJECT); |
| } |
| } |
| |
| private SoundTriggerHelper newSoundTriggerHelper( |
| ModuleProperties moduleProperties, EventLogger eventLogger) { |
| return newSoundTriggerHelper(moduleProperties, eventLogger, false); |
| } |
| private SoundTriggerHelper newSoundTriggerHelper( |
| ModuleProperties moduleProperties, EventLogger eventLogger, boolean isTrusted) { |
| |
| Identity middlemanIdentity = new Identity(); |
| middlemanIdentity.packageName = ActivityThread.currentOpPackageName(); |
| Identity originatorIdentity = IdentityContext.getNonNull(); |
| |
| List<ModuleProperties> moduleList = listUnderlyingModuleProperties(originatorIdentity); |
| |
| // Don't fail existing CTS tests which run without a ST module |
| final int moduleId = (moduleProperties != null) ? |
| moduleProperties.getId() : SoundTriggerHelper.INVALID_MODULE_ID; |
| |
| if (moduleId != SoundTriggerHelper.INVALID_MODULE_ID) { |
| if (!moduleList.contains(moduleProperties)) { |
| throw new IllegalArgumentException("Invalid module properties"); |
| } |
| } |
| |
| return new SoundTriggerHelper( |
| mContext, |
| eventLogger, |
| (SoundTrigger.StatusListener statusListener) -> new SoundTriggerModule( |
| mMiddlewareService, moduleId, statusListener, |
| Looper.getMainLooper(), middlemanIdentity, originatorIdentity, isTrusted), |
| moduleId, |
| () -> listUnderlyingModuleProperties(originatorIdentity) |
| ); |
| } |
| |
| // Helper to add session logger to the capacity limited detached list. |
| // If we are at capacity, remove the oldest, and retry |
| private void detachSessionLogger(EventLogger logger) { |
| if (!mSessionEventLoggers.remove(logger)) { |
| return; |
| } |
| // Attempt to push to the top of the queue |
| while (!mDetachedSessionEventLoggers.offerFirst(logger)) { |
| // Remove the oldest element, if one still exists |
| mDetachedSessionEventLoggers.pollLast(); |
| } |
| } |
| |
| class MyAppOpsListener implements AppOpsManager.OnOpChangedListener { |
| private final Identity mOriginatorIdentity; |
| private final Consumer<Boolean> mOnOpModeChanged; |
| |
| MyAppOpsListener(Identity originatorIdentity, Consumer<Boolean> onOpModeChanged) { |
| mOriginatorIdentity = Objects.requireNonNull(originatorIdentity); |
| mOnOpModeChanged = Objects.requireNonNull(onOpModeChanged); |
| // Validate package name |
| try { |
| int uid = mPackageManager.getPackageUid(mOriginatorIdentity.packageName, |
| PackageManager.PackageInfoFlags.of(0)); |
| if (!UserHandle.isSameApp(uid, mOriginatorIdentity.uid)) { |
| throw new SecurityException("Uid " + mOriginatorIdentity.uid + |
| " attempted to spoof package name " + |
| mOriginatorIdentity.packageName + " with uid: " + uid); |
| } |
| } catch (PackageManager.NameNotFoundException e) { |
| throw new SecurityException("Package name not found: " |
| + mOriginatorIdentity.packageName); |
| } |
| } |
| |
| @Override |
| public void onOpChanged(String op, String packageName) { |
| if (!Objects.equals(op, AppOpsManager.OPSTR_RECORD_AUDIO)) { |
| return; |
| } |
| final int mode = mAppOpsManager.checkOpNoThrow( |
| AppOpsManager.OPSTR_RECORD_AUDIO, mOriginatorIdentity.uid, |
| mOriginatorIdentity.packageName); |
| mOnOpModeChanged.accept(mode == AppOpsManager.MODE_ALLOWED); |
| } |
| |
| void forceOpChangeRefresh() { |
| onOpChanged(AppOpsManager.OPSTR_RECORD_AUDIO, mOriginatorIdentity.packageName); |
| } |
| } |
| |
| class SoundTriggerServiceStub extends ISoundTriggerService.Stub { |
| @Override |
| public ISoundTriggerSession attachAsOriginator(@NonNull Identity originatorIdentity, |
| @NonNull ModuleProperties moduleProperties, |
| @NonNull IBinder client) { |
| |
| int sessionId = mSessionIdCounter.getAndIncrement(); |
| mServiceEventLogger.enqueue(new ServiceEvent( |
| ServiceEvent.Type.ATTACH, originatorIdentity.packageName + "#" + sessionId)); |
| try (SafeCloseable ignored = PermissionUtil.establishIdentityDirect( |
| originatorIdentity)) { |
| var eventLogger = new EventLogger(SESSION_MAX_EVENT_SIZE, |
| "SoundTriggerSessionLogs for package: " |
| + Objects.requireNonNull(originatorIdentity.packageName) |
| + "#" + sessionId); |
| return new SoundTriggerSessionStub(client, |
| newSoundTriggerHelper(moduleProperties, eventLogger), eventLogger); |
| } |
| } |
| |
| @Override |
| public ISoundTriggerSession attachAsMiddleman(@NonNull Identity originatorIdentity, |
| @NonNull Identity middlemanIdentity, |
| @NonNull ModuleProperties moduleProperties, |
| @NonNull IBinder client) { |
| |
| int sessionId = mSessionIdCounter.getAndIncrement(); |
| mServiceEventLogger.enqueue(new ServiceEvent( |
| ServiceEvent.Type.ATTACH, originatorIdentity.packageName + "#" + sessionId)); |
| try (SafeCloseable ignored = PermissionUtil.establishIdentityIndirect(mContext, |
| SOUNDTRIGGER_DELEGATE_IDENTITY, middlemanIdentity, |
| originatorIdentity)) { |
| var eventLogger = new EventLogger(SESSION_MAX_EVENT_SIZE, |
| "SoundTriggerSessionLogs for package: " |
| + Objects.requireNonNull(originatorIdentity.packageName) + "#" |
| + sessionId); |
| return new SoundTriggerSessionStub(client, |
| newSoundTriggerHelper(moduleProperties, eventLogger), eventLogger); |
| } |
| } |
| |
| @Override |
| public List<ModuleProperties> listModuleProperties(@NonNull Identity originatorIdentity) { |
| mServiceEventLogger.enqueue(new ServiceEvent( |
| ServiceEvent.Type.LIST_MODULE, originatorIdentity.packageName)); |
| try (SafeCloseable ignored = PermissionUtil.establishIdentityDirect( |
| originatorIdentity)) { |
| return listUnderlyingModuleProperties(originatorIdentity); |
| } |
| } |
| |
| @Override |
| public void attachInjection(@NonNull ISoundTriggerInjection injection) { |
| if (PermissionChecker.checkCallingPermissionForPreflight(mContext, |
| android.Manifest.permission.MANAGE_SOUND_TRIGGER, null) |
| != PermissionChecker.PERMISSION_GRANTED) { |
| throw new SecurityException(); |
| } |
| try { |
| ISoundTriggerMiddlewareService.Stub |
| .asInterface(ServiceManager |
| .waitForService(Context.SOUND_TRIGGER_MIDDLEWARE_SERVICE)) |
| .attachFakeHalInjection(injection); |
| } catch (RemoteException e) { |
| throw e.rethrowFromSystemServer(); |
| } |
| } |
| |
| @Override |
| public void setInPhoneCallState(boolean isInPhoneCall) { |
| Slog.i(TAG, "Overriding phone call state: " + isInPhoneCall); |
| mDeviceStateHandler.onPhoneCallStateChanged(isInPhoneCall); |
| } |
| |
| @Override |
| public void dump(FileDescriptor fd, PrintWriter pw, String[] args) { |
| if (!DumpUtils.checkDumpPermission(mContext, TAG, pw)) return; |
| // Event loggers |
| pw.println("##Service-Wide logs:"); |
| mServiceEventLogger.dump(pw, /* indent = */ " "); |
| pw.println("\n##Device state logs:"); |
| mDeviceStateHandler.dump(pw); |
| mDeviceEventLogger.dump(pw, /* indent = */ " "); |
| |
| pw.println("\n##Active Session dumps:\n"); |
| for (var sessionLogger : mSessionEventLoggers) { |
| sessionLogger.dump(pw, /* indent= */ " "); |
| pw.println(""); |
| } |
| pw.println("##Detached Session dumps:\n"); |
| for (var sessionLogger : mDetachedSessionEventLoggers) { |
| sessionLogger.dump(pw, /* indent= */ " "); |
| pw.println(""); |
| } |
| // enrolled models |
| pw.println("##Enrolled db dump:\n"); |
| mDbHelper.dump(pw); |
| |
| // stats |
| pw.println("\n##Sound Model Stats dump:\n"); |
| mSoundModelStatTracker.dump(pw); |
| } |
| } |
| |
| class SoundTriggerSessionStub extends ISoundTriggerSession.Stub { |
| private final SoundTriggerHelper mSoundTriggerHelper; |
| private final DeviceStateListener mListener; |
| // Used to detect client death. |
| private final IBinder mClient; |
| private final Identity mOriginatorIdentity; |
| private final TreeMap<UUID, SoundModel> mLoadedModels = new TreeMap<>(); |
| private final Object mCallbacksLock = new Object(); |
| private final TreeMap<UUID, IRecognitionStatusCallback> mCallbacks = new TreeMap<>(); |
| private final EventLogger mEventLogger; |
| private final MyAppOpsListener mAppOpsListener; |
| |
| SoundTriggerSessionStub(@NonNull IBinder client, |
| SoundTriggerHelper soundTriggerHelper, EventLogger eventLogger) { |
| mSoundTriggerHelper = soundTriggerHelper; |
| mClient = client; |
| mOriginatorIdentity = IdentityContext.getNonNull(); |
| mEventLogger = eventLogger; |
| mSessionEventLoggers.add(mEventLogger); |
| |
| try { |
| mClient.linkToDeath(() -> clientDied(), 0); |
| } catch (RemoteException e) { |
| clientDied(); |
| } |
| mListener = (SoundTriggerDeviceState state) |
| -> mSoundTriggerHelper.onDeviceStateChanged(state); |
| mAppOpsListener = new MyAppOpsListener(mOriginatorIdentity, |
| mSoundTriggerHelper::onAppOpStateChanged); |
| mAppOpsListener.forceOpChangeRefresh(); |
| mAppOpsManager.startWatchingMode(AppOpsManager.OPSTR_RECORD_AUDIO, |
| mOriginatorIdentity.packageName, AppOpsManager.WATCH_FOREGROUND_CHANGES, |
| mAppOpsListener); |
| mDeviceStateHandler.registerListener(mListener); |
| } |
| |
| @Override |
| public int startRecognition(GenericSoundModel soundModel, |
| IRecognitionStatusCallback callback, |
| RecognitionConfig config, boolean runInBatterySaverMode) { |
| mEventLogger.enqueue(new SessionEvent(Type.START_RECOGNITION, getUuid(soundModel))); |
| |
| try (SafeCloseable ignored = ClearCallingIdentityContext.create()) { |
| enforceCallingPermission(Manifest.permission.MANAGE_SOUND_TRIGGER); |
| |
| if (soundModel == null) { |
| mEventLogger.enqueue(new SessionEvent(Type.START_RECOGNITION, |
| getUuid(soundModel), "Invalid sound model").printLog(ALOGW, TAG)); |
| return STATUS_ERROR; |
| } |
| |
| if (runInBatterySaverMode) { |
| enforceCallingPermission(Manifest.permission.SOUND_TRIGGER_RUN_IN_BATTERY_SAVER); |
| } |
| |
| int ret = mSoundTriggerHelper.startGenericRecognition(soundModel.getUuid(), |
| soundModel, |
| callback, config, runInBatterySaverMode); |
| if (ret == STATUS_OK) { |
| mSoundModelStatTracker.onStart(soundModel.getUuid()); |
| } |
| return ret; |
| } |
| } |
| |
| @Override |
| public int stopRecognition(ParcelUuid parcelUuid, IRecognitionStatusCallback callback) { |
| mEventLogger.enqueue(new SessionEvent(Type.STOP_RECOGNITION, getUuid(parcelUuid))); |
| try (SafeCloseable ignored = ClearCallingIdentityContext.create()) { |
| enforceCallingPermission(Manifest.permission.MANAGE_SOUND_TRIGGER); |
| int ret = mSoundTriggerHelper.stopGenericRecognition(parcelUuid.getUuid(), |
| callback); |
| if (ret == STATUS_OK) { |
| mSoundModelStatTracker.onStop(parcelUuid.getUuid()); |
| } |
| return ret; |
| } |
| } |
| |
| @Override |
| public SoundTrigger.GenericSoundModel getSoundModel(ParcelUuid soundModelId) { |
| try (SafeCloseable ignored = ClearCallingIdentityContext.create()) { |
| enforceCallingPermission(Manifest.permission.MANAGE_SOUND_TRIGGER); |
| SoundTrigger.GenericSoundModel model = mDbHelper.getGenericSoundModel( |
| soundModelId.getUuid()); |
| return model; |
| } |
| } |
| |
| @Override |
| public void updateSoundModel(SoundTrigger.GenericSoundModel soundModel) { |
| mEventLogger.enqueue(new SessionEvent(Type.UPDATE_MODEL, getUuid(soundModel))); |
| try (SafeCloseable ignored = ClearCallingIdentityContext.create()) { |
| enforceCallingPermission(Manifest.permission.MANAGE_SOUND_TRIGGER); |
| mDbHelper.updateGenericSoundModel(soundModel); |
| } |
| } |
| |
| @Override |
| public void deleteSoundModel(ParcelUuid soundModelId) { |
| mEventLogger.enqueue(new SessionEvent(Type.DELETE_MODEL, getUuid(soundModelId))); |
| try (SafeCloseable ignored = ClearCallingIdentityContext.create()) { |
| enforceCallingPermission(Manifest.permission.MANAGE_SOUND_TRIGGER); |
| // Unload the model if it is loaded. |
| mSoundTriggerHelper.unloadGenericSoundModel(soundModelId.getUuid()); |
| |
| // Stop tracking recognition if it is started. |
| mSoundModelStatTracker.onStop(soundModelId.getUuid()); |
| |
| mDbHelper.deleteGenericSoundModel(soundModelId.getUuid()); |
| } |
| } |
| |
| @Override |
| public int loadGenericSoundModel(GenericSoundModel soundModel) { |
| mEventLogger.enqueue(new SessionEvent(Type.LOAD_MODEL, getUuid(soundModel))); |
| try (SafeCloseable ignored = ClearCallingIdentityContext.create()) { |
| enforceCallingPermission(Manifest.permission.MANAGE_SOUND_TRIGGER); |
| if (soundModel == null || soundModel.getUuid() == null) { |
| mEventLogger.enqueue(new SessionEvent(Type.LOAD_MODEL, |
| getUuid(soundModel), "Invalid sound model").printLog(ALOGW, TAG)); |
| return STATUS_ERROR; |
| } |
| |
| synchronized (mLock) { |
| SoundModel oldModel = mLoadedModels.get(soundModel.getUuid()); |
| // If the model we're loading is actually different than what we had loaded, we |
| // should unload that other model now. We don't care about return codes since we |
| // don't know if the other model is loaded. |
| if (oldModel != null && !oldModel.equals(soundModel)) { |
| mSoundTriggerHelper.unloadGenericSoundModel(soundModel.getUuid()); |
| synchronized (mCallbacksLock) { |
| mCallbacks.remove(soundModel.getUuid()); |
| } |
| } |
| mLoadedModels.put(soundModel.getUuid(), soundModel); |
| } |
| return STATUS_OK; |
| } |
| } |
| |
| @Override |
| public int loadKeyphraseSoundModel(KeyphraseSoundModel soundModel) { |
| mEventLogger.enqueue(new SessionEvent(Type.LOAD_MODEL, getUuid(soundModel))); |
| |
| try (SafeCloseable ignored = ClearCallingIdentityContext.create()) { |
| enforceCallingPermission(Manifest.permission.MANAGE_SOUND_TRIGGER); |
| if (soundModel == null || soundModel.getUuid() == null) { |
| mEventLogger.enqueue(new SessionEvent(Type.LOAD_MODEL, getUuid(soundModel), |
| "Invalid sound model").printLog(ALOGW, TAG)); |
| |
| return STATUS_ERROR; |
| } |
| if (soundModel.getKeyphrases() == null || soundModel.getKeyphrases().length != 1) { |
| mEventLogger.enqueue(new SessionEvent(Type.LOAD_MODEL, getUuid(soundModel), |
| "Only one keyphrase supported").printLog(ALOGW, TAG)); |
| return STATUS_ERROR; |
| } |
| |
| |
| synchronized (mLock) { |
| SoundModel oldModel = mLoadedModels.get(soundModel.getUuid()); |
| // If the model we're loading is actually different than what we had loaded, we |
| // should unload that other model now. We don't care about return codes since we |
| // don't know if the other model is loaded. |
| if (oldModel != null && !oldModel.equals(soundModel)) { |
| mSoundTriggerHelper.unloadKeyphraseSoundModel( |
| soundModel.getKeyphrases()[0].getId()); |
| synchronized (mCallbacksLock) { |
| mCallbacks.remove(soundModel.getUuid()); |
| } |
| } |
| mLoadedModels.put(soundModel.getUuid(), soundModel); |
| } |
| return STATUS_OK; |
| } |
| } |
| |
| @Override |
| public int startRecognitionForService(ParcelUuid soundModelId, Bundle params, |
| ComponentName detectionService, SoundTrigger.RecognitionConfig config) { |
| mEventLogger.enqueue(new SessionEvent(Type.START_RECOGNITION_SERVICE, |
| getUuid(soundModelId))); |
| try (SafeCloseable ignored = ClearCallingIdentityContext.create()) { |
| Objects.requireNonNull(soundModelId); |
| Objects.requireNonNull(detectionService); |
| Objects.requireNonNull(config); |
| |
| enforceCallingPermission(Manifest.permission.MANAGE_SOUND_TRIGGER); |
| enforceDetectionPermissions(detectionService); |
| |
| IRecognitionStatusCallback callback = |
| new RemoteSoundTriggerDetectionService(soundModelId.getUuid(), params, |
| detectionService, Binder.getCallingUserHandle(), config); |
| |
| synchronized (mLock) { |
| SoundModel soundModel = mLoadedModels.get(soundModelId.getUuid()); |
| if (soundModel == null) { |
| mEventLogger.enqueue(new SessionEvent( |
| Type.START_RECOGNITION_SERVICE, |
| getUuid(soundModelId), |
| "Model not loaded").printLog(ALOGW, TAG)); |
| |
| return STATUS_ERROR; |
| } |
| IRecognitionStatusCallback existingCallback = null; |
| synchronized (mCallbacksLock) { |
| existingCallback = mCallbacks.get(soundModelId.getUuid()); |
| } |
| if (existingCallback != null) { |
| mEventLogger.enqueue(new SessionEvent( |
| Type.START_RECOGNITION_SERVICE, |
| getUuid(soundModelId), |
| "Model already running").printLog(ALOGW, TAG)); |
| return STATUS_ERROR; |
| } |
| int ret; |
| switch (soundModel.getType()) { |
| case SoundModel.TYPE_GENERIC_SOUND: |
| ret = mSoundTriggerHelper.startGenericRecognition(soundModel.getUuid(), |
| (GenericSoundModel) soundModel, callback, config, false); |
| break; |
| default: |
| mEventLogger.enqueue(new SessionEvent( |
| Type.START_RECOGNITION_SERVICE, |
| getUuid(soundModelId), |
| "Unsupported model type").printLog(ALOGW, TAG)); |
| return STATUS_ERROR; |
| } |
| |
| if (ret != STATUS_OK) { |
| mEventLogger.enqueue(new SessionEvent( |
| Type.START_RECOGNITION_SERVICE, |
| getUuid(soundModelId), |
| "Model start fail").printLog(ALOGW, TAG)); |
| return ret; |
| } |
| synchronized (mCallbacksLock) { |
| mCallbacks.put(soundModelId.getUuid(), callback); |
| } |
| |
| mSoundModelStatTracker.onStart(soundModelId.getUuid()); |
| } |
| return STATUS_OK; |
| } |
| } |
| |
| @Override |
| public int stopRecognitionForService(ParcelUuid soundModelId) { |
| mEventLogger.enqueue(new SessionEvent(Type.STOP_RECOGNITION_SERVICE, |
| getUuid(soundModelId))); |
| |
| try (SafeCloseable ignored = ClearCallingIdentityContext.create()) { |
| enforceCallingPermission(Manifest.permission.MANAGE_SOUND_TRIGGER); |
| |
| synchronized (mLock) { |
| SoundModel soundModel = mLoadedModels.get(soundModelId.getUuid()); |
| if (soundModel == null) { |
| mEventLogger.enqueue(new SessionEvent( |
| Type.STOP_RECOGNITION_SERVICE, |
| getUuid(soundModelId), |
| "Model not loaded") |
| .printLog(ALOGW, TAG)); |
| |
| return STATUS_ERROR; |
| } |
| IRecognitionStatusCallback callback = null; |
| synchronized (mCallbacksLock) { |
| callback = mCallbacks.get(soundModelId.getUuid()); |
| } |
| if (callback == null) { |
| mEventLogger.enqueue(new SessionEvent( |
| Type.STOP_RECOGNITION_SERVICE, |
| getUuid(soundModelId), |
| "Model not running") |
| .printLog(ALOGW, TAG)); |
| return STATUS_ERROR; |
| } |
| int ret; |
| switch (soundModel.getType()) { |
| case SoundModel.TYPE_GENERIC_SOUND: |
| ret = mSoundTriggerHelper.stopGenericRecognition( |
| soundModel.getUuid(), callback); |
| break; |
| default: |
| mEventLogger.enqueue(new SessionEvent( |
| Type.STOP_RECOGNITION_SERVICE, |
| getUuid(soundModelId), |
| "Unknown model type") |
| .printLog(ALOGW, TAG)); |
| |
| return STATUS_ERROR; |
| } |
| |
| if (ret != STATUS_OK) { |
| mEventLogger.enqueue(new SessionEvent( |
| Type.STOP_RECOGNITION_SERVICE, |
| getUuid(soundModelId), |
| "Failed to stop model") |
| .printLog(ALOGW, TAG)); |
| return ret; |
| } |
| synchronized (mCallbacksLock) { |
| mCallbacks.remove(soundModelId.getUuid()); |
| } |
| |
| mSoundModelStatTracker.onStop(soundModelId.getUuid()); |
| } |
| return STATUS_OK; |
| } |
| } |
| |
| @Override |
| public int unloadSoundModel(ParcelUuid soundModelId) { |
| mEventLogger.enqueue(new SessionEvent(Type.UNLOAD_MODEL, getUuid(soundModelId))); |
| try (SafeCloseable ignored = ClearCallingIdentityContext.create()) { |
| enforceCallingPermission(Manifest.permission.MANAGE_SOUND_TRIGGER); |
| |
| synchronized (mLock) { |
| SoundModel soundModel = mLoadedModels.get(soundModelId.getUuid()); |
| if (soundModel == null) { |
| mEventLogger.enqueue(new SessionEvent( |
| Type.UNLOAD_MODEL, |
| getUuid(soundModelId), |
| "Model not loaded") |
| .printLog(ALOGW, TAG)); |
| return STATUS_ERROR; |
| } |
| int ret; |
| switch (soundModel.getType()) { |
| case SoundModel.TYPE_KEYPHRASE: |
| ret = mSoundTriggerHelper.unloadKeyphraseSoundModel( |
| ((KeyphraseSoundModel) soundModel).getKeyphrases()[0].getId()); |
| break; |
| case SoundModel.TYPE_GENERIC_SOUND: |
| ret = mSoundTriggerHelper.unloadGenericSoundModel(soundModel.getUuid()); |
| break; |
| default: |
| mEventLogger.enqueue(new SessionEvent( |
| Type.UNLOAD_MODEL, |
| getUuid(soundModelId), |
| "Unknown model type") |
| .printLog(ALOGW, TAG)); |
| return STATUS_ERROR; |
| } |
| if (ret != STATUS_OK) { |
| mEventLogger.enqueue(new SessionEvent( |
| Type.UNLOAD_MODEL, |
| getUuid(soundModelId), |
| "Failed to unload model") |
| .printLog(ALOGW, TAG)); |
| return ret; |
| } |
| mLoadedModels.remove(soundModelId.getUuid()); |
| return STATUS_OK; |
| } |
| } |
| } |
| |
| @Override |
| public boolean isRecognitionActive(ParcelUuid parcelUuid) { |
| try (SafeCloseable ignored = ClearCallingIdentityContext.create()) { |
| enforceCallingPermission(Manifest.permission.MANAGE_SOUND_TRIGGER); |
| synchronized (mCallbacksLock) { |
| IRecognitionStatusCallback callback = mCallbacks.get(parcelUuid.getUuid()); |
| if (callback == null) { |
| return false; |
| } |
| } |
| return mSoundTriggerHelper.isRecognitionRequested(parcelUuid.getUuid()); |
| } |
| } |
| |
| @Override |
| public int getModelState(ParcelUuid soundModelId) { |
| mEventLogger.enqueue(new SessionEvent(Type.GET_MODEL_STATE, getUuid(soundModelId))); |
| try (SafeCloseable ignored = ClearCallingIdentityContext.create()) { |
| enforceCallingPermission(Manifest.permission.MANAGE_SOUND_TRIGGER); |
| int ret = STATUS_ERROR; |
| |
| synchronized (mLock) { |
| SoundModel soundModel = mLoadedModels.get(soundModelId.getUuid()); |
| if (soundModel == null) { |
| mEventLogger.enqueue(new SessionEvent( |
| Type.GET_MODEL_STATE, |
| getUuid(soundModelId), |
| "Model is not loaded") |
| .printLog(ALOGW, TAG)); |
| return ret; |
| } |
| switch (soundModel.getType()) { |
| case SoundModel.TYPE_GENERIC_SOUND: |
| ret = mSoundTriggerHelper.getGenericModelState(soundModel.getUuid()); |
| break; |
| default: |
| // SoundModel.TYPE_KEYPHRASE is not supported to increase privacy. |
| mEventLogger.enqueue(new SessionEvent( |
| Type.GET_MODEL_STATE, |
| getUuid(soundModelId), |
| "Unsupported model type") |
| .printLog(ALOGW, TAG)); |
| break; |
| } |
| return ret; |
| } |
| } |
| } |
| |
| @Override |
| @Nullable |
| public ModuleProperties getModuleProperties() { |
| mEventLogger.enqueue(new SessionEvent(Type.GET_MODULE_PROPERTIES, null)); |
| try (SafeCloseable ignored = ClearCallingIdentityContext.create()) { |
| enforceCallingPermission(Manifest.permission.MANAGE_SOUND_TRIGGER); |
| synchronized (mLock) { |
| ModuleProperties properties = mSoundTriggerHelper.getModuleProperties(); |
| return properties; |
| } |
| } |
| } |
| |
| @Override |
| public int setParameter(ParcelUuid soundModelId, |
| @ModelParams int modelParam, int value) { |
| mEventLogger.enqueue(new SessionEvent(Type.SET_PARAMETER, getUuid(soundModelId))); |
| try (SafeCloseable ignored = ClearCallingIdentityContext.create()) { |
| enforceCallingPermission(Manifest.permission.MANAGE_SOUND_TRIGGER); |
| synchronized (mLock) { |
| SoundModel soundModel = mLoadedModels.get(soundModelId.getUuid()); |
| if (soundModel == null) { |
| mEventLogger.enqueue(new SessionEvent( |
| Type.SET_PARAMETER, |
| getUuid(soundModelId), |
| "Model not loaded") |
| .printLog(ALOGW, TAG)); |
| return STATUS_BAD_VALUE; |
| } |
| return mSoundTriggerHelper.setParameter( |
| soundModel.getUuid(), modelParam, value); |
| } |
| } |
| } |
| |
| @Override |
| public int getParameter(@NonNull ParcelUuid soundModelId, |
| @ModelParams int modelParam) |
| throws UnsupportedOperationException, IllegalArgumentException { |
| try (SafeCloseable ignored = ClearCallingIdentityContext.create()) { |
| enforceCallingPermission(Manifest.permission.MANAGE_SOUND_TRIGGER); |
| synchronized (mLock) { |
| SoundModel soundModel = mLoadedModels.get(soundModelId.getUuid()); |
| if (soundModel == null) { |
| throw new IllegalArgumentException("sound model is not loaded"); |
| } |
| return mSoundTriggerHelper.getParameter(soundModel.getUuid(), modelParam); |
| } |
| } |
| } |
| |
| @Override |
| @Nullable |
| public ModelParamRange queryParameter(@NonNull ParcelUuid soundModelId, |
| @ModelParams int modelParam) { |
| try (SafeCloseable ignored = ClearCallingIdentityContext.create()) { |
| enforceCallingPermission(Manifest.permission.MANAGE_SOUND_TRIGGER); |
| synchronized (mLock) { |
| SoundModel soundModel = mLoadedModels.get(soundModelId.getUuid()); |
| if (soundModel == null) { |
| return null; |
| } |
| return mSoundTriggerHelper.queryParameter(soundModel.getUuid(), modelParam); |
| } |
| } |
| } |
| |
| private void clientDied() { |
| mEventLogger.enqueue(new SessionEvent(Type.DETACH, null)); |
| mServiceEventLogger.enqueue(new ServiceEvent( |
| ServiceEvent.Type.DETACH, mOriginatorIdentity.packageName, "Client died") |
| .printLog(ALOGW, TAG)); |
| detach(); |
| } |
| |
| private void detach() { |
| if (mAppOpsListener != null) { |
| mAppOpsManager.stopWatchingMode(mAppOpsListener); |
| } |
| mDeviceStateHandler.unregisterListener(mListener); |
| mSoundTriggerHelper.detach(); |
| detachSessionLogger(mEventLogger); |
| } |
| |
| private void enforceCallingPermission(String permission) { |
| if (PermissionUtil.checkPermissionForPreflight(mContext, mOriginatorIdentity, |
| permission) != PackageManager.PERMISSION_GRANTED) { |
| throw new SecurityException( |
| "Identity " + mOriginatorIdentity + " does not have permission " |
| + permission); |
| } |
| } |
| |
| private void enforceDetectionPermissions(ComponentName detectionService) { |
| String packageName = detectionService.getPackageName(); |
| if (mPackageManager.checkPermission( |
| Manifest.permission.CAPTURE_AUDIO_HOTWORD, packageName) |
| != PackageManager.PERMISSION_GRANTED) { |
| throw new SecurityException(detectionService.getPackageName() + " does not have" |
| + " permission " + Manifest.permission.CAPTURE_AUDIO_HOTWORD); |
| } |
| } |
| |
| private UUID getUuid(ParcelUuid uuid) { |
| return (uuid != null) ? uuid.getUuid() : null; |
| } |
| |
| private UUID getUuid(SoundModel model) { |
| return (model != null) ? model.getUuid() : null; |
| } |
| |
| /** |
| * Local end for a {@link SoundTriggerDetectionService}. Operations are queued up and |
| * executed when the service connects. |
| * |
| * <p>If operations take too long they are forcefully aborted. |
| * |
| * <p>This also limits the amount of operations in 24 hours. |
| */ |
| private class RemoteSoundTriggerDetectionService |
| extends IRecognitionStatusCallback.Stub implements ServiceConnection { |
| private static final int MSG_STOP_ALL_PENDING_OPERATIONS = 1; |
| |
| private final Object mRemoteServiceLock = new Object(); |
| |
| /** UUID of the model the service is started for */ |
| private final @NonNull |
| ParcelUuid mPuuid; |
| /** Params passed into the start method for the service */ |
| private final @Nullable |
| Bundle mParams; |
| /** Component name passed when starting the service */ |
| private final @NonNull |
| ComponentName mServiceName; |
| /** User that started the service */ |
| private final @NonNull |
| UserHandle mUser; |
| /** Configuration of the recognition the service is handling */ |
| private final @NonNull |
| RecognitionConfig mRecognitionConfig; |
| /** Wake lock keeping the remote service alive */ |
| private final @NonNull |
| PowerManager.WakeLock mRemoteServiceWakeLock; |
| |
| private final @NonNull |
| Handler mHandler; |
| |
| /** Callbacks that are called by the service */ |
| private final @NonNull |
| ISoundTriggerDetectionServiceClient mClient; |
| |
| /** Operations that are pending because the service is not yet connected */ |
| @GuardedBy("mRemoteServiceLock") |
| private final ArrayList<Operation> mPendingOps = new ArrayList<>(); |
| /** Operations that have been send to the service but have no yet finished */ |
| @GuardedBy("mRemoteServiceLock") |
| private final ArraySet<Integer> mRunningOpIds = new ArraySet<>(); |
| /** The number of operations executed in each of the last 24 hours */ |
| private final NumOps mNumOps; |
| |
| /** The service binder if connected */ |
| @GuardedBy("mRemoteServiceLock") |
| private @Nullable |
| ISoundTriggerDetectionService mService; |
| /** Whether the service has been bound */ |
| @GuardedBy("mRemoteServiceLock") |
| private boolean mIsBound; |
| /** Whether the service has been destroyed */ |
| @GuardedBy("mRemoteServiceLock") |
| private boolean mIsDestroyed; |
| /** |
| * Set once a final op is scheduled. No further ops can be added and the service is |
| * destroyed once the op finishes. |
| */ |
| @GuardedBy("mRemoteServiceLock") |
| private boolean mDestroyOnceRunningOpsDone; |
| |
| /** Total number of operations performed by this service */ |
| @GuardedBy("mRemoteServiceLock") |
| private int mNumTotalOpsPerformed; |
| |
| /** |
| * Create a new remote sound trigger detection service. This only binds to the service |
| * when operations are in flight. Each operation has a certain time it can run. Once no |
| * operations are allowed to run anymore, {@link #stopAllPendingOperations() all |
| * operations are aborted and stopped} and the service is disconnected. |
| * |
| * @param modelUuid The UUID of the model the recognition is for |
| * @param params The params passed to each method of the service |
| * @param serviceName The component name of the service |
| * @param user The user of the service |
| * @param config The configuration of the recognition |
| */ |
| public RemoteSoundTriggerDetectionService(@NonNull UUID modelUuid, |
| @Nullable Bundle params, @NonNull ComponentName serviceName, |
| @NonNull UserHandle user, @NonNull RecognitionConfig config) { |
| mPuuid = new ParcelUuid(modelUuid); |
| mParams = params; |
| mServiceName = serviceName; |
| mUser = user; |
| mRecognitionConfig = config; |
| mHandler = new Handler(Looper.getMainLooper()); |
| |
| PowerManager pm = ((PowerManager) mContext.getSystemService(Context.POWER_SERVICE)); |
| mRemoteServiceWakeLock = pm.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, |
| "RemoteSoundTriggerDetectionService " + mServiceName.getPackageName() + ":" |
| + mServiceName.getClassName()); |
| |
| synchronized (mLock) { |
| NumOps numOps = mNumOpsPerPackage.get(mServiceName.getPackageName()); |
| if (numOps == null) { |
| numOps = new NumOps(); |
| mNumOpsPerPackage.put(mServiceName.getPackageName(), numOps); |
| } |
| mNumOps = numOps; |
| } |
| |
| mClient = new ISoundTriggerDetectionServiceClient.Stub() { |
| @Override |
| public void onOpFinished(int opId) { |
| final long token = Binder.clearCallingIdentity(); |
| try { |
| synchronized (mRemoteServiceLock) { |
| mRunningOpIds.remove(opId); |
| |
| if (mRunningOpIds.isEmpty() && mPendingOps.isEmpty()) { |
| if (mDestroyOnceRunningOpsDone) { |
| destroy(); |
| } else { |
| disconnectLocked(); |
| } |
| } |
| } |
| } finally { |
| Binder.restoreCallingIdentity(token); |
| } |
| } |
| }; |
| } |
| |
| @Override |
| public boolean pingBinder() { |
| return !(mIsDestroyed || mDestroyOnceRunningOpsDone); |
| } |
| |
| /** |
| * Disconnect from the service, but allow to re-connect when new operations are |
| * triggered. |
| */ |
| @GuardedBy("mRemoteServiceLock") |
| private void disconnectLocked() { |
| if (mService != null) { |
| try { |
| mService.removeClient(mPuuid); |
| } catch (Exception e) { |
| Slog.e(TAG, mPuuid + ": Cannot remove client", e); |
| |
| mEventLogger.enqueue(new EventLogger.StringEvent(mPuuid |
| + ": Cannot remove client")); |
| |
| } |
| |
| mService = null; |
| } |
| |
| if (mIsBound) { |
| mContext.unbindService(RemoteSoundTriggerDetectionService.this); |
| mIsBound = false; |
| |
| synchronized (mCallbacksLock) { |
| mRemoteServiceWakeLock.release(); |
| } |
| } |
| } |
| |
| /** |
| * Disconnect, do not allow to reconnect to the service. All further operations will be |
| * dropped. |
| */ |
| private void destroy() { |
| mEventLogger.enqueue(new EventLogger.StringEvent(mPuuid + ": destroy")); |
| |
| synchronized (mRemoteServiceLock) { |
| disconnectLocked(); |
| |
| mIsDestroyed = true; |
| } |
| |
| // The callback is removed before the flag is set |
| if (!mDestroyOnceRunningOpsDone) { |
| synchronized (mCallbacksLock) { |
| mCallbacks.remove(mPuuid.getUuid()); |
| } |
| } |
| } |
| |
| /** |
| * Stop all pending operations and then disconnect for the service. |
| */ |
| private void stopAllPendingOperations() { |
| synchronized (mRemoteServiceLock) { |
| if (mIsDestroyed) { |
| return; |
| } |
| |
| if (mService != null) { |
| int numOps = mRunningOpIds.size(); |
| for (int i = 0; i < numOps; i++) { |
| try { |
| mService.onStopOperation(mPuuid, mRunningOpIds.valueAt(i)); |
| } catch (Exception e) { |
| Slog.e(TAG, mPuuid + ": Could not stop operation " |
| + mRunningOpIds.valueAt(i), e); |
| |
| mEventLogger.enqueue(new EventLogger.StringEvent(mPuuid |
| + ": Could not stop operation " + mRunningOpIds.valueAt( |
| i))); |
| |
| } |
| } |
| |
| mRunningOpIds.clear(); |
| } |
| |
| disconnectLocked(); |
| } |
| } |
| |
| /** |
| * Verify that the service has the expected properties and then bind to the service |
| */ |
| private void bind() { |
| final long token = Binder.clearCallingIdentity(); |
| try { |
| Intent i = new Intent(); |
| i.setComponent(mServiceName); |
| |
| ResolveInfo ri = mContext.getPackageManager().resolveServiceAsUser(i, |
| GET_SERVICES | GET_META_DATA | MATCH_DEBUG_TRIAGED_MISSING, |
| mUser.getIdentifier()); |
| |
| if (ri == null) { |
| Slog.w(TAG, mPuuid + ": " + mServiceName + " not found"); |
| |
| mEventLogger.enqueue(new EventLogger.StringEvent(mPuuid |
| + ": " + mServiceName + " not found")); |
| |
| return; |
| } |
| |
| if (!BIND_SOUND_TRIGGER_DETECTION_SERVICE |
| .equals(ri.serviceInfo.permission)) { |
| Slog.w(TAG, mPuuid + ": " + mServiceName + " does not require " |
| + BIND_SOUND_TRIGGER_DETECTION_SERVICE); |
| |
| mEventLogger.enqueue(new EventLogger.StringEvent(mPuuid |
| + ": " + mServiceName + " does not require " |
| + BIND_SOUND_TRIGGER_DETECTION_SERVICE)); |
| |
| return; |
| } |
| |
| mIsBound = mContext.bindServiceAsUser(i, this, |
| BIND_AUTO_CREATE | BIND_FOREGROUND_SERVICE | BIND_INCLUDE_CAPABILITIES, |
| mUser); |
| |
| if (mIsBound) { |
| mRemoteServiceWakeLock.acquire(); |
| } else { |
| Slog.w(TAG, mPuuid + ": Could not bind to " + mServiceName); |
| |
| mEventLogger.enqueue(new EventLogger.StringEvent(mPuuid |
| + ": Could not bind to " + mServiceName)); |
| |
| } |
| } finally { |
| Binder.restoreCallingIdentity(token); |
| } |
| } |
| |
| /** |
| * Run an operation (i.e. send it do the service). If the service is not connected, this |
| * binds the service and then runs the operation once connected. |
| * |
| * @param op The operation to run |
| */ |
| private void runOrAddOperation(Operation op) { |
| synchronized (mRemoteServiceLock) { |
| if (mIsDestroyed || mDestroyOnceRunningOpsDone) { |
| Slog.w(TAG, |
| mPuuid + ": Dropped operation as already destroyed or marked for " |
| + "destruction"); |
| |
| mEventLogger.enqueue(new EventLogger.StringEvent(mPuuid |
| + ":Dropped operation as already destroyed or marked for " |
| + "destruction")); |
| |
| op.drop(); |
| return; |
| } |
| |
| if (mService == null) { |
| mPendingOps.add(op); |
| |
| if (!mIsBound) { |
| bind(); |
| } |
| } else { |
| long currentTime = System.nanoTime(); |
| mNumOps.clearOldOps(currentTime); |
| |
| // Drop operation if too many were executed in the last 24 hours. |
| int opsAllowed = Settings.Global.getInt(mContext.getContentResolver(), |
| MAX_SOUND_TRIGGER_DETECTION_SERVICE_OPS_PER_DAY, |
| Integer.MAX_VALUE); |
| |
| // As we currently cannot dropping an op safely, disable throttling |
| int opsAdded = mNumOps.getOpsAdded(); |
| if (false && mNumOps.getOpsAdded() >= opsAllowed) { |
| try { |
| if (DEBUG || opsAllowed + 10 > opsAdded) { |
| Slog.w(TAG, |
| mPuuid + ": Dropped operation as too many operations " |
| + "were run in last 24 hours"); |
| |
| mEventLogger.enqueue(new EventLogger.StringEvent(mPuuid |
| + ": Dropped operation as too many operations " |
| + "were run in last 24 hours")); |
| |
| } |
| |
| op.drop(); |
| } catch (Exception e) { |
| Slog.e(TAG, mPuuid + ": Could not drop operation", e); |
| |
| mEventLogger.enqueue(new EventLogger.StringEvent(mPuuid |
| + ": Could not drop operation")); |
| |
| } |
| } else { |
| mNumOps.addOp(currentTime); |
| |
| // Find a free opID |
| int opId = mNumTotalOpsPerformed; |
| do { |
| mNumTotalOpsPerformed++; |
| } while (mRunningOpIds.contains(opId)); |
| |
| // Run OP |
| try { |
| if (DEBUG) Slog.v(TAG, mPuuid + ": runOp " + opId); |
| |
| mEventLogger.enqueue(new EventLogger.StringEvent(mPuuid |
| + ": runOp " + opId)); |
| |
| op.run(opId, mService); |
| mRunningOpIds.add(opId); |
| } catch (Exception e) { |
| Slog.e(TAG, mPuuid + ": Could not run operation " + opId, e); |
| |
| mEventLogger.enqueue(new EventLogger.StringEvent(mPuuid |
| + ": Could not run operation " + opId)); |
| |
| } |
| } |
| |
| // Unbind from service if no operations are left (i.e. if the operation |
| // failed) |
| if (mPendingOps.isEmpty() && mRunningOpIds.isEmpty()) { |
| if (mDestroyOnceRunningOpsDone) { |
| destroy(); |
| } else { |
| disconnectLocked(); |
| } |
| } else { |
| mHandler.removeMessages(MSG_STOP_ALL_PENDING_OPERATIONS); |
| mHandler.sendMessageDelayed(obtainMessage( |
| RemoteSoundTriggerDetectionService::stopAllPendingOperations, |
| this) |
| .setWhat(MSG_STOP_ALL_PENDING_OPERATIONS), |
| Settings.Global.getLong(mContext.getContentResolver(), |
| SOUND_TRIGGER_DETECTION_SERVICE_OP_TIMEOUT, |
| Long.MAX_VALUE)); |
| } |
| } |
| } |
| } |
| |
| @Override |
| public void onKeyphraseDetected(SoundTrigger.KeyphraseRecognitionEvent event) { |
| } |
| |
| /** |
| * Create an AudioRecord enough for starting and releasing the data buffered for the event. |
| * |
| * @param event The event that was received |
| * @return The initialized AudioRecord |
| */ |
| private @NonNull AudioRecord createAudioRecordForEvent( |
| @NonNull SoundTrigger.GenericRecognitionEvent event) |
| throws IllegalArgumentException, UnsupportedOperationException { |
| AudioAttributes.Builder attributesBuilder = new AudioAttributes.Builder(); |
| attributesBuilder.setInternalCapturePreset(MediaRecorder.AudioSource.HOTWORD); |
| AudioAttributes attributes = attributesBuilder.build(); |
| |
| AudioFormat originalFormat = event.getCaptureFormat(); |
| |
| mEventLogger.enqueue(new EventLogger.StringEvent("createAudioRecordForEvent")); |
| |
| return (new AudioRecord.Builder()) |
| .setAudioAttributes(attributes) |
| .setAudioFormat((new AudioFormat.Builder()) |
| .setChannelMask(originalFormat.getChannelMask()) |
| .setEncoding(originalFormat.getEncoding()) |
| .setSampleRate(originalFormat.getSampleRate()) |
| .build()) |
| .setSessionId(event.getCaptureSession()) |
| .build(); |
| } |
| |
| @Override |
| public void onGenericSoundTriggerDetected(SoundTrigger.GenericRecognitionEvent event) { |
| runOrAddOperation(new Operation( |
| // always execute: |
| () -> { |
| if (!mRecognitionConfig.allowMultipleTriggers) { |
| // Unregister this remoteService once op is done |
| synchronized (mCallbacksLock) { |
| mCallbacks.remove(mPuuid.getUuid()); |
| } |
| mDestroyOnceRunningOpsDone = true; |
| } |
| }, |
| // execute if not throttled: |
| (opId, service) -> service.onGenericRecognitionEvent(mPuuid, opId, event), |
| // execute if throttled: |
| () -> { |
| if (event.isCaptureAvailable()) { |
| try { |
| AudioRecord capturedData = createAudioRecordForEvent(event); |
| capturedData.startRecording(); |
| capturedData.release(); |
| } catch (IllegalArgumentException | UnsupportedOperationException e) { |
| Slog.w(TAG, mPuuid + ": createAudioRecordForEvent(" + event |
| + "), failed to create AudioRecord"); |
| } |
| } |
| })); |
| } |
| |
| private void onError(int status) { |
| if (DEBUG) Slog.v(TAG, mPuuid + ": onError: " + status); |
| |
| mEventLogger.enqueue(new EventLogger.StringEvent(mPuuid |
| + ": onError: " + status)); |
| |
| runOrAddOperation( |
| new Operation( |
| // always execute: |
| () -> { |
| // Unregister this remoteService once op is done |
| synchronized (mCallbacksLock) { |
| mCallbacks.remove(mPuuid.getUuid()); |
| } |
| mDestroyOnceRunningOpsDone = true; |
| }, |
| // execute if not throttled: |
| (opId, service) -> service.onError(mPuuid, opId, status), |
| // nothing to do if throttled |
| null)); |
| } |
| |
| @Override |
| public void onPreempted() { |
| if (DEBUG) Slog.v(TAG, mPuuid + ": onPreempted"); |
| onError(STATUS_ERROR); |
| } |
| |
| @Override |
| public void onModuleDied() { |
| if (DEBUG) Slog.v(TAG, mPuuid + ": onModuleDied"); |
| onError(STATUS_DEAD_OBJECT); |
| } |
| |
| @Override |
| public void onResumeFailed(int status) { |
| if (DEBUG) Slog.v(TAG, mPuuid + ": onResumeFailed: " + status); |
| onError(status); |
| } |
| |
| @Override |
| public void onPauseFailed(int status) { |
| if (DEBUG) Slog.v(TAG, mPuuid + ": onPauseFailed: " + status); |
| onError(status); |
| } |
| |
| @Override |
| public void onRecognitionPaused() { |
| } |
| |
| @Override |
| public void onRecognitionResumed() { |
| } |
| |
| @Override |
| public void onServiceConnected(ComponentName name, IBinder service) { |
| if (DEBUG) Slog.v(TAG, mPuuid + ": onServiceConnected(" + service + ")"); |
| |
| mEventLogger.enqueue(new EventLogger.StringEvent(mPuuid |
| + ": onServiceConnected(" + service + ")")); |
| |
| synchronized (mRemoteServiceLock) { |
| mService = ISoundTriggerDetectionService.Stub.asInterface(service); |
| |
| try { |
| mService.setClient(mPuuid, mParams, mClient); |
| } catch (Exception e) { |
| Slog.e(TAG, mPuuid + ": Could not init " + mServiceName, e); |
| return; |
| } |
| |
| while (!mPendingOps.isEmpty()) { |
| runOrAddOperation(mPendingOps.remove(0)); |
| } |
| } |
| } |
| |
| @Override |
| public void onServiceDisconnected(ComponentName name) { |
| if (DEBUG) Slog.v(TAG, mPuuid + ": onServiceDisconnected"); |
| |
| mEventLogger.enqueue(new EventLogger.StringEvent(mPuuid |
| + ": onServiceDisconnected")); |
| |
| synchronized (mRemoteServiceLock) { |
| mService = null; |
| } |
| } |
| |
| @Override |
| public void onBindingDied(ComponentName name) { |
| if (DEBUG) Slog.v(TAG, mPuuid + ": onBindingDied"); |
| |
| mEventLogger.enqueue(new EventLogger.StringEvent(mPuuid |
| + ": onBindingDied")); |
| |
| synchronized (mRemoteServiceLock) { |
| destroy(); |
| } |
| } |
| |
| @Override |
| public void onNullBinding(ComponentName name) { |
| Slog.w(TAG, name + " for model " + mPuuid + " returned a null binding"); |
| |
| mEventLogger.enqueue(new EventLogger.StringEvent(name + " for model " |
| + mPuuid + " returned a null binding")); |
| |
| synchronized (mRemoteServiceLock) { |
| disconnectLocked(); |
| } |
| } |
| } |
| } |
| |
| /** |
| * Counts the number of operations added in the last 24 hours. |
| */ |
| private static class NumOps { |
| private final Object mLock = new Object(); |
| |
| @GuardedBy("mLock") |
| private int[] mNumOps = new int[24]; |
| @GuardedBy("mLock") |
| private long mLastOpsHourSinceBoot; |
| |
| /** |
| * Clear buckets of new hours that have elapsed since last operation. |
| * |
| * <p>I.e. when the last operation was triggered at 1:40 and the current operation was |
| * triggered at 4:03, the buckets "2, 3, and 4" are cleared. |
| * |
| * @param currentTime Current elapsed time since boot in ns |
| */ |
| void clearOldOps(long currentTime) { |
| synchronized (mLock) { |
| long numHoursSinceBoot = TimeUnit.HOURS.convert(currentTime, TimeUnit.NANOSECONDS); |
| |
| // Clear buckets of new hours that have elapsed since last operation |
| // I.e. when the last operation was triggered at 1:40 and the current |
| // operation was triggered at 4:03, the bucket "2, 3, and 4" is cleared |
| if (mLastOpsHourSinceBoot != 0) { |
| for (long hour = mLastOpsHourSinceBoot + 1; hour <= numHoursSinceBoot; hour++) { |
| mNumOps[(int) (hour % 24)] = 0; |
| } |
| } |
| } |
| } |
| |
| /** |
| * Add a new operation. |
| * |
| * @param currentTime Current elapsed time since boot in ns |
| */ |
| void addOp(long currentTime) { |
| synchronized (mLock) { |
| long numHoursSinceBoot = TimeUnit.HOURS.convert(currentTime, TimeUnit.NANOSECONDS); |
| |
| mNumOps[(int) (numHoursSinceBoot % 24)]++; |
| mLastOpsHourSinceBoot = numHoursSinceBoot; |
| } |
| } |
| |
| /** |
| * Get the total operations added in the last 24 hours. |
| * |
| * @return The total number of operations added in the last 24 hours |
| */ |
| int getOpsAdded() { |
| synchronized (mLock) { |
| int totalOperationsInLastDay = 0; |
| for (int i = 0; i < 24; i++) { |
| totalOperationsInLastDay += mNumOps[i]; |
| } |
| |
| return totalOperationsInLastDay; |
| } |
| } |
| } |
| |
| /** |
| * A single operation run in a {@link RemoteSoundTriggerDetectionService}. |
| * |
| * <p>Once the remote service is connected either setup + execute or setup + stop is executed. |
| */ |
| private static class Operation { |
| private interface ExecuteOp { |
| void run(int opId, ISoundTriggerDetectionService service) throws RemoteException; |
| } |
| |
| private final @Nullable Runnable mSetupOp; |
| private final @NonNull ExecuteOp mExecuteOp; |
| private final @Nullable Runnable mDropOp; |
| |
| private Operation(@Nullable Runnable setupOp, @NonNull ExecuteOp executeOp, |
| @Nullable Runnable cancelOp) { |
| mSetupOp = setupOp; |
| mExecuteOp = executeOp; |
| mDropOp = cancelOp; |
| } |
| |
| private void setup() { |
| if (mSetupOp != null) { |
| mSetupOp.run(); |
| } |
| } |
| |
| void run(int opId, @NonNull ISoundTriggerDetectionService service) throws RemoteException { |
| setup(); |
| mExecuteOp.run(opId, service); |
| } |
| |
| void drop() { |
| setup(); |
| |
| if (mDropOp != null) { |
| mDropOp.run(); |
| } |
| } |
| } |
| |
| public final class LocalSoundTriggerService implements SoundTriggerInternal { |
| private final Context mContext; |
| LocalSoundTriggerService(Context context) { |
| mContext = context; |
| } |
| |
| private class SessionImpl implements Session { |
| private final @NonNull SoundTriggerHelper mSoundTriggerHelper; |
| private final @NonNull IBinder mClient; |
| private final EventLogger mEventLogger; |
| private final Identity mOriginatorIdentity; |
| private final @NonNull DeviceStateListener mListener; |
| private final MyAppOpsListener mAppOpsListener; |
| |
| private final SparseArray<UUID> mModelUuid = new SparseArray<>(1); |
| |
| private SessionImpl(@NonNull SoundTriggerHelper soundTriggerHelper, |
| @NonNull IBinder client, |
| @NonNull EventLogger eventLogger, @NonNull Identity originatorIdentity) { |
| |
| mSoundTriggerHelper = soundTriggerHelper; |
| mClient = client; |
| mOriginatorIdentity = originatorIdentity; |
| mEventLogger = eventLogger; |
| |
| mSessionEventLoggers.add(mEventLogger); |
| try { |
| mClient.linkToDeath(() -> clientDied(), 0); |
| } catch (RemoteException e) { |
| clientDied(); |
| } |
| mListener = (SoundTriggerDeviceState state) |
| -> mSoundTriggerHelper.onDeviceStateChanged(state); |
| mAppOpsListener = new MyAppOpsListener(mOriginatorIdentity, |
| mSoundTriggerHelper::onAppOpStateChanged); |
| mAppOpsListener.forceOpChangeRefresh(); |
| mAppOpsManager.startWatchingMode(AppOpsManager.OPSTR_RECORD_AUDIO, |
| mOriginatorIdentity.packageName, AppOpsManager.WATCH_FOREGROUND_CHANGES, |
| mAppOpsListener); |
| mDeviceStateHandler.registerListener(mListener); |
| } |
| |
| @Override |
| public int startRecognition(int keyphraseId, KeyphraseSoundModel soundModel, |
| IRecognitionStatusCallback listener, RecognitionConfig recognitionConfig, |
| boolean runInBatterySaverMode) { |
| mModelUuid.put(keyphraseId, soundModel.getUuid()); |
| mEventLogger.enqueue(new SessionEvent(Type.START_RECOGNITION, |
| soundModel.getUuid())); |
| return mSoundTriggerHelper.startKeyphraseRecognition(keyphraseId, soundModel, |
| listener, recognitionConfig, runInBatterySaverMode); |
| } |
| |
| @Override |
| public synchronized int stopRecognition(int keyphraseId, |
| IRecognitionStatusCallback listener) { |
| var uuid = mModelUuid.get(keyphraseId); |
| mEventLogger.enqueue(new SessionEvent(Type.STOP_RECOGNITION, uuid)); |
| return mSoundTriggerHelper.stopKeyphraseRecognition(keyphraseId, listener); |
| } |
| |
| @Override |
| public ModuleProperties getModuleProperties() { |
| mEventLogger.enqueue(new SessionEvent(Type.GET_MODULE_PROPERTIES, null)); |
| return mSoundTriggerHelper.getModuleProperties(); |
| } |
| |
| @Override |
| public int setParameter(int keyphraseId, @ModelParams int modelParam, int value) { |
| var uuid = mModelUuid.get(keyphraseId); |
| mEventLogger.enqueue(new SessionEvent(Type.SET_PARAMETER, uuid)); |
| return mSoundTriggerHelper.setKeyphraseParameter(keyphraseId, modelParam, value); |
| } |
| |
| @Override |
| public int getParameter(int keyphraseId, @ModelParams int modelParam) { |
| return mSoundTriggerHelper.getKeyphraseParameter(keyphraseId, modelParam); |
| } |
| |
| @Override |
| @Nullable |
| public ModelParamRange queryParameter(int keyphraseId, @ModelParams int modelParam) { |
| return mSoundTriggerHelper.queryKeyphraseParameter(keyphraseId, modelParam); |
| } |
| |
| @Override |
| public void detach() { |
| detachInternal(); |
| } |
| |
| @Override |
| public int unloadKeyphraseModel(int keyphraseId) { |
| var uuid = mModelUuid.get(keyphraseId); |
| mEventLogger.enqueue(new SessionEvent(Type.UNLOAD_MODEL, uuid)); |
| return mSoundTriggerHelper.unloadKeyphraseSoundModel(keyphraseId); |
| } |
| |
| private void clientDied() { |
| mServiceEventLogger.enqueue(new ServiceEvent( |
| ServiceEvent.Type.DETACH, mOriginatorIdentity.packageName, |
| "Client died") |
| .printLog(ALOGW, TAG)); |
| detachInternal(); |
| } |
| |
| private void detachInternal() { |
| if (mAppOpsListener != null) { |
| mAppOpsManager.stopWatchingMode(mAppOpsListener); |
| } |
| mEventLogger.enqueue(new SessionEvent(Type.DETACH, null)); |
| detachSessionLogger(mEventLogger); |
| mDeviceStateHandler.unregisterListener(mListener); |
| mSoundTriggerHelper.detach(); |
| } |
| } |
| |
| @Override |
| public Session attach(@NonNull IBinder client, ModuleProperties underlyingModule, |
| boolean isTrusted) { |
| var identity = IdentityContext.getNonNull(); |
| int sessionId = mSessionIdCounter.getAndIncrement(); |
| mServiceEventLogger.enqueue(new ServiceEvent( |
| ServiceEvent.Type.ATTACH, identity.packageName + "#" + sessionId)); |
| var eventLogger = new EventLogger(SESSION_MAX_EVENT_SIZE, |
| "LocalSoundTriggerEventLogger for package: " + |
| identity.packageName + "#" + sessionId); |
| |
| return new SessionImpl(newSoundTriggerHelper(underlyingModule, eventLogger, isTrusted), |
| client, eventLogger, identity); |
| } |
| |
| @Override |
| public List<ModuleProperties> listModuleProperties(Identity originatorIdentity) { |
| mServiceEventLogger.enqueue(new ServiceEvent( |
| ServiceEvent.Type.LIST_MODULE, originatorIdentity.packageName)); |
| try (SafeCloseable ignored = PermissionUtil.establishIdentityDirect( |
| originatorIdentity)) { |
| return listUnderlyingModuleProperties(originatorIdentity); |
| } |
| } |
| } |
| } |