Merge "Handle providing disconnect message through CallRedirectionService." into sc-dev
diff --git a/src/com/android/server/telecom/Call.java b/src/com/android/server/telecom/Call.java
index c4f398c..0bdf58d 100644
--- a/src/com/android/server/telecom/Call.java
+++ b/src/com/android/server/telecom/Call.java
@@ -39,6 +39,7 @@
 import android.provider.ContactsContract.Contacts;
 import android.telecom.BluetoothCallQualityReport;
 import android.telecom.CallAudioState;
+import android.telecom.CallDiagnosticService;
 import android.telecom.CallerInfo;
 import android.telecom.Conference;
 import android.telecom.Connection;
@@ -60,6 +61,7 @@
 import android.telephony.PhoneNumberUtils;
 import android.telephony.TelephonyManager;
 import android.telephony.emergency.EmergencyNumber;
+import android.telephony.ims.ImsReasonInfo;
 import android.text.TextUtils;
 import android.util.ArrayMap;
 import android.widget.Toast;
@@ -81,7 +83,9 @@
 import java.util.Map;
 import java.util.Objects;
 import java.util.Set;
+import java.util.concurrent.CompletableFuture;
 import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.TimeUnit;
 
 /**
  *  Encapsulates all aspects of a given phone call throughout its lifecycle, starting
@@ -662,6 +666,22 @@
     private boolean mIsSimCall;
 
     /**
+     * Set to {@code true} if we received a valid response ({@code null} or otherwise) from
+     * the {@link DiagnosticCall#onCallDisconnected(ImsReasonInfo)} or
+     * {@link DiagnosticCall#onCallDisconnected(int, int)} calls.  This is used to detect a timeout
+     * when awaiting a response from the call diagnostic service.
+     */
+    private boolean mReceivedCallDiagnosticPostCallResponse = false;
+
+    /**
+     * {@link CompletableFuture} used to delay posting disconnection and removal to a call until
+     * after a {@link CallDiagnosticService} is able to handle the disconnection and provide a
+     * disconnect message via {@link DiagnosticCall#onCallDisconnected(ImsReasonInfo)} or
+     * {@link DiagnosticCall#onCallDisconnected(int, int)}.
+     */
+    private CompletableFuture<Boolean> mDisconnectFuture;
+
+    /**
      * Persists the specified parameters and initializes the new instance.
      * @param context The context.
      * @param repository The connection service repository.
@@ -1092,8 +1112,29 @@
         }
     }
 
+    /**
+     * Handles an incoming overridden disconnect message for this call.
+     *
+     * We only care if the disconnect is handled via a future.
+     * @param message the overridden disconnect message.
+     */
     public void handleOverrideDisconnectMessage(@Nullable CharSequence message) {
+        Log.i(this, "handleOverrideDisconnectMessage; callid=%s, msg=%s", getId(), message);
 
+        if (isDisconnectHandledViaFuture()) {
+            mReceivedCallDiagnosticPostCallResponse = true;
+            if (message != null) {
+                Log.addEvent(this, LogUtils.Events.OVERRIDE_DISCONNECT_MESSAGE, message);
+                // Replace the existing disconnect cause in this call
+                setOverrideDisconnectCauseCode(new DisconnectCause(DisconnectCause.ERROR, message,
+                        message, null));
+            }
+
+            mDisconnectFuture.complete(true);
+        } else {
+            Log.w(this, "handleOverrideDisconnectMessage; callid=%s - got override when unbound",
+                    getId());
+        }
     }
 
     /**
@@ -4088,4 +4129,66 @@
     public boolean isSimCall() {
         return mIsSimCall;
     }
+
+    /**
+     * Sets whether this is a sim call or not.
+     * @param isSimCall {@code true} if this is a SIM call, {@code false} otherwise.
+     */
+    public void setIsSimCall(boolean isSimCall) {
+        mIsSimCall = isSimCall;
+    }
+
+    /**
+     * Initializes a disconnect future which is used to chain up pending operations which take
+     * place when the {@link CallDiagnosticService} returns the result of the
+     * {@link DiagnosticCall#onCallDisconnected(int, int)} or
+     * {@link DiagnosticCall#onCallDisconnected(ImsReasonInfo)} invocation via
+     * {@link CallDiagnosticServiceAdapter}.  If no {@link CallDiagnosticService} is in use, we
+     * would not try to make a disconnect future.
+     * @param timeoutMillis Timeout we use for waiting for the response.
+     * @return the {@link CompletableFuture}.
+     */
+    public CompletableFuture<Boolean> initializeDisconnectFuture(long timeoutMillis) {
+        if (mDisconnectFuture == null) {
+            mDisconnectFuture = new CompletableFuture<Boolean>()
+                    .completeOnTimeout(false, timeoutMillis, TimeUnit.MILLISECONDS);
+            // After all the chained stuff we will report where the CDS timed out.
+            mDisconnectFuture.thenRunAsync(() -> {
+                if (!mReceivedCallDiagnosticPostCallResponse) {
+                    Log.addEvent(this, LogUtils.Events.CALL_DIAGNOSTIC_SERVICE_TIMEOUT);
+                }},
+                new LoggedHandlerExecutor(mHandler, "C.iDF", mLock))
+                    .exceptionally((throwable) -> {
+                        Log.e(this, throwable, "Error while executing disconnect future");
+                        return null;
+                    });
+        }
+        return mDisconnectFuture;
+    }
+
+    /**
+     * @return the disconnect future, if initialized.  Used for chaining operations after creation.
+     */
+    public CompletableFuture<Boolean> getDisconnectFuture() {
+        return mDisconnectFuture;
+    }
+
+    /**
+     * @return {@code true} if disconnection and removal is handled via a future, or {@code false}
+     * if this is handled immediately.
+     */
+    public boolean isDisconnectHandledViaFuture() {
+        return mDisconnectFuture != null && !mDisconnectFuture.isDone();
+    }
+
+    /**
+     * Perform any cleanup on this call as a result of a {@link TelecomServiceImpl}
+     * {@code cleanupStuckCalls} request.
+     */
+    public void cleanup() {
+        if (mDisconnectFuture != null) {
+            mDisconnectFuture.complete(false);
+            mDisconnectFuture = null;
+        }
+    }
 }
