blob: 0911e048948af35bcff35d491572a574bb8208a4 [file]
/*
* Copyright (C) 2022 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.telecom;
import static com.android.server.telecom.LogUtils.Events.STATE_TIMEOUT;
import android.content.Context;
import android.content.pm.ApplicationInfo;
import android.content.pm.PackageManager;
import android.os.Build;
import android.os.UserHandle;
import android.provider.DeviceConfig;
import android.telecom.ConnectionService;
import android.telecom.DisconnectCause;
import android.telecom.Log;
import android.telecom.PhoneAccountHandle;
import android.util.IndentingPrintWriter;
import android.util.LocalLog;
import com.android.internal.annotations.VisibleForTesting;
import com.android.server.telecom.metrics.TelecomMetricsController;
import com.android.server.telecom.stats.CallStateChangedAtomWriter;
import com.android.server.telecom.flags.FeatureFlags;
import java.util.Collections;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;
/**
* Watchdog class responsible for detecting potential anomalous conditions for {@link Call}s.
*/
public class CallAnomalyWatchdog extends CallsManagerListenerBase implements Call.Listener {
private final EmergencyCallDiagnosticLogger mEmergencyCallDiagnosticLogger;
/**
* Class used to track the call state as it pertains to the watchdog. The watchdog cares about
* both the call state and whether a {@link ConnectionService} has finished creating the
* connection.
*/
public static class WatchdogCallState {
public final int state;
public final boolean isCreateConnectionComplete;
public final long stateStartTimeMillis;
public WatchdogCallState(int newState, boolean newIsCreateConnectionComplete,
long newStateStartTimeMillis) {
state = newState;
isCreateConnectionComplete = newIsCreateConnectionComplete;
stateStartTimeMillis = newStateStartTimeMillis;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof WatchdogCallState)) return false;
WatchdogCallState that = (WatchdogCallState) o;
// don't include the state timestamp in the equality check.
return state == that.state
&& isCreateConnectionComplete == that.isCreateConnectionComplete;
}
@Override
public int hashCode() {
return Objects.hash(state, isCreateConnectionComplete);
}
@Override
public String toString() {
return "[isCreateConnComplete=" + isCreateConnectionComplete + ", state="
+ CallState.toString(state) + "]";
}
/**
* Determines if the current call is in a transitory state. A call is deemed to be in a
* transitory state if either {@link CallState#isTransitoryState(int)} returns true, OR
* if the call has been created but is not yet added to {@link CallsManager} (i.e. we are
* still waiting for the {@link ConnectionService} to create the connection.
* @return {@code true} if the call is in a transitory state, {@code false} otherwise.
*/
public boolean isInTransitoryState() {
return CallState.isTransitoryState(state)
// Consider it transitory if create connection hasn't completed, EXCEPT if we
// are in SELECT_PHONE_ACCOUNT state since that state will depend on user input.
|| (!isCreateConnectionComplete && state != CallState.SELECT_PHONE_ACCOUNT);
}
/**
* Determines if the current call is in an intermediate state. A call is deemed to be in
* an intermediate state if either {@link CallState#isIntermediateState(int)} returns true,
* AND the call has been created to the connection.
* @return {@code true} if the call is in a intermediate state, {@code false} otherwise.
*/
public boolean isInIntermediateState() {
return CallState.isIntermediateState(state) && isCreateConnectionComplete;
}
}
// Handler for tracking pending timeouts.
private final ScheduledExecutorService mScheduledExecutorService;
private final TelecomSystem.SyncRoot mLock;
private final Timeouts.Adapter mTimeoutAdapter;
private final ClockProxy mClockProxy;
private final FeatureFlags mFeatureFlags;
private AnomalyReporterAdapter mAnomalyReporter = new AnomalyReporterAdapterImpl();
// Pre-allocate space for 2 calls; realistically thats all we should ever need (tm)
private final Map<Call, ScheduledFuture<?>> mScheduledFutureMap = new ConcurrentHashMap<>(2);
private final Map<Call, WatchdogCallState> mWatchdogCallStateMap = new ConcurrentHashMap<>(2);
// Track the calls which are pending destruction.
// TODO: enhance to handle the case where a call never gets destroyed.
private final Set<Call> mCallsPendingDestruction = Collections.newSetFromMap(
new ConcurrentHashMap<>(2));
private final LocalLog mLocalLog = new LocalLog(20);
private final TelecomMetricsController mMetricsController;
/**
* Enables the action to disconnect the call when the Transitory state and Intermediate state
* time expires.
*/
private static final String ENABLE_DISCONNECT_CALL_ON_STUCK_STATE =
"enable_disconnect_call_on_stuck_state";
/**
* Anomaly Report UUIDs and corresponding event descriptions specific to CallAnomalyWatchdog.
*/
public static final UUID WATCHDOG_DISCONNECTED_STUCK_CALL_UUID =
UUID.fromString("4b093985-c78f-45e3-a9fe-5319f397b025");
public static final String WATCHDOG_DISCONNECTED_STUCK_CALL_MSG =
"Telecom CallAnomalyWatchdog caught and disconnected a stuck/zombie call.";
public static final UUID WATCHDOG_DISCONNECTED_STUCK_EMERGENCY_CALL_UUID =
UUID.fromString("d57d8aab-d723-485e-a0dd-d1abb0f346c8");
public static final String WATCHDOG_DISCONNECTED_STUCK_EMERGENCY_CALL_MSG =
"Telecom CallAnomalyWatchdog caught and disconnected a stuck/zombie emergency call.";
public static final UUID WATCHDOG_DISCONNECTED_STUCK_VOIP_CALL_UUID =
UUID.fromString("3fbecd12-059d-4fd3-87b7-6c3079891c23");
public static final String WATCHDOG_DISCONNECTED_STUCK_VOIP_CALL_MSG =
"A VoIP call was flagged due to exceeding a one-minute threshold in the DIALING or "
+ "RINGING state";
@VisibleForTesting
public void setAnomalyReporterAdapter(AnomalyReporterAdapter mAnomalyReporterAdapter){
mAnomalyReporter = mAnomalyReporterAdapter;
}
public CallAnomalyWatchdog(ScheduledExecutorService executorService,
TelecomSystem.SyncRoot lock,
FeatureFlags featureFlags,
Timeouts.Adapter timeoutAdapter, ClockProxy clockProxy,
EmergencyCallDiagnosticLogger emergencyCallDiagnosticLogger,
TelecomMetricsController metricsController) {
mScheduledExecutorService = executorService;
mLock = lock;
mFeatureFlags = featureFlags;
mTimeoutAdapter = timeoutAdapter;
mClockProxy = clockProxy;
mEmergencyCallDiagnosticLogger = emergencyCallDiagnosticLogger;
mMetricsController = metricsController;
}
/**
* Start tracking a call that we're waiting for a ConnectionService to create.
* @param call the call.
*/
@Override
public void onStartCreateConnection(Call call) {
maybeTrackCall(call);
call.addListener(this);
}
@Override
public void onCallAdded(Call call) {
maybeTrackCall(call);
mMetricsController.getCallStats().onCallStart(call);
}
/**
* Override of {@link CallsManagerListenerBase} to track when calls have failed to be created by
* a ConnectionService. These calls should no longer be tracked by the CallAnomalyWatchdog.
* @param call the call
*/
@Override
public void onCreateConnectionFailed(Call call) {
Log.i(this, "onCreateConnectionFailed: call=%s", call.toString());
stopTrackingCall(call);
}
/**
* Override of {@link CallsManagerListenerBase} to track when calls are removed
* @param call the call
*/
@Override
public void onCallRemoved(Call call) {
Log.i(this, "onCallRemoved: call=%s", call.toString());
stopTrackingCall(call);
mMetricsController.getCallStats().onCallEnd(call);
if (mFeatureFlags.callSequencingMetrics()) {
mMetricsController.getCallSequencingStats().onCallEnd(call);
}
if (mFeatureFlags.integratedCallLogs() && call.isTransactionalCall()) {
mMetricsController.getEventStats().onCallEnd(call);
}
}
/**
* Override of {@link com.android.server.telecom.CallsManager.CallsManagerListener} to track
* call state changes.
* @param call the call
* @param oldState its old state
* @param newState the new state
*/
@Override
public void onCallStateChanged(Call call, int oldState, int newState) {
Log.i(this, "onCallStateChanged: call=%s", call.toString());
maybeTrackCall(call);
}
/**
* Override of {@link Call.Listener} so we can capture successful creation of calls.
* @param call the call
* @param callState the state the call is now in
*/
@Override
public void onSuccessfulOutgoingCall(Call call, int callState) {
maybeTrackCall(call);
}
/**
* Override of {@link Call.Listener} so we can capture failed call creation.
* @param call the call
* @param disconnectCause the disconnect cause
*/
@Override
public void onFailedOutgoingCall(Call call, DisconnectCause disconnectCause) {
Log.i(this, "onFailedOutgoingCall: call=%s", call.toString());
stopTrackingCall(call);
}
/**
* Override of {@link Call.Listener} so we can capture successful creation of calls
* @param call the call
*/
@Override
public void onSuccessfulIncomingCall(Call call) {
maybeTrackCall(call);
}
/**
* Override of {@link Call.Listener} so we can capture failed call creation.
* @param call the call
*/
@Override
public void onFailedIncomingCall(Call call) {
Log.i(this, "onFailedIncomingCall: call=%s", call.toString());
stopTrackingCall(call);
}
/**
* Helper method used to stop CallAnomalyWatchdog from tracking or destroying the call.
* @param call the call.
*/
private void stopTrackingCall(Call call) {
if (mScheduledFutureMap.containsKey(call)) {
ScheduledFuture<?> existingTimeout = mScheduledFutureMap.get(call);
existingTimeout.cancel(false /* cancelIfRunning */);
mScheduledFutureMap.remove(call);
}
if (mCallsPendingDestruction.contains(call)) {
mCallsPendingDestruction.remove(call);
}
if (mWatchdogCallStateMap.containsKey(call)) {
mWatchdogCallStateMap.remove(call);
}
call.removeListener(this);
}
/**
* Given a {@link Call}, potentially post a cleanup task to track when the call has been in a
* transitory state too long.
* @param call the call.
*/
private void maybeTrackCall(Call call) {
final WatchdogCallState currentState = mWatchdogCallStateMap.get(call);
boolean isCreateConnectionComplete = call.isCreateConnectionComplete()
|| call.isTransactionalCall();
final WatchdogCallState newState = new WatchdogCallState(call.getState(),
isCreateConnectionComplete, mClockProxy.elapsedRealtime());
if (Objects.equals(currentState, newState)) {
// No state change; skip.
return;
}
mWatchdogCallStateMap.put(call, newState);
// The call's state has changed, so we will remove any existing state cleanup tasks.
if (mScheduledFutureMap.containsKey(call)) {
ScheduledFuture<?> existingTimeout = mScheduledFutureMap.get(call);
existingTimeout.cancel(false /* cancelIfRunning */);
mScheduledFutureMap.remove(call);
}
Log.i(this, "maybePostCleanupTask; callId=%s, state=%s, createConnComplete=%b",
call.getId(), CallState.toString(call.getState()),
call.isCreateConnectionComplete());
long timeoutMillis = getTimeoutMillis(call, newState);
boolean isEnabledDisconnect = isEnabledDisconnectForStuckCall();
// If the call is now in a transitory or intermediate state, post a new cleanup task.
if (timeoutMillis > 0) {
Runnable cleanupRunnable = getCleanupRunnable(call, newState, timeoutMillis,
isEnabledDisconnect);
// Post cleanup to the executor service and cache the future, so we can cancel it if
// needed.
ScheduledFuture<?> future = mScheduledExecutorService.schedule(cleanupRunnable,
timeoutMillis, TimeUnit.MILLISECONDS);
mScheduledFutureMap.put(call, future);
}
}
public long getTimeoutMillis(Call call, WatchdogCallState state) {
boolean isVoip = call.getIsVoipAudioMode();
boolean isEmergency = call.isEmergencyCall();
if (state.isInTransitoryState()) {
if (isVoip) {
return (isEmergency) ?
mTimeoutAdapter.getVoipEmergencyCallTransitoryStateTimeoutMillis() :
mTimeoutAdapter.getVoipCallTransitoryStateTimeoutMillis();
}
return (isEmergency) ?
mTimeoutAdapter.getNonVoipEmergencyCallTransitoryStateTimeoutMillis() :
mTimeoutAdapter.getNonVoipCallTransitoryStateTimeoutMillis();
}
if (state.isInIntermediateState()) {
if (isVoip) {
return (isEmergency) ?
mTimeoutAdapter.getVoipEmergencyCallIntermediateStateTimeoutMillis() :
mTimeoutAdapter.getVoipCallIntermediateStateTimeoutMillis();
}
return (isEmergency) ?
mTimeoutAdapter.getNonVoipEmergencyCallIntermediateStateTimeoutMillis() :
mTimeoutAdapter.getNonVoipCallIntermediateStateTimeoutMillis();
}
return 0;
}
private Runnable getCleanupRunnable(Call call, WatchdogCallState newState, long timeoutMillis,
boolean isEnabledDisconnect) {
Runnable cleanupRunnable = new android.telecom.Logging.Runnable("CAW.mR", mLock) {
@Override
public void loggedRun() {
// If we're already pending a cleanup due to a state violation for this call.
if (mCallsPendingDestruction.contains(call)) {
return;
}
// Ensure that at timeout we are still in the original state when we posted the
// timeout.
boolean isCreateConnectionComplete = call.isCreateConnectionComplete() ||
call.isTransactionalCall();
final WatchdogCallState expiredState = new WatchdogCallState(call.getState(),
isCreateConnectionComplete, mClockProxy.elapsedRealtime());
if (expiredState.equals(newState)
&& getDurationInCurrentStateMillis(newState) > timeoutMillis) {
// The call has been in this transitory or intermediate state too long,
// so disconnect it and destroy it.
Log.addEvent(call, STATE_TIMEOUT, newState);
mLocalLog.log("STATE_TIMEOUT; callId=" + call.getId() + " in state "
+ newState);
if (call.isEmergencyCall()){
mAnomalyReporter.reportAnomaly(
WATCHDOG_DISCONNECTED_STUCK_EMERGENCY_CALL_UUID,
WATCHDOG_DISCONNECTED_STUCK_EMERGENCY_CALL_MSG);
mEmergencyCallDiagnosticLogger.reportStuckCall(call);
} else {
mAnomalyReporter.reportAnomaly(
WATCHDOG_DISCONNECTED_STUCK_CALL_UUID,
WATCHDOG_DISCONNECTED_STUCK_CALL_MSG);
}
if (isEnabledDisconnect || isInSelfManagedStuckStartingState(call)) {
call.setOverrideDisconnectCauseCode(
new DisconnectCause(DisconnectCause.ERROR, "state_timeout"));
call.disconnect("State timeout");
} else {
writeCallStateChangedAtom(call);
}
mCallsPendingDestruction.add(call);
if (mWatchdogCallStateMap.containsKey(call)) {
mWatchdogCallStateMap.remove(call);
}
}
mScheduledFutureMap.remove(call);
}
}.prepare();
return cleanupRunnable;
}
private boolean isInSelfManagedStuckStartingState(Call call) {
Context context = call.getContext();
if (context == null) {
return false;
}
int currentStuckState = call.getState();
if (com.android.internal.telecom.flags.Flags.addEscapeHatchForStuckVoip()) {
if (!call.isSelfManaged()) {
return false;
}
boolean isRinging = currentStuckState == CallState.RINGING;
boolean isOtherState = currentStuckState == CallState.NEW ||
currentStuckState == CallState.DIALING ||
currentStuckState == CallState.CONNECTING;
if (isRinging) {
return true;
}
if (isOtherState) {
return isVanillaIceCreamBuildOrHigher(context, call);
}
return false;
} else {
return call.isSelfManaged() &&
(currentStuckState == CallState.NEW ||
currentStuckState == CallState.RINGING ||
currentStuckState == CallState.DIALING ||
currentStuckState == CallState.CONNECTING) &&
isVanillaIceCreamBuildOrHigher(context, call);
}
}
private boolean isVanillaIceCreamBuildOrHigher(Context context, Call call) {
// report the anomaly for metrics purposes
mAnomalyReporter.reportAnomaly(
WATCHDOG_DISCONNECTED_STUCK_VOIP_CALL_UUID,
WATCHDOG_DISCONNECTED_STUCK_VOIP_CALL_MSG);
// only disconnect calls running on V and when the flag is enabled!
PhoneAccountHandle phoneAccountHandle = call.getTargetPhoneAccount();
PackageManager pm = context.getPackageManager();
if (pm == null ||
phoneAccountHandle == null ||
phoneAccountHandle.getComponentName() == null) {
return false;
}
String packageName = phoneAccountHandle.getComponentName().getPackageName();
Log.d(this, "pah=[%s], user=[%s]", phoneAccountHandle, call.getAssociatedUser());
ApplicationInfo applicationInfo;
try {
applicationInfo = pm.getApplicationInfoAsUser(
packageName,
0,
call.getAssociatedUser());
} catch (Exception e) {
Log.e(this, e, "iVICBOH: pm.getApplicationInfoAsUser(...) exception");
return false;
}
int targetSdk = (applicationInfo == null) ? 0 : applicationInfo.targetSdkVersion;
Log.i(this, "iVICBOH: packageName=[%s], sdk=[%d]", packageName, targetSdk);
return targetSdk >= Build.VERSION_CODES.VANILLA_ICE_CREAM;
}
/**
* Returns whether the action to disconnect the call when the Transitory state and
* Intermediate state time expires is enabled or disabled.
* @return {@code true} if the action is enabled, {@code false} if the action is disabled.
*/
private boolean isEnabledDisconnectForStuckCall() {
return DeviceConfig.getBoolean(DeviceConfig.NAMESPACE_TELEPHONY,
ENABLE_DISCONNECT_CALL_ON_STUCK_STATE, false);
}
/**
* Determines how long a call has been in a specific state.
* @param state the call state.
* @return the time in the state, in millis.
*/
private long getDurationInCurrentStateMillis(WatchdogCallState state) {
return mClockProxy.elapsedRealtime() - state.stateStartTimeMillis;
}
private void writeCallStateChangedAtom(Call call) {
new CallStateChangedAtomWriter()
.setDisconnectCause(call.getDisconnectCause())
.setSelfManaged(call.isSelfManaged())
.setExternalCall(call.isExternalCall())
.setEmergencyCall(call.isEmergencyCall())
.write(call.getState());
}
/**
* Dumps the state of the {@link CallAnomalyWatchdog}.
*
* @param pw The {@code IndentingPrintWriter} to write the state to.
*/
public void dump(IndentingPrintWriter pw) {
pw.println("Anomaly log:");
pw.increaseIndent();
mLocalLog.dump(pw);
pw.decreaseIndent();
pw.print("Pending timeouts: ");
pw.println(mScheduledFutureMap.keySet().stream().map(c -> c.getId()).collect(
Collectors.joining(",")));
pw.print("Pending destruction: ");
pw.println(mCallsPendingDestruction.stream().map(c -> c.getId()).collect(
Collectors.joining(",")));
}
@VisibleForTesting
public int getNumberOfScheduledTimeouts() {
return mScheduledFutureMap.size();
}
}