Add timeout for emergency calls

Currently connection managers are only able to route
emergency calls if the call can't be placed by the
normal phone account first.

Unfortunately this strategy only works for "fake" emergency
calls (for example when you set a fake number using
ril.ecclist).

When actually dialing "911" the call doesn't disconnect
even if there's no service. This means that the connection
manager never gets a chance to handle the call.

To fix this issue we're adding a timeout for emergency calls
if a connection manager is present. If after 25 seconds
the call is not yet active then we'll disconnect the call
and route it through the connection manager. The timeout
length is 60 seconds if the device is in airplane mode.
Both timeout values can be modified using config values.

To limit the impact of this change we've also added the
following extra condition:
  - only use a connection manager if we have wifi
    connectivity but no cellular connectivity

BUG: 19020123

Change-Id: Id2d740006cbf850db1cffb0d9f2aaca702ef1366
diff --git a/src/com/android/server/telecom/Call.java b/src/com/android/server/telecom/Call.java
index 8bf516d..8a85f2c 100644
--- a/src/com/android/server/telecom/Call.java
+++ b/src/com/android/server/telecom/Call.java
@@ -410,6 +410,28 @@
         return mState;
     }
 
+    private boolean shouldContinueProcessingAfterDisconnect() {
+        // Stop processing once the call is active.
+        if (!CreateConnectionTimeout.isCallBeingPlaced(this)) {
+            return false;
+        }
+
+        // Make sure that there are additional connection services to process.
+        if (mCreateConnectionProcessor == null
+            || !mCreateConnectionProcessor.isProcessingComplete()
+            || !mCreateConnectionProcessor.hasMorePhoneAccounts()) {
+            return false;
+        }
+
+        if (mDisconnectCause == null) {
+            return false;
+        }
+
+        // Continue processing if the current attempt failed or timed out.
+        return mDisconnectCause.getCode() == DisconnectCause.ERROR ||
+            mCreateConnectionProcessor.isCallTimedOut();
+    }
+
     /**
      * Sets the call state. Although there exists the notion of appropriate state transitions
      * (see {@link CallState}), in practice those expectations break down when cellular systems
@@ -420,13 +442,8 @@
         if (mState != newState) {
             Log.v(this, "setState %s -> %s", mState, newState);
 
-            if (newState == CallState.DISCONNECTED
-                    && (mState == CallState.DIALING || mState == CallState.CONNECTING)
-                    && mCreateConnectionProcessor != null
-                    && mCreateConnectionProcessor.isProcessingComplete()
-                    && mCreateConnectionProcessor.hasMorePhoneAccounts()
-                    && mDisconnectCause != null
-                    && mDisconnectCause.getCode() == DisconnectCause.ERROR) {
+            if (newState == CallState.DISCONNECTED && shouldContinueProcessingAfterDisconnect()) {
+                Log.w(this, "continuing processing disconnected call with another service");
                 mCreateConnectionProcessor.continueProcessingIfPossible(this, mDisconnectCause);
                 return;
             }
diff --git a/src/com/android/server/telecom/CreateConnectionProcessor.java b/src/com/android/server/telecom/CreateConnectionProcessor.java
index 91091ec..eec1427 100644
--- a/src/com/android/server/telecom/CreateConnectionProcessor.java
+++ b/src/com/android/server/telecom/CreateConnectionProcessor.java
@@ -17,17 +17,24 @@
 package com.android.server.telecom;
 
 import android.content.Context;
+import android.telecom.CallState;
 import android.telecom.DisconnectCause;
 import android.telecom.ParcelableConnection;
 import android.telecom.Phone;
 import android.telecom.PhoneAccount;
 import android.telecom.PhoneAccountHandle;
+import android.telephony.TelephonyManager;
+import android.telephony.PhoneStateListener;
+import android.telephony.ServiceState;
 
 // TODO: Needed for move to system service: import com.android.internal.R;
 
 import java.util.ArrayList;
+import java.util.Collection;
+import java.util.HashSet;
 import java.util.Iterator;
 import java.util.List;
+import java.util.Set;
 import java.util.Objects;
 
 /**
@@ -90,6 +97,7 @@
     private final PhoneAccountRegistrar mPhoneAccountRegistrar;
     private final Context mContext;
     private boolean mShouldUseConnectionManager = true;
+    private CreateConnectionTimeout mTimeout;
 
     CreateConnectionProcessor(
             Call call, ConnectionServiceRepository repository, CreateConnectionResponse response,
@@ -105,8 +113,13 @@
         return mResponse == null;
     }
 
+    boolean isCallTimedOut() {
+        return mTimeout != null && mTimeout.isCallTimedOut();
+    }
+
     void process() {
         Log.v(this, "process");
+        clearTimeout();
         mAttemptRecords = new ArrayList<>();
         if (mCall.getTargetPhoneAccount() != null) {
             mAttemptRecords.add(new CallAttemptRecord(
@@ -137,6 +150,7 @@
         // more services.
         CreateConnectionResponse response = mResponse;
         mResponse = null;
+        clearTimeout();
 
         ConnectionServiceWrapper service = mCall.getConnectionService();
         if (service != null) {
@@ -189,12 +203,15 @@
                 mCall.setConnectionManagerPhoneAccount(attempt.connectionManagerPhoneAccount);
                 mCall.setTargetPhoneAccount(attempt.targetPhoneAccount);
                 mCall.setConnectionService(service);
+                setTimeoutIfNeeded(service, attempt);
+
                 Log.i(this, "Attempting to call from %s", service.getComponentName());
                 service.createConnection(mCall, new Response(service));
             }
         } else {
             Log.v(this, "attemptNextPhoneAccount, no more accounts, failing");
             if (mResponse != null) {
+                clearTimeout();
                 mResponse.handleCreateConnectionFailure(mLastErrorDisconnectCause != null ?
                         mLastErrorDisconnectCause : new DisconnectCause(DisconnectCause.ERROR));
                 mResponse = null;
@@ -203,6 +220,25 @@
         }
     }
 
+    private void setTimeoutIfNeeded(ConnectionServiceWrapper service, CallAttemptRecord attempt) {
+        clearTimeout();
+
+        CreateConnectionTimeout timeout = new CreateConnectionTimeout(
+                mContext, mPhoneAccountRegistrar, service, mCall);
+        if (timeout.isTimeoutNeededForCall(getConnectionServices(mAttemptRecords),
+                attempt.connectionManagerPhoneAccount)) {
+            mTimeout = timeout;
+            timeout.registerTimeout();
+        }
+    }
+
+    private void clearTimeout() {
+        if (mTimeout != null) {
+            mTimeout.unregisterTimeout();
+            mTimeout = null;
+        }
+    }
+
     private boolean shouldSetConnectionManager() {
         if (!mShouldUseConnectionManager) {
             return false;
@@ -287,7 +323,7 @@
 
             // Next, add the connection manager account as a backup if it can place emergency calls.
             PhoneAccountHandle callManagerHandle = mPhoneAccountRegistrar.getSimCallManager();
-            if (callManagerHandle != null) {
+            if (mShouldUseConnectionManager && callManagerHandle != null) {
                 PhoneAccount callManager = mPhoneAccountRegistrar
                         .getPhoneAccount(callManagerHandle);
                 if (callManager.hasCapabilities(PhoneAccount.CAPABILITY_PLACE_EMERGENCY_CALLS)) {
@@ -306,6 +342,16 @@
         }
     }
 
+    /** Returns all connection services used by the call attempt records. */
+    private static Collection<PhoneAccountHandle> getConnectionServices(
+            List<CallAttemptRecord> records) {
+        HashSet<PhoneAccountHandle> result = new HashSet<>();
+        for (CallAttemptRecord record : records) {
+            result.add(record.connectionManagerPhoneAccount);
+        }
+        return result;
+    }
+
     private class Response implements CreateConnectionResponse {
         private final ConnectionServiceWrapper mService;
 
@@ -326,6 +372,8 @@
                 // in hearing about any more attempts
                 mResponse.handleCreateConnectionSuccess(idMapper, connection);
                 mResponse = null;
+                // If there's a timeout running then don't clear it. The timeout can be triggered
+                // after the call has successfully been created but before it has become active.
             }
         }
 
