Accept timeout from apps requesting call disconnect

When processing a call disconnected via the ACTION_NEW_OUTGOING_CALL
broadcast, use the timeout specified by the app instead of the default
timeout. This allows apps that may take a bit longer to place a new call
some more time if they need it.

This also changes the default timeout to 500ms and sets a max timeout of
10s for an app-specified timeout.

Bug: 34474757
Test: manual, unit
Merged-In: I3121dcaf973a10043ffcaa0c8251aa21e18d027e
Change-Id: I1b535f17aefffe423cfbc2cb1d42352e8f4b57f0
diff --git a/src/com/android/server/telecom/Call.java b/src/com/android/server/telecom/Call.java
index 18c9c05..99a1c8c 100644
--- a/src/com/android/server/telecom/Call.java
+++ b/src/com/android/server/telecom/Call.java
@@ -110,7 +110,7 @@
         void onConnectionManagerPhoneAccountChanged(Call call);
         void onPhoneAccountChanged(Call call);
         void onConferenceableCallsChanged(Call call);
-        boolean onCanceledViaNewOutgoingCallBroadcast(Call call);
+        boolean onCanceledViaNewOutgoingCallBroadcast(Call call, long disconnectionTimeout);
         void onHoldToneRequested(Call call);
         void onConnectionEvent(Call call, String event, Bundle extras);
         void onExternalCallChanged(Call call, boolean isExternalCall);
@@ -172,7 +172,7 @@
         @Override
         public void onConferenceableCallsChanged(Call call) {}
         @Override
