Mostly rewrite dialler integration

Stephen Paul Weber created

Doing it properly this time. No Intent, no popping a second activity, just
instrument the call into the Android UI.

This turned out to be a bit easier than expected.  The ConnectionService does
indeed run inside our app process everywhere it matters, so we can reuse the
machinery from elsewhere to get a reference to XmppConnectionService.  Asking
JingleConnectionManager to send propose is enough to kick off all audio, etc.

Put in a guard that the JingleConnectionManager expects so that we won't try to
start a call if isBusy is true (that is, if another call is in progress), and
just return a failed connection BUSY in that case.

Added a flag to XmppConnectionService that we set when using the dialler
integration so it will temporarily ignore phone call state and not hang itself
up.

The app still shows a separate call notification, so you see two in-call
notifications.  You can also go into the app during the call and manually pull
up the call UI there.  If you do anything in either call UI it will work and
sync state, so that's not dangerous just perhaps odd.

Kept the connection address as a tel: for contact integration in the call
history, but use our normalized version now instead of whatever we got raw from
the system.

Add support for post-dial DTMF including 2-second pause with comma and
manual-length user-intervention pause with semicolon.  Dial with 100ms break
between tones.  Doing no break at all mostly works in practise, but not for two
identical tones in a row, this seems to be enough in my tests.

When using a post-dial string from contacts the UI shows more/different state
than it should and at least in my emulator plays a different ringback tone for
one ring before switching.  Very odd, as though it's partially ignoring our
Connection object's settings.

Audio routing has not been tested yet (for speakerphone, bluetooth, etc).

Change summary

src/cheogram/java/com/cheogram/android/ConnectionService.java                 | 236 
src/main/java/eu/siacs/conversations/services/XmppConnectionService.java      |   6 
src/main/java/eu/siacs/conversations/ui/RtpSessionActivity.java               |  36 
src/main/java/eu/siacs/conversations/xmpp/jingle/JingleConnectionManager.java |  18 
4 files changed, 198 insertions(+), 98 deletions(-)

Detailed changes

src/cheogram/java/com/cheogram/android/ConnectionService.java 🔗

@@ -1,106 +1,202 @@
 package com.cheogram.android;
 
+import java.lang.ref.WeakReference;
+import java.util.Collections;
+import java.util.Set;
+import java.util.Stack;
+
+import com.google.common.collect.ImmutableSet;
+
+import android.telecom.CallAudioState;
 import android.telecom.Connection;
 import android.telecom.ConnectionRequest;
+import android.telecom.DisconnectCause;
 import android.telecom.PhoneAccount;
 import android.telecom.PhoneAccountHandle;
 import android.telecom.StatusHints;
 import android.telecom.TelecomManager;
-import android.telecom.DisconnectCause;
+import android.telephony.PhoneNumberUtils;
 
+import android.content.ComponentName;
+import android.content.Context;
 import android.content.Intent;
+import android.content.ServiceConnection;
+import android.net.Uri;
 import android.os.Bundle;
+import android.os.IBinder;
 import android.os.Parcel;
+import android.util.Log;
 
-import eu.siacs.conversations.xmpp.Jid;
+import eu.siacs.conversations.entities.Account;
+import eu.siacs.conversations.services.AppRTCAudioManager;
+import eu.siacs.conversations.services.XmppConnectionService.XmppConnectionBinder;
+import eu.siacs.conversations.services.XmppConnectionService;
 import eu.siacs.conversations.ui.RtpSessionActivity;