diff --git a/src/com/android/server/telecom/CreateConnectionTimeout.java b/src/com/android/server/telecom/CreateConnectionTimeout.java
new file mode 100644
index 0000000..3f308cc
--- /dev/null
+++ b/src/com/android/server/telecom/CreateConnectionTimeout.java
@@ -0,0 +1,155 @@
+/*
+ * Copyright 2015, 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 android.content.Context;
+import android.net.ConnectivityManager;
+import android.net.NetworkInfo;
+import android.os.Handler;
+import android.telecom.CallState;
+import android.telecom.PhoneAccountHandle;
+import android.telephony.TelephonyManager;
+import android.telephony.PhoneStateListener;
+import android.telephony.ServiceState;
+
+import java.util.Collection;
+import java.util.Objects;
+
+/**
+ * Registers a timeout for a call and disconnects the call when the timeout expires.
+ */
+final class CreateConnectionTimeout extends PhoneStateListener implements Runnable {
+    private final Context mContext;
+    private final PhoneAccountRegistrar mPhoneAccountRegistrar;
+    private final ConnectionServiceWrapper mConnectionService;
+    private final Call mCall;
+    private final Handler mHandler = new Handler();
+    private boolean mIsRegistered;
+    private boolean mIsCallTimedOut;
+
+    CreateConnectionTimeout(Context context, PhoneAccountRegistrar phoneAccountRegistrar,
+            ConnectionServiceWrapper service, Call call) {
+        mContext = context;
+        mPhoneAccountRegistrar = phoneAccountRegistrar;
+        mConnectionService = service;
+        mCall = call;
+    }
+
+    boolean isTimeoutNeededForCall(Collection<PhoneAccountHandle> accounts,
+            PhoneAccountHandle currentAccount) {
+        // Non-emergency calls timeout automatically at the radio layer. No need for a timeout here.
+        if (!TelephonyUtil.shouldProcessAsEmergency(mContext, mCall.getHandle())) {
+            return false;
+        }
+
+        // If there's no connection manager to fallback on then there's no point in having a
+        // timeout.
+        PhoneAccountHandle connectionManager = mPhoneAccountRegistrar.getSimCallManager();
+        if (!accounts.contains(connectionManager)) {
+            return false;
+        }
+
+        // No need to add a timeout if the current attempt is over the connection manager.
+        if (Objects.equals(connectionManager, currentAccount)) {
+            return false;
+        }
+
+        // To reduce the number of scenarios where a timeout is needed, only use a timeout if
+        // we're connected to Wi-Fi. This ensures that the fallback connection manager has an
+        // alternate route to place the call. TODO: remove this condition or allow connection
+        // managers to specify transports. See http://b/19199181.
+        if (!isConnectedToWifi()) {
+            return false;
+        }
+
+        Log.d(this, "isTimeoutNeededForCall, returning true");
+        return true;
+    }
+
+    void registerTimeout() {
+        Log.d(this, "registerTimeout");
+        mIsRegistered = true;
+        // First find out the cellular service state. Based on the state we decide whether a timeout
+        // will actually be enforced and if so how long it should be.
+        TelephonyManager telephonyManager =
+            (TelephonyManager) mContext.getSystemService(Context.TELEPHONY_SERVICE);
+        telephonyManager.listen(this, PhoneStateListener.LISTEN_SERVICE_STATE);
+        telephonyManager.listen(this, 0);
+    }
+
+    void unregisterTimeout() {
+        Log.d(this, "unregisterTimeout");
+        mIsRegistered = false;
+        mHandler.removeCallbacksAndMessages(null);
+    }
+
+    boolean isCallTimedOut() {
+        return mIsCallTimedOut;
+    }
+
+    @Override
+    public void onServiceStateChanged(ServiceState serviceState) {
+        long timeoutLengthMillis = getTimeoutLengthMillis(serviceState);
+        if (!mIsRegistered) {
+            Log.d(this, "onServiceStateChanged, timeout no longer registered, skipping");
+        } else if (timeoutLengthMillis  <= 0) {
+            Log.d(this, "onServiceStateChanged, timeout set to %d, skipping", timeoutLengthMillis);
+        } else if (serviceState.getState() == ServiceState.STATE_IN_SERVICE) {
+            // If cellular service is available then don't bother with a timeout.
+            Log.d(this, "onServiceStateChanged, cellular service available, skipping");
+        } else {
+            mHandler.postDelayed(this, timeoutLengthMillis);
+        }
+    }
+
+    @Override
+    public void run() {
+        if (mIsRegistered && isCallBeingPlaced(mCall)) {
+            Log.d(this, "run, call timed out, calling disconnect");
+            mIsCallTimedOut = true;
+            mConnectionService.disconnect(mCall);
+        }
+    }
+
+    static boolean isCallBeingPlaced(Call call) {
+        int state = call.getState();
+        return state == CallState.NEW
+            || state == CallState.CONNECTING
+            || state == CallState.DIALING;
+    }
+
+    private long getTimeoutLengthMillis(ServiceState serviceState) {
+        // If the radio is off then use a longer timeout. This gives us more time to power on the
+        // radio.
+        if (serviceState.getState() == ServiceState.STATE_POWER_OFF) {
+            return Timeouts.getEmergencyCallTimeoutRadioOffMillis(
+                    mContext.getContentResolver());
+        } else {
+            return Timeouts.getEmergencyCallTimeoutMillis(mContext.getContentResolver());
+        }
+    }
+
+    private boolean isConnectedToWifi() {
+        ConnectivityManager cm = (ConnectivityManager) mContext.getSystemService(
+            Context.CONNECTIVITY_SERVICE);
+        if (cm != null) {
+          NetworkInfo ni = cm.getActiveNetworkInfo();
+          return ni != null && ni.isConnected() && ni.getType() == ConnectivityManager.TYPE_WIFI;
+        }
+        return false;
+    }
+}
diff --git a/src/com/android/server/telecom/Timeouts.java b/src/com/android/server/telecom/Timeouts.java
index 75b94c2..b5cf39a 100644
--- a/src/com/android/server/telecom/Timeouts.java
+++ b/src/com/android/server/telecom/Timeouts.java
@@ -72,4 +72,21 @@
         return get(contentResolver, "delay_between_dtmf_tones_ms", 300L);
     }
 
+    /**
+     * Returns the amount of time to wait for an emergency call to be placed before routing to
+     * a different call service. A value of 0 or less means no timeout should be used.
+     */
+    public static long getEmergencyCallTimeoutMillis(ContentResolver contentResolver) {
+        return get(contentResolver, "emergency_call_timeout_millis", 25000L /* 25 seconds */);
+    }
+
+    /**
+     * Returns the amount of time to wait for an emergency call to be placed before routing to
+     * a different call service. This timeout is used only when the radio is powered off (for
+     * example in airplane mode). A value of 0 or less means no timeout should be used.
+     */
+    public static long getEmergencyCallTimeoutRadioOffMillis(ContentResolver contentResolver) {
+        return get(contentResolver, "emergency_call_timeout_radio_off_millis",
+                60000L /* 1 minute */);
+    }
 }