diff --git a/src/com/android/server/telecom/CallDiagnosticServiceController.java b/src/com/android/server/telecom/CallDiagnosticServiceController.java
index 943a176..547bdcd 100644
--- a/src/com/android/server/telecom/CallDiagnosticServiceController.java
+++ b/src/com/android/server/telecom/CallDiagnosticServiceController.java
@@ -36,6 +36,7 @@
 import android.telecom.CallDiagnosticService;
 import android.telecom.ConnectionService;
 import android.telecom.DiagnosticCall;
+import android.telecom.DisconnectCause;
 import android.telecom.InCallService;
 import android.telecom.Log;
 import android.telecom.ParcelableCall;
@@ -255,6 +256,32 @@
     }
 
     /**
+     * Handles a newly disconnected call signalled from {@link CallsManager}.
+     * @param call The call
+     * @param disconnectCause The disconnect cause
+     * @return {@code true} if the {@link CallDiagnosticService} was sent the call, {@code false}
+     * if the call was not applicable to the CDS or if there was an issue sending it.
+     */
+    public boolean onCallDisconnected(@NonNull Call call,
+            @NonNull DisconnectCause disconnectCause) {
+        if (!call.isSimCall() || call.isExternalCall()) {
+            Log.i(this, "onCallDisconnected: skipping call %s as non-sim or external.",
+                    call.getId());
+            return false;
+        }
+        String callId = mCallIdMapper.getCallId(call);
+        try {
+            if (isConnected()) {
+                mCallDiagnosticService.notifyCallDisconnected(callId, disconnectCause);
+                return true;
+            }
+        } catch (RemoteException e) {
+            Log.w(this, "onCallDisconnected: callId=%s, exception=%s", call.getId(), e);
+        }
+        return false;
+    }
+
+    /**
      * Handles Telecom removal of calls; will remove the call from the bound service and if the
      * number of tracked calls falls to zero, unbind from the service.
      * @param call The call to remove from the bound CDS.
@@ -569,7 +596,7 @@
     /**
      * @return {@code true} if the call diagnostic service is bound/connected.
      */
