/** * 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; import static android.os.PowerManager.SOUND_TRIGGER_MODE_ALL_DISABLED; import static android.os.PowerManager.SOUND_TRIGGER_MODE_ALL_ENABLED; import static android.os.PowerManager.SOUND_TRIGGER_MODE_CRITICAL_ONLY; import com.android.internal.annotations.GuardedBy; import com.android.server.utils.EventLogger; import java.io.PrintWriter; import java.util.HashSet; import java.util.Objects; import java.util.Set; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.CountDownLatch; import java.util.concurrent.Executor; import java.util.concurrent.Executors; import java.util.concurrent.TimeUnit; /** * Manages device state events which require pausing SoundTrigger recognition * * @hide */ public class DeviceStateHandler implements PhoneCallStateHandler.Callback { public static final long CALL_INACTIVE_MSG_DELAY_MS = 1000; public interface DeviceStateListener { void onSoundTriggerDeviceStateUpdate(SoundTriggerDeviceState state); } public enum SoundTriggerDeviceState { DISABLE, // The device state requires all SoundTrigger sessions are disabled CRITICAL, // The device state requires all non-critical SoundTrigger sessions are disabled ENABLE // The device state permits all SoundTrigger sessions } private final Object mLock = new Object(); private final EventLogger mEventLogger; @GuardedBy("mLock") SoundTriggerDeviceState mSoundTriggerDeviceState = SoundTriggerDeviceState.ENABLE; // Individual components of the SoundTriggerDeviceState @GuardedBy("mLock") private int mSoundTriggerPowerSaveMode = SOUND_TRIGGER_MODE_ALL_ENABLED; @GuardedBy("mLock") private boolean mIsPhoneCallOngoing = false; // There can only be one pending notify at any given time. // If any phone state change comes in between, we will cancel the previous pending // task. @GuardedBy("mLock") private NotificationTask mPhoneStateChangePendingNotify = null; private Set mCallbackSet = ConcurrentHashMap.newKeySet(4); private final Executor mDelayedNotificationExecutor = Executors.newSingleThreadExecutor(); private final Executor mCallbackExecutor; public void onPowerModeChanged(int soundTriggerPowerSaveMode) { mEventLogger.enqueue(new SoundTriggerPowerEvent(soundTriggerPowerSaveMode)); synchronized (mLock) { if (soundTriggerPowerSaveMode == mSoundTriggerPowerSaveMode) { // No state change, nothing to do return; } mSoundTriggerPowerSaveMode = soundTriggerPowerSaveMode; evaluateStateChange(); } } @Override public void onPhoneCallStateChanged(boolean isInPhoneCall) { mEventLogger.enqueue(new PhoneCallEvent(isInPhoneCall)); synchronized (mLock) { if (mIsPhoneCallOngoing == isInPhoneCall) { // no change, nothing to do return; } // Clear any pending notification if (mPhoneStateChangePendingNotify != null) { mPhoneStateChangePendingNotify.cancel(); mPhoneStateChangePendingNotify = null; } mIsPhoneCallOngoing = isInPhoneCall; if (!mIsPhoneCallOngoing) { // State has changed from call to no call, delay notification mPhoneStateChangePendingNotify = new NotificationTask( new Runnable() { @Override public void run() { synchronized (mLock) { if (mPhoneStateChangePendingNotify != null && mPhoneStateChangePendingNotify.runnableEquals(this)) { mPhoneStateChangePendingNotify = null; evaluateStateChange(); } } } }, CALL_INACTIVE_MSG_DELAY_MS); mDelayedNotificationExecutor.execute(mPhoneStateChangePendingNotify); } else { evaluateStateChange(); } } } /** Note, we expect initial callbacks immediately following construction */ public DeviceStateHandler(Executor callbackExecutor, EventLogger eventLogger) { mCallbackExecutor = Objects.requireNonNull(callbackExecutor); mEventLogger = Objects.requireNonNull(eventLogger); } public SoundTriggerDeviceState getDeviceState() { synchronized (mLock) { return mSoundTriggerDeviceState; } } public void registerListener(DeviceStateListener callback) { final var state = getDeviceState(); mCallbackExecutor.execute( () -> callback.onSoundTriggerDeviceStateUpdate(state)); mCallbackSet.add(callback); } public void unregisterListener(DeviceStateListener callback) { mCallbackSet.remove(callback); } void dump(PrintWriter pw) { synchronized (mLock) { pw.println("DeviceState: " + mSoundTriggerDeviceState.name()); pw.println("PhoneState: " + mIsPhoneCallOngoing); pw.println("PowerSaveMode: " + mSoundTriggerPowerSaveMode); } } @GuardedBy("mLock") private void evaluateStateChange() { // We should wait until any pending delays are complete to update. // We will eventually get called by the notification task, or something which // cancels it. // Additionally, if there isn't a state change, there is nothing to update. SoundTriggerDeviceState newState = computeState(); if (mPhoneStateChangePendingNotify != null || mSoundTriggerDeviceState == newState) { return; } mSoundTriggerDeviceState = newState; mEventLogger.enqueue(new DeviceStateEvent(mSoundTriggerDeviceState)); final var state = mSoundTriggerDeviceState; for (var callback : mCallbackSet) { mCallbackExecutor.execute( () -> callback.onSoundTriggerDeviceStateUpdate(state)); } } @GuardedBy("mLock") private SoundTriggerDeviceState computeState() { if (mIsPhoneCallOngoing) { return SoundTriggerDeviceState.DISABLE; } return switch (mSoundTriggerPowerSaveMode) { case SOUND_TRIGGER_MODE_ALL_ENABLED -> SoundTriggerDeviceState.ENABLE; case SOUND_TRIGGER_MODE_CRITICAL_ONLY -> SoundTriggerDeviceState.CRITICAL; case SOUND_TRIGGER_MODE_ALL_DISABLED -> SoundTriggerDeviceState.DISABLE; default -> throw new IllegalStateException( "Received unexpected power state code" + mSoundTriggerPowerSaveMode); }; } /** * One-shot, cancellable task which runs after a delay. Run must only be called once, from a * single thread. Cancel can be called from any other thread. */ private static class NotificationTask implements Runnable { private final Runnable mRunnable; private final long mWaitInMillis; private final CountDownLatch mCancelLatch = new CountDownLatch(1); NotificationTask(Runnable r, long waitInMillis) { mRunnable = r; mWaitInMillis = waitInMillis; } void cancel() { mCancelLatch.countDown(); } // Used for determining task equality. boolean runnableEquals(Runnable runnable) { return mRunnable == runnable; } public void run() { try { if (!mCancelLatch.await(mWaitInMillis, TimeUnit.MILLISECONDS)) { mRunnable.run(); } } catch (InterruptedException e) { Thread.currentThread().interrupt(); throw new AssertionError("Unexpected InterruptedException", e); } } } private static class PhoneCallEvent extends EventLogger.Event { final boolean mIsInPhoneCall; PhoneCallEvent(boolean isInPhoneCall) { mIsInPhoneCall = isInPhoneCall; } @Override public String eventToString() { return "PhoneCallChange - inPhoneCall: " + mIsInPhoneCall; } } private static class SoundTriggerPowerEvent extends EventLogger.Event { final int mSoundTriggerPowerState; SoundTriggerPowerEvent(int soundTriggerPowerState) { mSoundTriggerPowerState = soundTriggerPowerState; } @Override public String eventToString() { return "SoundTriggerPowerChange: " + stateToString(); } private String stateToString() { return switch (mSoundTriggerPowerState) { case SOUND_TRIGGER_MODE_ALL_ENABLED -> "All enabled"; case SOUND_TRIGGER_MODE_CRITICAL_ONLY -> "Critical only"; case SOUND_TRIGGER_MODE_ALL_DISABLED -> "All disabled"; default -> "Unknown power state: " + mSoundTriggerPowerState; }; } } private static class DeviceStateEvent extends EventLogger.Event { final SoundTriggerDeviceState mSoundTriggerDeviceState; DeviceStateEvent(SoundTriggerDeviceState soundTriggerDeviceState) { mSoundTriggerDeviceState = soundTriggerDeviceState; } @Override public String eventToString() { return "DeviceStateChange: " + mSoundTriggerDeviceState.name(); } } }