+import eu.siacs.conversations.xmpp.Jid;
+import eu.siacs.conversations.xmpp.jingle.JingleRtpConnection;
+import eu.siacs.conversations.xmpp.jingle.Media;
+import eu.siacs.conversations.xmpp.jingle.RtpEndUserState;
 
 public class ConnectionService extends android.telecom.ConnectionService {
+	public XmppConnectionService xmppConnectionService = null;
+	protected ServiceConnection mConnection = new ServiceConnection() {
+		@Override
+			public void onServiceConnected(ComponentName className, IBinder service) {
+				XmppConnectionBinder binder = (XmppConnectionBinder) service;
+				xmppConnectionService = binder.getService();
+			}
+
+		@Override
+		public void onServiceDisconnected(ComponentName arg0) {
+			xmppConnectionService = null;
+		}
+	};
+
+	@Override
+	public void onCreate() {
+		// From XmppActivity.connectToBackend
+		Intent intent = new Intent(this, XmppConnectionService.class);
+		intent.setAction("ui");
+		try {
+			startService(intent);
+		} catch (IllegalStateException e) {
+			Log.w("com.cheogram.android.ConnectionService", "unable to start service from " + getClass().getSimpleName());
+		}
+		bindService(intent, mConnection, Context.BIND_AUTO_CREATE);
+	}
+
+	@Override
+	public void onDestroy() {
+		unbindService(mConnection);
+	}
+
 	@Override
 	public Connection onCreateOutgoingConnection(
 		PhoneAccountHandle phoneAccountHandle,
 		ConnectionRequest request
 	) {
 		String[] gateway = phoneAccountHandle.getId().split("/", 2);
-		Connection connection = new CheogramConnection();
-		connection.setAddress(
-			request.getAddress(),
-			TelecomManager.PRESENTATION_ALLOWED
-		);
-		connection.setAudioModeIsVoip(true);
-		connection.setDialing();
-		connection.setRingbackRequested(true);
-		connection.setConnectionCapabilities(
-			Connection.CAPABILITY_CAN_SEND_RESPONSE_VIA_CONNECTION
-		);
+
+		String rawTel = request.getAddress().getSchemeSpecificPart();
+		String postDial = PhoneNumberUtils.extractPostDialPortion(rawTel);
 
 		// TODO: jabber:iq:gateway
-		String tel = request.getAddress().getSchemeSpecificPart().
-		           replaceAll("[^\\+0-9]", "");
+		String tel = PhoneNumberUtils.extractNetworkPortion(rawTel);
 		if (tel.startsWith("1")) {
 			tel = "+" + tel;
 		} else if (!tel.startsWith("+")) {
 			tel = "+1" + tel;
 		}
 
-		// Instead of wiring the call up to the Android call UI,
-		// just show our UI for now.  This means both are showing during a call.
-		final Intent intent = new Intent(this, RtpSessionActivity.class);
-		intent.setAction(RtpSessionActivity.ACTION_MAKE_VOICE_CALL);
-		Bundle extras = new Bundle();
-		extras.putString(
-			RtpSessionActivity.EXTRA_ACCOUNT,
-			Jid.of(gateway[0]).toEscapedString()
+		if (xmppConnectionService.getJingleConnectionManager().isBusy() != null) {
+			return Connection.createFailedConnection(
+				new DisconnectCause(DisconnectCause.BUSY)
+			);
+		}
+
+		Account account = xmppConnectionService.findAccountByJid(Jid.of(gateway[0]));
+		Jid with = Jid.ofLocalAndDomain(tel, gateway[1]);
+		String sessionId = xmppConnectionService.getJingleConnectionManager().proposeJingleRtpSession(
+			account,
+			with,
+			ImmutableSet.of(Media.AUDIO)
+		);
+
+		Connection connection = new CheogramConnection(account, with, sessionId, postDial);
+		connection.setAddress(
+			Uri.fromParts("tel", tel, null), // Normalized tel as tel: URI
+			TelecomManager.PRESENTATION_ALLOWED
+		);
+		connection.setCallerDisplayName(
+			account.getDisplayName(),
+			TelecomManager.PRESENTATION_ALLOWED
 		);
-		extras.putString(
-			RtpSessionActivity.EXTRA_WITH,
-			Jid.ofLocalAndDomain(tel, gateway[1]).toEscapedString()
+		connection.setAudioModeIsVoip(true);
+		connection.setRingbackRequested(true);
+		connection.setDialing();
+		connection.setConnectionCapabilities(
+			Connection.CAPABILITY_CAN_SEND_RESPONSE_VIA_CONNECTION
 		);
-		extras.putBinder(
-			RtpSessionActivity.EXTRA_CONNECTION_BINDER,
-			new ConnectionBinder(connection)
+
+		xmppConnectionService.setOnRtpConnectionUpdateListener(
+			(XmppConnectionService.OnJingleRtpConnectionUpdate) connection
 		);
-		intent.putExtras(extras);
-		intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
-		intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK);
-		startActivity(intent);
 
 		return connection;
 	}
 
-	public class ConnectionBinder extends android.os.Binder {
-		protected Connection connection;
-
-		public static final int TRANSACT_ACTIVE = android.os.IBinder.FIRST_CALL_TRANSACTION + 1;
-		public static final int TRANSACT_DISCONNECT = TRANSACT_ACTIVE + 1;
+	public class CheogramConnection extends Connection implements XmppConnectionService.OnJingleRtpConnectionUpdate {
+		protected Account account;
+		protected Jid with;
+		protected String sessionId;
+		protected Stack<String> postDial = new Stack();
+		protected WeakReference<JingleRtpConnection> rtpConnection = null;
 
-		ConnectionBinder(Connection connection) {
+		CheogramConnection(Account account, Jid with, String sessionId, String postDialString) {
 			super();
-			this.connection = connection;
+			this.account = account;
+			this.with = with;
+			this.sessionId = sessionId;
+
+			if (postDialString != null) {
+				for (int i = postDialString.length() - 1; i >= 0; i--) {
+					postDial.push("" + postDialString.charAt(i));
+				}
+			}
+		}
+
+		@Override
+		public void onJingleRtpConnectionUpdate(final Account account, final Jid with, final String sessionId, final RtpEndUserState state) {
+			if (!sessionId.equals(this.sessionId)) return;
+			if (rtpConnection == null) {
+				this.with = with; // Store full JID of connection
+				rtpConnection = xmppConnectionService.getJingleConnectionManager().findJingleRtpConnection(account, with, sessionId);
+			}
+
+			if (state == RtpEndUserState.CONNECTED) {
+				xmppConnectionService.setDiallerIntegrationActive(true);
+				setActive();
+
+				postDial();
+			} else if (state == RtpEndUserState.DECLINED_OR_BUSY) {
+				setDisconnected(new DisconnectCause(DisconnectCause.BUSY));
+			} else if (state == RtpEndUserState.ENDED) {
+				setDisconnected(new DisconnectCause(DisconnectCause.LOCAL));
+			} else if (state == RtpEndUserState.RETRACTED) {
+				setDisconnected(new DisconnectCause(DisconnectCause.CANCELED));
+			} else if (RtpSessionActivity.END_CARD.contains(state)) {
+				setDisconnected(new DisconnectCause(DisconnectCause.ERROR));
+			}
 		}
 
 		@Override
-		protected boolean onTransact(int code, Parcel data, Parcel reply, int flags) {
-			switch(code) {
-				case TRANSACT_ACTIVE:
-					this.connection.setActive();
-					connection.setRingbackRequested(false);
-					return true;
-				case TRANSACT_DISCONNECT:
-					this.connection.setDisconnected(
-						new DisconnectCause(DisconnectCause.UNKNOWN)
-					);
-					return true;
+		public void onAudioDeviceChanged(AppRTCAudioManager.AudioDevice selectedAudioDevice, Set<AppRTCAudioManager.AudioDevice> availableAudioDevices) {
+			switch(selectedAudioDevice) {
+				case SPEAKER_PHONE:
+					setAudioRoute(CallAudioState.ROUTE_SPEAKER);
+				case WIRED_HEADSET:
+					setAudioRoute(CallAudioState.ROUTE_WIRED_HEADSET);
+				case EARPIECE:
+					setAudioRoute(CallAudioState.ROUTE_EARPIECE);
+				case BLUETOOTH:
+					setAudioRoute(CallAudioState.ROUTE_BLUETOOTH);
 				default:
-					return false;
+					setAudioRoute(CallAudioState.ROUTE_WIRED_OR_EARPIECE);
 			}
 		}
-	}
 
-	public class CheogramConnection extends Connection {
 		@Override
 		public void onDisconnect() {
+			if (rtpConnection == null || rtpConnection.get() == null) {
+				xmppConnectionService.getJingleConnectionManager().retractSessionProposal(account, with.asBareJid());
+			} else {
+				rtpConnection.get().endCall();
+			}
 			destroy();
+			xmppConnectionService.setDiallerIntegrationActive(false);
+			xmppConnectionService.removeRtpConnectionUpdateListener(
+				(XmppConnectionService.OnJingleRtpConnectionUpdate) this
+			);
 		}
 
 		@Override
@@ -110,7 +206,37 @@ public class ConnectionService extends android.telecom.ConnectionService {
 
 		@Override
 		public void onPlayDtmfTone(char c) {
-			// TODO
+			rtpConnection.get().applyDtmfTone("" + c);
+		}
+
+		@Override
+		public void onPostDialContinue(boolean c) {
+			if (c) postDial();
+		}
+
+		protected void sleep(int ms) {
+			try {
+				Thread.sleep(ms);
+			} catch (InterruptedException ex) {
+				Thread.currentThread().interrupt();
+			}
+		}
+
+		protected void postDial() {
+			while (!postDial.empty()) {
+				String next = postDial.pop();
+				if (next.equals(";")) {
+					Stack v = (Stack) postDial.clone();
+					Collections.reverse(v);
+					setPostDialWait(String.join("", v));
+					return;
+				} else if (next.equals(",")) {
+					sleep(2000);
+				} else {
+					rtpConnection.get().applyDtmfTone(next);
+					sleep(100);
+				}
+			}
 		}
 	}
 }

src/main/java/eu/siacs/conversations/services/XmppConnectionService.java 🔗

@@ -286,9 +286,11 @@ public class XmppConnectionService extends Service {
         }
     };
     private final AtomicBoolean isPhoneInCall = new AtomicBoolean(false);
+    private final AtomicBoolean diallerIntegrationActive = new AtomicBoolean(false);
     private final PhoneStateListener phoneStateListener = new PhoneStateListener() {
         @Override
         public void onCallStateChanged(final int state, final String phoneNumber) {
+            if (diallerIntegrationActive.get()) return;
             isPhoneInCall.set(state != TelephonyManager.CALL_STATE_IDLE);
             if (state == TelephonyManager.CALL_STATE_OFFHOOK) {
                 mJingleConnectionManager.notifyPhoneCallStarted();
@@ -296,6 +298,10 @@ public class XmppConnectionService extends Service {
         }
     };
 
+    public void setDiallerIntegrationActive(boolean active) {
+      diallerIntegrationActive.set(active);
+    }
+
     private boolean destroyed = false;
 
     private int unreadCount = -1;

src/main/java/eu/siacs/conversations/ui/RtpSessionActivity.java 🔗

@@ -71,8 +71,6 @@ import eu.siacs.conversations.xmpp.jingle.JingleRtpConnection;
 import eu.siacs.conversations.xmpp.jingle.Media;
 import eu.siacs.conversations.xmpp.jingle.RtpEndUserState;
 
-import com.cheogram.android.ConnectionService.ConnectionBinder;
-
 import static eu.siacs.conversations.utils.PermissionUtils.getFirstDenied;
 import static java.util.Arrays.asList;
 
@@ -82,14 +80,13 @@ public class RtpSessionActivity extends XmppActivity implements XmppConnectionSe
     public static final String EXTRA_SESSION_ID = "session_id";
     public static final String EXTRA_LAST_REPORTED_STATE = "last_reported_state";
     public static final String EXTRA_LAST_ACTION = "last_action";
-    public static final String EXTRA_CONNECTION_BINDER = "connection_binder";
     public static final String ACTION_ACCEPT_CALL = "action_accept_call";
     public static final String ACTION_MAKE_VOICE_CALL = "action_make_voice_call";
     public static final String ACTION_MAKE_VIDEO_CALL = "action_make_video_call";
 
     private static final int CALL_DURATION_UPDATE_INTERVAL = 333;
 
-    private static final List<RtpEndUserState> END_CARD = Arrays.asList(
+    public static final List<RtpEndUserState> END_CARD = Arrays.asList(
             RtpEndUserState.APPLICATION_ERROR,
             RtpEndUserState.SECURITY_ERROR,
             RtpEndUserState.DECLINED_OR_BUSY,
@@ -148,8 +145,6 @@ public class RtpSessionActivity extends XmppActivity implements XmppConnectionSe
         }
     }
 
-    protected android.os.IBinder connectionBinder = null;
-
     @Override
     public void onCreate(Bundle savedInstanceState) {
         super.onCreate(savedInstanceState);
@@ -168,8 +163,6 @@ public class RtpSessionActivity extends XmppActivity implements XmppConnectionSe
             boolean dialpadVisible = savedInstanceState.getBoolean("dialpad_visible");
             binding.dialpad.setVisibility(dialpadVisible ? View.VISIBLE : View.GONE);
         }
-
-        this.connectionBinder = getIntent().getExtras().getBinder(EXTRA_CONNECTION_BINDER);
     }
 
     @Override
@@ -272,29 +265,6 @@ public class RtpSessionActivity extends XmppActivity implements XmppConnectionSe
         } else {
             requireRtpConnection().endCall();
         }
-        disconnectConnectionBinder();
-    }
-
-    private void disconnectConnectionBinder() {
-        if (connectionBinder != null) {
-            android.os.Parcel args = android.os.Parcel.obtain();
-            try {
-                connectionBinder.transact(ConnectionBinder.TRANSACT_DISCONNECT, args, null, 0);
-            } catch (android.os.RemoteException e) {}
-            args.recycle();
-        }
-    }
-
-    private void activateConnectionBinder() {
-        // If we do this, the other UI takes over and kills our call
-        // So we can't activate that UI unless we are going to use it.
-        /*if (connectionBinder != null) {
-            android.os.Parcel args = android.os.Parcel.obtain();
-            try {
-                connectionBinder.transact(ConnectionBinder.TRANSACT_ACTIVE, args, null, 0);
-            } catch (android.os.RemoteException e) {}
-            args.recycle();
-        }*/
     }
 
     private void retractSessionProposal() {
@@ -1186,14 +1156,10 @@ public class RtpSessionActivity extends XmppActivity implements XmppConnectionSe
     @Override
     public void onJingleRtpConnectionUpdate(Account account, Jid with, final String sessionId, RtpEndUserState state) {
         Log.d(Config.LOGTAG, "onJingleRtpConnectionUpdate(" + state + ")");
-        if (state == RtpEndUserState.CONNECTED) {
-            activateConnectionBinder();
-        }
         if (END_CARD.contains(state)) {
             Log.d(Config.LOGTAG, "end card reached");
             releaseProximityWakeLock();
             runOnUiThread(() -> getWindow().clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON));
-            disconnectConnectionBinder();
         }
         if (with.isBareJid()) {
             updateRtpSessionProposalState(account, with, state);

src/main/java/eu/siacs/conversations/xmpp/jingle/JingleConnectionManager.java 🔗

@@ -172,7 +172,7 @@ public class JingleConnectionManager extends AbstractConnectionManager {
         return Optional.absent();
     }
 
-    private boolean hasMatchingRtpSession(final Account account, final Jid with, final Set<Media> media) {
+    private String hasMatchingRtpSession(final Account account, final Jid with, final Set<Media> media) {
         for (AbstractJingleConnection connection : this.connections.values()) {
             if (connection instanceof JingleRtpConnection) {
                 final JingleRtpConnection rtpConnection = (JingleRtpConnection) connection;
@@ -182,11 +182,11 @@ public class JingleConnectionManager extends AbstractConnectionManager {
                 if (rtpConnection.getId().account == account
                         && rtpConnection.getId().with.asBareJid().equals(with.asBareJid())
                         && rtpConnection.getMedia().equals(media)) {
-                    return true;
+                    return rtpConnection.getId().sessionId;
                 }
             }
         }
-        return false;
+        return null;
     }
 
     private boolean isWithStrangerAndStrangerNotificationsAreOff(final Account account, Jid with) {
@@ -573,7 +573,7 @@ public class JingleConnectionManager extends AbstractConnectionManager {
         return id.sessionId;
     }
 
-    public void proposeJingleRtpSession(final Account account, final Jid with, final Set<Media> media) {
+    public String proposeJingleRtpSession(final Account account, final Jid with, final Set<Media> media) {
         synchronized (this.rtpSessionProposals) {
             for (Map.Entry<RtpSessionProposal, DeviceDiscoveryState> entry : this.rtpSessionProposals.entrySet()) {
                 RtpSessionProposal proposal = entry.getKey();
@@ -588,15 +588,16 @@ public class JingleConnectionManager extends AbstractConnectionManager {
                                 proposal.sessionId,
                                 endUserState
                         );
-                        return;
+                        return proposal.sessionId;
                     }
                 }
             }
             String busyCode = isBusy();
             if (busyCode != null) {
-                if (hasMatchingRtpSession(account, with, media)) {
-                    Log.d(Config.LOGTAG, "ignoring request to propose jingle session because the other party already created one for us");
-                    return;
+                String sessionId = hasMatchingRtpSession(account, with, media);
+                if (sessionId != null) {
+                    Log.d(Config.LOGTAG, "ignoring request to propose jingle session because the other party already created one for us: " + sessionId);
+                    return sessionId;
                 }
                 throw new IllegalStateException("There is already a running RTP session: " + busyCode);
             }
@@ -610,6 +611,7 @@ public class JingleConnectionManager extends AbstractConnectionManager {
             );
             final MessagePacket messagePacket = mXmppConnectionService.getMessageGenerator().sessionProposal(proposal);
             mXmppConnectionService.sendMessagePacket(account, messagePacket);
+            return proposal.sessionId;
         }
     }