-    private boolean isConnected() {
+    public boolean isConnected() {
         return mCallDiagnosticService != null;
     }
 
diff --git a/src/com/android/server/telecom/CallsManager.java b/src/com/android/server/telecom/CallsManager.java
index d2d50ad..e6be6f6 100755
--- a/src/com/android/server/telecom/CallsManager.java
+++ b/src/com/android/server/telecom/CallsManager.java
@@ -3099,27 +3099,82 @@
             // be marked as missed.
             call.setOverrideDisconnectCauseCode(new DisconnectCause(DisconnectCause.MISSED));
         }
-        call.setDisconnectCause(disconnectCause);
-        setCallState(call, CallState.DISCONNECTED, "disconnected set explicitly");
 
-        if(oldState == CallState.NEW && disconnectCause.getCode() == DisconnectCause.MISSED) {
+        // If a call diagnostic service is in use, we will log the original telephony-provided
+        // disconnect cause, inform the CDS of the disconnection, and then chain the update of the
+        // call state until AFTER the CDS reports it's result back.
+        if (oldState == CallState.ACTIVE && disconnectCause.getCode() != DisconnectCause.MISSED
+                && mCallDiagnosticServiceController.isConnected()
+                && mCallDiagnosticServiceController.onCallDisconnected(call, disconnectCause)) {
+            Log.i(this, "markCallAsDisconnected; callid=%s, postingToFuture.", call.getId());
+
+            // Log the original disconnect reason prior to calling into the
+            // CallDiagnosticService.
+            Log.addEvent(call, LogUtils.Events.SET_DISCONNECTED_ORIG, disconnectCause);
+
+            // Setup the future with a timeout so that the CDS is time boxed.
+            CompletableFuture<Boolean> future = call.initializeDisconnectFuture(
+                    mTimeoutsAdapter.getCallDiagnosticServiceTimeoutMillis(
+                            mContext.getContentResolver()));
+
+            // Post the disconnection updates to the future for completion once the CDS returns
+            // with it's overridden disconnect message.
+            future.thenRunAsync(() -> {
+                call.setDisconnectCause(disconnectCause);
+                setCallState(call, CallState.DISCONNECTED, "disconnected set explicitly");
+            }, new LoggedHandlerExecutor(mHandler, "CM.mCAD", mLock))
+                    .exceptionally((throwable) -> {
+                        Log.e(TAG, throwable, "Error while executing disconnect future.");
+                        return null;
+                    });
+        } else {
+            // No CallDiagnosticService, or it doesn't handle this call, so just do this
+            // synchronously as always.
+            call.setDisconnectCause(disconnectCause);
+            setCallState(call, CallState.DISCONNECTED, "disconnected set explicitly");
+        }
+
+        if (oldState == CallState.NEW && disconnectCause.getCode() == DisconnectCause.MISSED) {
             Log.i(this, "markCallAsDisconnected: logging missed call ");
             mCallLogManager.logCall(call, Calls.MISSED_TYPE, true, null);
         }
-
     }
 
     /**
      * Removes an existing disconnected call, and notifies the in-call app.
      */
     void markCallAsRemoved(Call call) {
+        if (call.isDisconnectHandledViaFuture()) {
+            Log.i(this, "markCallAsRemoved; callid=%s, postingToFuture.", call.getId());
+            // A future is being used due to a CallDiagnosticService handling the call.  We will
+            // chain the removal operation to the end of any outstanding disconnect work.
+            call.getDisconnectFuture().thenRunAsync(() -> {
+                performRemoval(call);
+            }, new LoggedHandlerExecutor(mHandler, "CM.mCAR", mLock))
+                    .exceptionally((throwable) -> {
+                        Log.e(TAG, throwable, "Error while executing disconnect future");
+                        return null;
+                    });
+
+        } else {
+            Log.i(this, "markCallAsRemoved; callid=%s, immediate.", call.getId());
+            performRemoval(call);
+        }
+    }
+
+    /**
+     * Work which is completed when a call is to be removed. Can either be be run synchronously or
+     * posted to a {@link Call#getDisconnectFuture()}.
+     * @param call The call.
+     */
+    private void performRemoval(Call call) {
         mInCallController.getBindingFuture().thenRunAsync(() -> {
             call.maybeCleanupHandover();
             removeCall(call);
             Call foregroundCall = mCallAudioManager.getPossiblyHeldForegroundCall();
             if (mLocallyDisconnectingCalls.contains(call)) {
                 boolean isDisconnectingChildCall = call.isDisconnectingChildCall();
-                Log.v(this, "markCallAsRemoved: isDisconnectingChildCall = "
+                Log.v(this, "performRemoval: isDisconnectingChildCall = "
                         + isDisconnectingChildCall + "call -> %s", call);
                 mLocallyDisconnectingCalls.remove(call);
                 // Auto-unhold the foreground call due to a locally disconnected call, except if the
@@ -3136,10 +3191,11 @@
                 // The new foreground call is on hold, however the carrier does not display the hold
                 // button in the UI.  Therefore, we need to auto unhold the held call since the user
                 // has no means of unholding it themselves.
-                Log.i(this, "Auto-unholding held foreground call (call doesn't support hold)");
+                Log.i(this, "performRemoval: Auto-unholding held foreground call (call doesn't "
+                        + "support hold)");
                 foregroundCall.unhold();
             }
-        }, new LoggedHandlerExecutor(mHandler, "CM.mCAR", mLock))
+        }, new LoggedHandlerExecutor(mHandler, "CM.pR", mLock))
                 .exceptionally((throwable) -> {
                     Log.e(TAG, throwable, "Error while executing call removal");
                     return null;
diff --git a/src/com/android/server/telecom/ConnectionServiceWrapper.java b/src/com/android/server/telecom/ConnectionServiceWrapper.java
index 705fe4f..316a04d 100755
--- a/src/com/android/server/telecom/ConnectionServiceWrapper.java
+++ b/src/com/android/server/telecom/ConnectionServiceWrapper.java
@@ -352,7 +352,7 @@
                     logIncoming("removeCall %s", callId);
                     Call call = mCallIdMapper.getCall(callId);
                     if (call != null) {
-                        if (call.isAlive()) {
+                        if (call.isAlive() && !call.isDisconnectHandledViaFuture()) {
                             mCallsManager.markCallAsDisconnected(
                                     call, new DisconnectCause(DisconnectCause.REMOTE));
                         } else {
diff --git a/src/com/android/server/telecom/LogUtils.java b/src/com/android/server/telecom/LogUtils.java
index a9bf18c..6270828 100644
--- a/src/com/android/server/telecom/LogUtils.java
+++ b/src/com/android/server/telecom/LogUtils.java
@@ -197,6 +197,10 @@
         public static final String REDIRECTION_USER_CONFIRMED = "REDIRECTION_USER_CONFIRMED";
         public static final String REDIRECTION_USER_CANCELLED = "REDIRECTION_USER_CANCELLED";
         public static final String BT_QUALITY_REPORT = "BT_QUALITY_REPORT";
+        public static final String SET_DISCONNECTED_ORIG = "SET_DISCONNECTED_ORIG";
+        public static final String OVERRIDE_DISCONNECT_MESSAGE = "OVERRIDE_DISCONNECT_MSG";
+        public static final String CALL_DIAGNOSTIC_SERVICE_TIMEOUT =
+                "CALL_DIAGNOSTIC_SERVICE_TIMEOUT";
 
         public static class Timings {
             public static final String ACCEPT_TIMING = "accept";
diff --git a/src/com/android/server/telecom/TelecomServiceImpl.java b/src/com/android/server/telecom/TelecomServiceImpl.java
index a18ae6d..dd2d034 100644
--- a/src/com/android/server/telecom/TelecomServiceImpl.java
+++ b/src/com/android/server/telecom/TelecomServiceImpl.java
@@ -1776,6 +1776,8 @@
          * {@link CallState#DISCONNECTED} or {@link CallState#DISCONNECTING} states. Stuck calls
          * during CTS cause cascading failures, so if the CTS test detects such a state, it should
          * call this method via a shell command to clean up before moving on to the next test.
+         * Also cleans up any pending futures related to
+         * {@link android.telecom.CallDiagnosticService}s.
          */
         @Override
         public void cleanupStuckCalls() {
@@ -1785,6 +1787,7 @@
                     enforceShellOnly(Binder.getCallingUid(), "cleanupStuckCalls");
                     Binder.withCleanCallingIdentity(() -> {
                         for (Call call : mCallsManager.getCalls()) {
+                            call.cleanup();
                             if (call.getState() == CallState.DISCONNECTED
                                     || call.getState() == CallState.DISCONNECTING) {
                                 mCallsManager.markCallAsRemoved(call);
diff --git a/src/com/android/server/telecom/Timeouts.java b/src/com/android/server/telecom/Timeouts.java
index 4d53d6d..22d1fbd 100644
--- a/src/com/android/server/telecom/Timeouts.java
+++ b/src/com/android/server/telecom/Timeouts.java
@@ -18,7 +18,11 @@
 
 import android.content.ContentResolver;
 import android.provider.Settings;
+import android.telecom.CallDiagnosticService;
 import android.telecom.CallRedirectionService;
+import android.telecom.DiagnosticCall;
+import android.telephony.ims.ImsReasonInfo;
+
 import java.util.concurrent.TimeUnit;
 
 /**
@@ -67,6 +71,10 @@
         public long getCallRecordingToneRepeatIntervalMillis(ContentResolver cr) {
             return Timeouts.getCallRecordingToneRepeatIntervalMillis(cr);
         }
+
+        public long getCallDiagnosticServiceTimeoutMillis(ContentResolver cr) {
+            return Timeouts.getCallDiagnosticServiceTimeoutMillis(cr);
+        }
     }
 
     /** A prefix to use for all keys so to not clobber the global namespace. */
@@ -190,7 +198,7 @@
     /**
      * Returns the amount of time for an user-defined {@link CallRedirectionService}.
      *
-     * @param contentResolver The content resolved.
+     * @param contentResolver The content resolver.
      */
     public static long getUserDefinedCallRedirectionTimeoutMillis(ContentResolver contentResolver) {
         return get(contentResolver, "user_defined_call_redirection_timeout",
@@ -200,7 +208,7 @@
     /**
      * Returns the amount of time for a carrier {@link CallRedirectionService}.
      *
-     * @param contentResolver The content resolved.
+     * @param contentResolver The content resolver.
      */
     public static long getCarrierCallRedirectionTimeoutMillis(ContentResolver contentResolver) {
         return get(contentResolver, "carrier_call_redirection_timeout", 5000L /* 5 seconds */);
@@ -214,6 +222,17 @@
     }
 
     /**
+     * Returns the maximum amount of time a {@link CallDiagnosticService} is permitted to take to
+     * return back from {@link DiagnosticCall#onCallDisconnected(ImsReasonInfo)} and
+     * {@link DiagnosticCall#onCallDisconnected(int, int)}.
+     * @param contentResolver The resolver for the config option.
+     * @return The timeout in millis.
+     */
+    public static long getCallDiagnosticServiceTimeoutMillis(ContentResolver contentResolver) {
+        return get(contentResolver, "call_diagnostic_service_timeout", 2000L /* 2 sec */);
+    }
+
+    /**
      * Returns the number of milliseconds for which the system should exempt the default dialer from
      * power save restrictions due to the dialer needing to handle a missed call notification
      * (update call log, check VVM, etc...).
diff --git a/testapps/src/com/android/server/telecom/testapps/TestCallDiagnosticService.java b/testapps/src/com/android/server/telecom/testapps/TestCallDiagnosticService.java
index 73bf438..36554ec 100644
--- a/testapps/src/com/android/server/telecom/testapps/TestCallDiagnosticService.java
+++ b/testapps/src/com/android/server/telecom/testapps/TestCallDiagnosticService.java
@@ -51,14 +51,14 @@
         @Override
         public CharSequence onCallDisconnected(int disconnectCause, int preciseDisconnectCause) {
             Log.i(this, "onCallDisconnected");
-            return null;
+            return "GSM/CDMA call dropped because " + disconnectCause;
         }
 
         @Nullable
         @Override
         public CharSequence onCallDisconnected(@NonNull ImsReasonInfo disconnectReason) {
             Log.i(this, "onCallDisconnected");
-            return null;
+            return "ImsCall dropped because something happened " + disconnectReason.mExtraMessage;
         }
 
         @Override
diff --git a/tests/src/com/android/server/telecom/tests/CallsManagerTest.java b/tests/src/com/android/server/telecom/tests/CallsManagerTest.java
index 08f3536..71e6b35 100644
--- a/tests/src/com/android/server/telecom/tests/CallsManagerTest.java
+++ b/tests/src/com/android/server/telecom/tests/CallsManagerTest.java
@@ -41,6 +41,7 @@
 import static org.mockito.Mockito.when;
 
 import android.content.ComponentName;
+import android.content.ContentResolver;
 import android.content.Context;
 import android.content.Intent;
 import android.net.Uri;
@@ -52,6 +53,7 @@
 import android.os.UserHandle;
 import android.telecom.CallerInfo;
 import android.telecom.Connection;
+import android.telecom.DisconnectCause;
 import android.telecom.PhoneAccount;
 import android.telecom.PhoneAccountHandle;
 import android.telecom.TelecomManager;
@@ -228,6 +230,8 @@
         doNothing().when(mRoleManagerAdapter).setCurrentUserHandle(any());
         when(mDisconnectedCallNotifierFactory.create(any(Context.class),any(CallsManager.class)))
                 .thenReturn(mDisconnectedCallNotifier);
+        when(mTimeoutsAdapter.getCallDiagnosticServiceTimeoutMillis(any(ContentResolver.class)))
+                .thenReturn(2000L);
         mCallsManager = new CallsManager(
                 mComponentContextFixture.getTestDouble().getApplicationContext(),
                 mLock,
@@ -1508,6 +1512,39 @@
                 eq(CallState.ACTIVE));
     }
 
+    /**
+     * Verifies where a call diagnostic service is NOT in use that we don't try to relay to the
+     * CallDiagnosticService and that we get a synchronous disconnect.
+     * @throws Exception
+     */
+    @MediumTest
+    @Test
+    public void testDisconnectCallSynchronous() throws Exception {
+        Call callSpy = addSpyCall();
+        callSpy.setIsSimCall(true);
+        when(mCallDiagnosticServiceController.isConnected()).thenReturn(false);
+        mCallsManager.markCallAsDisconnected(callSpy, new DisconnectCause(DisconnectCause.ERROR));
+
+        verify(mCallDiagnosticServiceController, never()).onCallDisconnected(any(Call.class),
+                any(DisconnectCause.class));
+        verify(callSpy).setDisconnectCause(any(DisconnectCause.class));
+    }
+
+    @MediumTest
+    @Test
+    public void testDisconnectCallAsynchronous() throws Exception {
+        Call callSpy = addSpyCall();
+        callSpy.setIsSimCall(true);
+        when(mCallDiagnosticServiceController.isConnected()).thenReturn(true);
+        when(mCallDiagnosticServiceController.onCallDisconnected(any(Call.class),
+                any(DisconnectCause.class))).thenReturn(true);
+        mCallsManager.markCallAsDisconnected(callSpy, new DisconnectCause(DisconnectCause.ERROR));
+
+        verify(mCallDiagnosticServiceController).onCallDisconnected(any(Call.class),
+                any(DisconnectCause.class));
+        verify(callSpy, never()).setDisconnectCause(any(DisconnectCause.class));
+    }
+
     private Call addSpyCall() {
         return addSpyCall(SIM_2_HANDLE, CallState.ACTIVE);
     }