-        public boolean onCanceledViaNewOutgoingCallBroadcast(Call call) {
+        public boolean onCanceledViaNewOutgoingCallBroadcast(Call call, long disconnectionTimeout) {
             return false;
         }
 
@@ -1314,14 +1314,14 @@
 
     @VisibleForTesting
     public void disconnect() {
-        disconnect(false);
+        disconnect(0);
     }
 
     /**
      * Attempts to disconnect the call through the connection service.
      */
     @VisibleForTesting
-    public void disconnect(boolean wasViaNewOutgoingCallBroadcaster) {
+    public void disconnect(long disconnectionTimeout) {
         Log.event(this, Log.Events.REQUEST_DISCONNECT);
 
         // Track that the call is now locally disconnecting.
@@ -1330,7 +1330,7 @@
         if (mState == CallState.NEW || mState == CallState.SELECT_PHONE_ACCOUNT ||
                 mState == CallState.CONNECTING) {
             Log.v(this, "Aborting call %s", this);
-            abort(wasViaNewOutgoingCallBroadcaster);
+            abort(disconnectionTimeout);
         } else if (mState != CallState.ABORTED && mState != CallState.DISCONNECTED) {
             if (mConnectionService == null) {
                 Log.e(this, new Exception(), "disconnect() request on a call without a"
@@ -1346,22 +1346,25 @@
         }
     }
 
-    void abort(boolean wasViaNewOutgoingCallBroadcaster) {
+    void abort(long disconnectionTimeout) {
         if (mCreateConnectionProcessor != null &&
                 !mCreateConnectionProcessor.isProcessingComplete()) {
             mCreateConnectionProcessor.abort();
         } else if (mState == CallState.NEW || mState == CallState.SELECT_PHONE_ACCOUNT
                 || mState == CallState.CONNECTING) {
-            if (wasViaNewOutgoingCallBroadcaster) {
-                // If the cancelation was from NEW_OUTGOING_CALL, then we do not automatically
-                // destroy the call.  Instead, we announce the cancelation and CallsManager handles
+            if (disconnectionTimeout > 0) {
+                // If the cancelation was from NEW_OUTGOING_CALL with a timeout of > 0
+                // milliseconds, do not destroy the call.
+                // Instead, we announce the cancellation and CallsManager handles
                 // it through a timer. Since apps often cancel calls through NEW_OUTGOING_CALL and
                 // then re-dial them quickly using a gateway, allowing the first call to end
                 // causes jank. This timeout allows CallsManager to transition the first call into
                 // the second call so that in-call only ever sees a single call...eliminating the
-                // jank altogether.
+                // jank altogether. The app will also be able to set the timeout via an extra on
+                // the ordered broadcast.
                 for (Listener listener : mListeners) {
-                    if (listener.onCanceledViaNewOutgoingCallBroadcast(this)) {
+                    if (listener.onCanceledViaNewOutgoingCallBroadcast(
+                            this, disconnectionTimeout)) {
                         // The first listener to handle this wins. A return value of true means that
                         // the listener will handle the disconnection process later and so we
                         // should not continue it here.
diff --git a/src/com/android/server/telecom/CallsManager.java b/src/com/android/server/telecom/CallsManager.java
index d6462d7..cd2aa85 100644
--- a/src/com/android/server/telecom/CallsManager.java
+++ b/src/com/android/server/telecom/CallsManager.java
@@ -552,7 +552,8 @@
     }
 
     @Override
-    public boolean onCanceledViaNewOutgoingCallBroadcast(final Call call) {
+    public boolean onCanceledViaNewOutgoingCallBroadcast(final Call call,
+            long disconnectionTimeout) {
         mPendingCallsToDisconnect.add(call);
         mHandler.postDelayed(new Runnable("CM.oCVNOCB", mLock) {
             @Override
@@ -562,7 +563,7 @@
                     call.disconnect();
                 }
             }
-        }.prepare(), Timeouts.getNewOutgoingCallCancelMillis(mContext.getContentResolver()));
+        }.prepare(), disconnectionTimeout);
 
         return true;
     }
diff --git a/src/com/android/server/telecom/NewOutgoingCallIntentBroadcaster.java b/src/com/android/server/telecom/NewOutgoingCallIntentBroadcaster.java
index 8b5604b..b63baaf 100644
--- a/src/com/android/server/telecom/NewOutgoingCallIntentBroadcaster.java
+++ b/src/com/android/server/telecom/NewOutgoingCallIntentBroadcaster.java
@@ -24,6 +24,7 @@
 import android.content.Intent;
 import android.content.res.Resources;
 import android.net.Uri;
+import android.os.Bundle;
 import android.os.Trace;
 import android.os.UserHandle;
 import android.telecom.GatewayInfo;
@@ -112,19 +113,24 @@
                             Log.pii(resultNumber));
 
                     boolean endEarly = false;
+                    long disconnectTimeout =
+                            Timeouts.getNewOutgoingCallCancelMillis(mContext.getContentResolver());
                     if (resultNumber == null) {
                         Log.v(this, "Call cancelled (null number), returning...");
+                        disconnectTimeout = getDisconnectTimeoutFromApp(
+                                getResultExtras(false), disconnectTimeout);
                         endEarly = true;
                     } else if (mPhoneNumberUtilsAdapter.isPotentialLocalEmergencyNumber(
                             mContext, resultNumber)) {
                         Log.w(this, "Cannot modify outgoing call to emergency number %s.",
                                 resultNumber);
+                        disconnectTimeout = 0;
                         endEarly = true;
                     }
 
                     if (endEarly) {
                         if (mCall != null) {
-                            mCall.disconnect(true /* wasViaNewOutgoingCall */);
+                            mCall.disconnect(disconnectTimeout);
                         }
                         return;
                     }
@@ -445,4 +451,18 @@
             intent.setAction(action);
         }
     }
+
+    private long getDisconnectTimeoutFromApp(Bundle resultExtras, long defaultTimeout) {
+        if (resultExtras != null) {
+            long disconnectTimeout = resultExtras.getLong(
+                    TelecomManager.EXTRA_NEW_OUTGOING_CALL_CANCEL_TIMEOUT, defaultTimeout);
+            if (disconnectTimeout < 0) {
+                disconnectTimeout = 0;
+            }
+            return Math.min(disconnectTimeout,
+                    Timeouts.getMaxNewOutgoingCallCancelMillis(mContext.getContentResolver()));
+        } else {
+            return defaultTimeout;
+        }
+    }
 }
diff --git a/src/com/android/server/telecom/Timeouts.java b/src/com/android/server/telecom/Timeouts.java
index 7be59c3..b2f4b6d 100644
--- a/src/com/android/server/telecom/Timeouts.java
+++ b/src/com/android/server/telecom/Timeouts.java
@@ -63,7 +63,16 @@
      * in-call UI.
      */
     public static long getNewOutgoingCallCancelMillis(ContentResolver contentResolver) {
-        return get(contentResolver, "new_outgoing_call_cancel_ms", 100000L);
+        return get(contentResolver, "new_outgoing_call_cancel_ms", 500L);
+    }
+
+    /**
+     * Returns the maximum amount of time to wait before disconnecting a call that was canceled via
+     * NEW_OUTGOING_CALL broadcast. This prevents malicious or poorly configured apps from
+     * forever tying up the Telecom stack.
+     */
+    public static long getMaxNewOutgoingCallCancelMillis(ContentResolver contentResolver) {
+        return get(contentResolver, "max_new_outgoing_call_cancel_ms", 10000L);
     }
 
     /**
diff --git a/tests/src/com/android/server/telecom/tests/NewOutgoingCallIntentBroadcasterTest.java b/tests/src/com/android/server/telecom/tests/NewOutgoingCallIntentBroadcasterTest.java
index 54613d4..9b7dc37 100644
--- a/tests/src/com/android/server/telecom/tests/NewOutgoingCallIntentBroadcasterTest.java
+++ b/tests/src/com/android/server/telecom/tests/NewOutgoingCallIntentBroadcasterTest.java
@@ -313,7 +313,28 @@
 
         result.receiver.onReceive(mContext, result.intent);
         verifyNoCallPlaced();
-        verify(mCall).disconnect(true);
+        ArgumentCaptor<Long> timeoutCaptor = ArgumentCaptor.forClass(Long.class);
+        verify(mCall).disconnect(timeoutCaptor.capture());
+        assertTrue(timeoutCaptor.getValue() > 0);
+    }
+
+    @SmallTest
+    public void testCallNumberModifiedToNullWithLongCustomTimeout() {
+        Uri handle = Uri.parse("tel:6505551234");
+        Intent callIntent = buildIntent(handle, Intent.ACTION_CALL, null);
+        ReceiverIntentPair result = regularCallTestHelper(callIntent, null);
+
+        long customTimeout = 100000000;
+        Bundle bundle = new Bundle();
+        bundle.putLong(TelecomManager.EXTRA_NEW_OUTGOING_CALL_CANCEL_TIMEOUT, customTimeout);
+        result.receiver.setResultData(null);
+        result.receiver.setResultExtras(bundle);
+
+        result.receiver.onReceive(mContext, result.intent);
+        verifyNoCallPlaced();
+        ArgumentCaptor<Long> timeoutCaptor = ArgumentCaptor.forClass(Long.class);
+        verify(mCall).disconnect(timeoutCaptor.capture());
+        assertTrue(timeoutCaptor.getValue() < customTimeout);
     }
 
     @SmallTest
@@ -328,7 +349,7 @@
         doReturn(true).when(mPhoneNumberUtilsAdapterSpy).isPotentialLocalEmergencyNumber(
                 any(Context.class), eq(newEmergencyNumber));
         result.receiver.onReceive(mContext, result.intent);
-        verify(mCall).disconnect(true);
+        verify(mCall).disconnect(eq(0L));
     }
 
     private ReceiverIntentPair regularCallTestHelper(Intent intent,