diff options
Diffstat (limited to 'java/com/android/incallui/call/CallList.java')
-rw-r--r-- | java/com/android/incallui/call/CallList.java | 763 |
1 files changed, 763 insertions, 0 deletions
diff --git a/java/com/android/incallui/call/CallList.java b/java/com/android/incallui/call/CallList.java new file mode 100644 index 000000000..862c71cf9 --- /dev/null +++ b/java/com/android/incallui/call/CallList.java @@ -0,0 +1,763 @@ +/* + * Copyright (C) 2017 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.incallui.call; + +import android.content.Context; +import android.os.Handler; +import android.os.Message; +import android.os.Trace; +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; +import android.support.annotation.VisibleForTesting; +import android.support.v4.os.BuildCompat; +import android.telecom.Call; +import android.telecom.DisconnectCause; +import android.telecom.PhoneAccount; +import android.util.ArrayMap; +import com.android.contacts.common.GeoUtil; +import com.android.dialer.blocking.FilteredNumberAsyncQueryHandler; +import com.android.dialer.blocking.FilteredNumbersUtil; +import com.android.dialer.common.Assert; +import com.android.dialer.common.LogUtil; +import com.android.dialer.logging.Logger; +import com.android.dialer.logging.nano.DialerImpression; +import com.android.dialer.shortcuts.ShortcutUsageReporter; +import com.android.dialer.spam.Spam; +import com.android.dialer.spam.SpamBindings; +import com.android.incallui.call.DialerCall.SessionModificationState; +import com.android.incallui.call.DialerCall.State; +import com.android.incallui.latencyreport.LatencyReport; +import com.android.incallui.util.TelecomCallUtil; +import java.util.Collections; +import java.util.Iterator; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Maintains the list of active calls and notifies interested classes of changes to the call list as + * they are received from the telephony stack. Primary listener of changes to this class is + * InCallPresenter. + */ +public class CallList implements DialerCallDelegate { + + private static final int DISCONNECTED_CALL_SHORT_TIMEOUT_MS = 200; + private static final int DISCONNECTED_CALL_MEDIUM_TIMEOUT_MS = 2000; + private static final int DISCONNECTED_CALL_LONG_TIMEOUT_MS = 5000; + + private static final int EVENT_DISCONNECTED_TIMEOUT = 1; + + private static CallList sInstance = new CallList(); + + private final Map<String, DialerCall> mCallById = new ArrayMap<>(); + private final Map<android.telecom.Call, DialerCall> mCallByTelecomCall = new ArrayMap<>(); + + /** + * ConcurrentHashMap constructor params: 8 is initial table size, 0.9f is load factor before + * resizing, 1 means we only expect a single thread to access the map so make only a single shard + */ + private final Set<Listener> mListeners = + Collections.newSetFromMap(new ConcurrentHashMap<Listener, Boolean>(8, 0.9f, 1)); + + private final Set<DialerCall> mPendingDisconnectCalls = + Collections.newSetFromMap(new ConcurrentHashMap<DialerCall, Boolean>(8, 0.9f, 1)); + /** Handles the timeout for destroying disconnected calls. */ + private final Handler mHandler = + new Handler() { + @Override + public void handleMessage(Message msg) { + switch (msg.what) { + case EVENT_DISCONNECTED_TIMEOUT: + LogUtil.d("CallList.handleMessage", "EVENT_DISCONNECTED_TIMEOUT ", msg.obj); + finishDisconnectedCall((DialerCall) msg.obj); + break; + default: + LogUtil.e("CallList.handleMessage", "Message not expected: " + msg.what); + break; + } + } + }; + + /** + * USED ONLY FOR TESTING Testing-only constructor. Instance should only be acquired through + * getInstance(). + */ + @VisibleForTesting + public CallList() {} + + /** Static singleton accessor method. */ + public static CallList getInstance() { + return sInstance; + } + + public void onCallAdded( + final Context context, final android.telecom.Call telecomCall, LatencyReport latencyReport) { + Trace.beginSection("onCallAdded"); + final DialerCall call = + new DialerCall(context, this, telecomCall, latencyReport, true /* registerCallback */); + final DialerCallListenerImpl dialerCallListener = new DialerCallListenerImpl(call); + call.addListener(dialerCallListener); + LogUtil.d("CallList.onCallAdded", "callState=" + call.getState()); + if (Spam.get(context).isSpamEnabled()) { + String number = TelecomCallUtil.getNumber(telecomCall); + Spam.get(context) + .checkSpamStatus( + number, + null, + new SpamBindings.Listener() { + @Override + public void onComplete(boolean isSpam) { + if (isSpam) { + if (call.getState() != DialerCall.State.INCOMING + && call.getState() != DialerCall.State.CALL_WAITING) { + LogUtil.i( + "CallList.onCallAdded", + "marking spam call as not spam because it's not an incoming call"); + isSpam = false; + } else if (isPotentialEmergencyCallback(context, call)) { + LogUtil.i( + "CallList.onCallAdded", + "marking spam call as not spam because an emergency call was made on this" + + " device recently"); + isSpam = false; + } + } + + Logger.get(context) + .logCallImpression( + isSpam + ? DialerImpression.Type.INCOMING_SPAM_CALL + : DialerImpression.Type.INCOMING_NON_SPAM_CALL, + call.getUniqueCallId(), + call.getTimeAddedMs()); + call.setSpam(isSpam); + dialerCallListener.onDialerCallUpdate(); + } + }); + + updateUserMarkedSpamStatus(call, context, number, dialerCallListener); + } + + FilteredNumberAsyncQueryHandler filteredNumberAsyncQueryHandler = + new FilteredNumberAsyncQueryHandler(context); + + filteredNumberAsyncQueryHandler.isBlockedNumber( + new FilteredNumberAsyncQueryHandler.OnCheckBlockedListener() { + @Override + public void onCheckComplete(Integer id) { + if (id != null && id != FilteredNumberAsyncQueryHandler.INVALID_ID) { + call.setBlockedStatus(true); + dialerCallListener.onDialerCallUpdate(); + } + } + }, + call.getNumber(), + GeoUtil.getCurrentCountryIso(context)); + + if (call.getState() == DialerCall.State.INCOMING + || call.getState() == DialerCall.State.CALL_WAITING) { + onIncoming(call); + } else { + dialerCallListener.onDialerCallUpdate(); + } + + if (call.getState() != State.INCOMING) { + // Only report outgoing calls + ShortcutUsageReporter.onOutgoingCallAdded(context, call.getNumber()); + } + + Trace.endSection(); + } + + private static boolean isPotentialEmergencyCallback(Context context, DialerCall call) { + if (BuildCompat.isAtLeastO()) { + return call.isPotentialEmergencyCallback(); + } else { + long timestampMillis = FilteredNumbersUtil.getLastEmergencyCallTimeMillis(context); + return call.isInEmergencyCallbackWindow(timestampMillis); + } + } + + @Override + public DialerCall getDialerCallFromTelecomCall(Call telecomCall) { + return mCallByTelecomCall.get(telecomCall); + } + + public void updateUserMarkedSpamStatus( + final DialerCall call, + final Context context, + String number, + final DialerCallListenerImpl dialerCallListener) { + + Spam.get(context) + .checkUserMarkedNonSpamStatus( + number, + null, + new SpamBindings.Listener() { + @Override + public void onComplete(boolean isInUserWhiteList) { + call.setIsInUserWhiteList(isInUserWhiteList); + } + }); + + Spam.get(context) + .checkGlobalSpamListStatus( + number, + null, + new SpamBindings.Listener() { + @Override + public void onComplete(boolean isInGlobalSpamList) { + call.setIsInGlobalSpamList(isInGlobalSpamList); + } + }); + + Spam.get(context) + .checkUserMarkedSpamStatus( + number, + null, + new SpamBindings.Listener() { + @Override + public void onComplete(boolean isInUserSpamList) { + call.setIsInUserSpamList(isInUserSpamList); + } + }); + } + + public void onCallRemoved(Context context, android.telecom.Call telecomCall) { + if (mCallByTelecomCall.containsKey(telecomCall)) { + DialerCall call = mCallByTelecomCall.get(telecomCall); + Assert.checkArgument(!call.isExternalCall()); + + // Don't log an already logged call. logCall() might be called multiple times + // for the same call due to b/24109437. + if (call.getLogState() != null && !call.getLogState().isLogged) { + getLegacyBindings(context).logCall(call); + call.getLogState().isLogged = true; + } + + if (updateCallInMap(call)) { + LogUtil.w( + "CallList.onCallRemoved", "Removing call not previously disconnected " + call.getId()); + } + } + } + + InCallUiLegacyBindings getLegacyBindings(Context context) { + Objects.requireNonNull(context); + + Context application = context.getApplicationContext(); + InCallUiLegacyBindings legacyInstance = null; + if (application instanceof InCallUiLegacyBindingsFactory) { + legacyInstance = ((InCallUiLegacyBindingsFactory) application).newInCallUiLegacyBindings(); + } + + if (legacyInstance == null) { + legacyInstance = new InCallUiLegacyBindingsStub(); + } + return legacyInstance; + } + + /** + * Handles the case where an internal call has become an exteral call. We need to + * + * @param context + * @param telecomCall + */ + public void onInternalCallMadeExternal(Context context, android.telecom.Call telecomCall) { + + if (mCallByTelecomCall.containsKey(telecomCall)) { + DialerCall call = mCallByTelecomCall.get(telecomCall); + + // Don't log an already logged call. logCall() might be called multiple times + // for the same call due to b/24109437. + if (call.getLogState() != null && !call.getLogState().isLogged) { + getLegacyBindings(context).logCall(call); + call.getLogState().isLogged = true; + } + + // When removing a call from the call list because it became an external call, we need to + // ensure the callback is unregistered -- this is normally only done when calls disconnect. + // However, the call won't be disconnected in this case. Also, logic in updateCallInMap + // would just re-add the call anyways. + call.unregisterCallback(); + mCallById.remove(call.getId()); + mCallByTelecomCall.remove(telecomCall); + } + } + + /** Called when a single call has changed. */ + private void onIncoming(DialerCall call) { + if (updateCallInMap(call)) { + LogUtil.i("CallList.onIncoming", String.valueOf(call)); + } + + for (Listener listener : mListeners) { + listener.onIncomingCall(call); + } + } + + public void addListener(@NonNull Listener listener) { + Objects.requireNonNull(listener); + + mListeners.add(listener); + + // Let the listener know about the active calls immediately. + listener.onCallListChange(this); + } + + public void removeListener(@Nullable Listener listener) { + if (listener != null) { + mListeners.remove(listener); + } + } + + /** + * TODO: Change so that this function is not needed. Instead of assuming there is an active call, + * the code should rely on the status of a specific DialerCall and allow the presenters to update + * the DialerCall object when the active call changes. + */ + public DialerCall getIncomingOrActive() { + DialerCall retval = getIncomingCall(); + if (retval == null) { + retval = getActiveCall(); + } + return retval; + } + + public DialerCall getOutgoingOrActive() { + DialerCall retval = getOutgoingCall(); + if (retval == null) { + retval = getActiveCall(); + } + return retval; + } + + /** A call that is waiting for {@link PhoneAccount} selection */ + public DialerCall getWaitingForAccountCall() { + return getFirstCallWithState(DialerCall.State.SELECT_PHONE_ACCOUNT); + } + + public DialerCall getPendingOutgoingCall() { + return getFirstCallWithState(DialerCall.State.CONNECTING); + } + + public DialerCall getOutgoingCall() { + DialerCall call = getFirstCallWithState(DialerCall.State.DIALING); + if (call == null) { + call = getFirstCallWithState(DialerCall.State.REDIALING); + } + if (call == null) { + call = getFirstCallWithState(DialerCall.State.PULLING); + } + return call; + } + + public DialerCall getActiveCall() { + return getFirstCallWithState(DialerCall.State.ACTIVE); + } + + public DialerCall getSecondActiveCall() { + return getCallWithState(DialerCall.State.ACTIVE, 1); + } + + public DialerCall getBackgroundCall() { + return getFirstCallWithState(DialerCall.State.ONHOLD); + } + + public DialerCall getDisconnectedCall() { + return getFirstCallWithState(DialerCall.State.DISCONNECTED); + } + + public DialerCall getDisconnectingCall() { + return getFirstCallWithState(DialerCall.State.DISCONNECTING); + } + + public DialerCall getSecondBackgroundCall() { + return getCallWithState(DialerCall.State.ONHOLD, 1); + } + + public DialerCall getActiveOrBackgroundCall() { + DialerCall call = getActiveCall(); + if (call == null) { + call = getBackgroundCall(); + } + return call; + } + + public DialerCall getIncomingCall() { + DialerCall call = getFirstCallWithState(DialerCall.State.INCOMING); + if (call == null) { + call = getFirstCallWithState(DialerCall.State.CALL_WAITING); + } + + return call; + } + + public DialerCall getFirstCall() { + DialerCall result = getIncomingCall(); + if (result == null) { + result = getPendingOutgoingCall(); + } + if (result == null) { + result = getOutgoingCall(); + } + if (result == null) { + result = getFirstCallWithState(DialerCall.State.ACTIVE); + } + if (result == null) { + result = getDisconnectingCall(); + } + if (result == null) { + result = getDisconnectedCall(); + } + return result; + } + + public boolean hasLiveCall() { + DialerCall call = getFirstCall(); + return call != null && call != getDisconnectingCall() && call != getDisconnectedCall(); + } + + /** + * Returns the first call found in the call map with the upgrade to video modification state. + * + * @return The first call with the upgrade to video state. + */ + public DialerCall getVideoUpgradeRequestCall() { + for (DialerCall call : mCallById.values()) { + if (call.getSessionModificationState() + == DialerCall.SESSION_MODIFICATION_STATE_RECEIVED_UPGRADE_TO_VIDEO_REQUEST) { + return call; + } + } + return null; + } + + public DialerCall getCallById(String callId) { + return mCallById.get(callId); + } + + /** Returns first call found in the call map with the specified state. */ + public DialerCall getFirstCallWithState(int state) { + return getCallWithState(state, 0); + } + + /** + * Returns the [position]th call found in the call map with the specified state. TODO: Improve + * this logic to sort by call time. + */ + public DialerCall getCallWithState(int state, int positionToFind) { + DialerCall retval = null; + int position = 0; + for (DialerCall call : mCallById.values()) { + if (call.getState() == state) { + if (position >= positionToFind) { + retval = call; + break; + } else { + position++; + } + } + } + + return retval; + } + + /** + * This is called when the service disconnects, either expectedly or unexpectedly. For the + * expected case, it's because we have no calls left. For the unexpected case, it is likely a + * crash of phone and we need to clean up our calls manually. Without phone, there can be no + * active calls, so this is relatively safe thing to do. + */ + public void clearOnDisconnect() { + for (DialerCall call : mCallById.values()) { + final int state = call.getState(); + if (state != DialerCall.State.IDLE + && state != DialerCall.State.INVALID + && state != DialerCall.State.DISCONNECTED) { + + call.setState(DialerCall.State.DISCONNECTED); + call.setDisconnectCause(new DisconnectCause(DisconnectCause.UNKNOWN)); + updateCallInMap(call); + } + } + notifyGenericListeners(); + } + + /** + * Called when the user has dismissed an error dialog. This indicates acknowledgement of the + * disconnect cause, and that any pending disconnects should immediately occur. + */ + public void onErrorDialogDismissed() { + final Iterator<DialerCall> iterator = mPendingDisconnectCalls.iterator(); + while (iterator.hasNext()) { + DialerCall call = iterator.next(); + iterator.remove(); + finishDisconnectedCall(call); + } + } + + /** + * Processes an update for a single call. + * + * @param call The call to update. + */ + private void onUpdateCall(DialerCall call) { + LogUtil.d("CallList.onUpdateCall", String.valueOf(call)); + if (!mCallById.containsKey(call.getId()) && call.isExternalCall()) { + // When a regular call becomes external, it is removed from the call list, and there may be + // pending updates to Telecom which are queued up on the Telecom call's handler which we no + // longer wish to cause updates to the call in the CallList. Bail here if the list of tracked + // calls doesn't contain the call which received the update. + return; + } + + if (updateCallInMap(call)) { + LogUtil.i("CallList.onUpdateCall", String.valueOf(call)); + } + } + + /** + * Sends a generic notification to all listeners that something has changed. It is up to the + * listeners to call back to determine what changed. + */ + private void notifyGenericListeners() { + for (Listener listener : mListeners) { + listener.onCallListChange(this); + } + } + + private void notifyListenersOfDisconnect(DialerCall call) { + for (Listener listener : mListeners) { + listener.onDisconnect(call); + } + } + + /** + * Updates the call entry in the local map. + * + * @return false if no call previously existed and no call was added, otherwise true. + */ + private boolean updateCallInMap(DialerCall call) { + Objects.requireNonNull(call); + + boolean updated = false; + + if (call.getState() == DialerCall.State.DISCONNECTED) { + // update existing (but do not add!!) disconnected calls + if (mCallById.containsKey(call.getId())) { + // For disconnected calls, we want to keep them alive for a few seconds so that the + // UI has a chance to display anything it needs when a call is disconnected. + + // Set up a timer to destroy the call after X seconds. + final Message msg = mHandler.obtainMessage(EVENT_DISCONNECTED_TIMEOUT, call); + mHandler.sendMessageDelayed(msg, getDelayForDisconnect(call)); + mPendingDisconnectCalls.add(call); + + mCallById.put(call.getId(), call); + mCallByTelecomCall.put(call.getTelecomCall(), call); + updated = true; + } + } else if (!isCallDead(call)) { + mCallById.put(call.getId(), call); + mCallByTelecomCall.put(call.getTelecomCall(), call); + updated = true; + } else if (mCallById.containsKey(call.getId())) { + mCallById.remove(call.getId()); + mCallByTelecomCall.remove(call.getTelecomCall()); + updated = true; + } + + return updated; + } + + private int getDelayForDisconnect(DialerCall call) { + if (call.getState() != DialerCall.State.DISCONNECTED) { + throw new IllegalStateException(); + } + + final int cause = call.getDisconnectCause().getCode(); + final int delay; + switch (cause) { + case DisconnectCause.LOCAL: + delay = DISCONNECTED_CALL_SHORT_TIMEOUT_MS; + break; + case DisconnectCause.REMOTE: + case DisconnectCause.ERROR: + delay = DISCONNECTED_CALL_MEDIUM_TIMEOUT_MS; + break; + case DisconnectCause.REJECTED: + case DisconnectCause.MISSED: + case DisconnectCause.CANCELED: + // no delay for missed/rejected incoming calls and canceled outgoing calls. + delay = 0; + break; + default: + delay = DISCONNECTED_CALL_LONG_TIMEOUT_MS; + break; + } + + return delay; + } + + private boolean isCallDead(DialerCall call) { + final int state = call.getState(); + return DialerCall.State.IDLE == state || DialerCall.State.INVALID == state; + } + + /** Sets up a call for deletion and notifies listeners of change. */ + private void finishDisconnectedCall(DialerCall call) { + if (mPendingDisconnectCalls.contains(call)) { + mPendingDisconnectCalls.remove(call); + } + call.setState(DialerCall.State.IDLE); + updateCallInMap(call); + notifyGenericListeners(); + } + + /** + * Notifies all video calls of a change in device orientation. + * + * @param rotation The new rotation angle (in degrees). + */ + public void notifyCallsOfDeviceRotation(int rotation) { + for (DialerCall call : mCallById.values()) { + // First, ensure that the call videoState has video enabled (there is no need to set + // device orientation on a voice call which has not yet been upgraded to video). + // Second, ensure a VideoCall is set on the call so that the change can be sent to the + // provider (a VideoCall can be present for a call that does not currently have video, + // but can be upgraded to video). + + // NOTE: is it necessary to use this order because getVideoCall references the class + // VideoProfile which is not available on APIs <23 (M). + if (VideoUtils.isVideoCall(call) && call.getVideoCall() != null) { + call.getVideoCall().setDeviceOrientation(rotation); + } + } + } + + public void onInCallUiShown(boolean forFullScreenIntent) { + for (DialerCall call : mCallById.values()) { + call.getLatencyReport().onInCallUiShown(forFullScreenIntent); + } + } + + /** Listener interface for any class that wants to be notified of changes to the call list. */ + public interface Listener { + + /** + * Called when a new incoming call comes in. This is the only method that gets called for + * incoming calls. Listeners that want to perform an action on incoming call should respond in + * this method because {@link #onCallListChange} does not automatically get called for incoming + * calls. + */ + void onIncomingCall(DialerCall call); + + /** + * Called when a new modify call request comes in This is the only method that gets called for + * modify requests. + */ + void onUpgradeToVideo(DialerCall call); + + /** Called when the session modification state of a call changes. */ + void onSessionModificationStateChange(@SessionModificationState int newState); + + /** + * Called anytime there are changes to the call list. The change can be switching call states, + * updating information, etc. This method will NOT be called for new incoming calls and for + * calls that switch to disconnected state. Listeners must add actions to those method + * implementations if they want to deal with those actions. + */ + void onCallListChange(CallList callList); + + /** + * Called when a call switches to the disconnected state. This is the only method that will get + * called upon disconnection. + */ + void onDisconnect(DialerCall call); + + void onWiFiToLteHandover(DialerCall call); + + /** + * Called when a user is in a video call and the call is unable to be handed off successfully to + * WiFi + */ + void onHandoverToWifiFailed(DialerCall call); + } + + private class DialerCallListenerImpl implements DialerCallListener { + + private final DialerCall mCall; + + DialerCallListenerImpl(DialerCall call) { + Assert.isNotNull(call); + mCall = call; + } + + @Override + public void onDialerCallDisconnect() { + if (updateCallInMap(mCall)) { + LogUtil.i("DialerCallListenerImpl.onDialerCallDisconnect", String.valueOf(mCall)); + // notify those listening for all disconnects + notifyListenersOfDisconnect(mCall); + } + } + + @Override + public void onDialerCallUpdate() { + Trace.beginSection("onUpdate"); + onUpdateCall(mCall); + notifyGenericListeners(); + Trace.endSection(); + } + + @Override + public void onDialerCallChildNumberChange() {} + + @Override + public void onDialerCallLastForwardedNumberChange() {} + + @Override + public void onDialerCallUpgradeToVideo() { + for (Listener listener : mListeners) { + listener.onUpgradeToVideo(mCall); + } + } + + @Override + public void onWiFiToLteHandover() { + for (Listener listener : mListeners) { + listener.onWiFiToLteHandover(mCall); + } + } + + @Override + public void onHandoverToWifiFailure() { + for (Listener listener : mListeners) { + listener.onHandoverToWifiFailed(mCall); + } + } + + @Override + public void onDialerCallSessionModificationStateChange(@SessionModificationState int state) { + for (Listener listener : mListeners) { + listener.onSessionModificationStateChange(state); + } + } + } +} |