From c0d30fdfd3a03003ad69ab7e317f2a531ab70aff Mon Sep 17 00:00:00 2001 From: Stephen Paul Weber Date: Mon, 21 Feb 2022 16:56:58 -0500 Subject: [PATCH 1/2] Much prettier calling account Register the service avatar, perpy background for call UI, and split the User's JID into the headline with the service JID (eg "cheogram.com") only shown in short description. --- .../eu/siacs/conversations/entities/Contact.java | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/src/main/java/eu/siacs/conversations/entities/Contact.java b/src/main/java/eu/siacs/conversations/entities/Contact.java index 555e35dceab3cdedc2bda5dfcb369d9609f45c0e..e2ede44666cbf2f733b55e8abf91e52e519130a0 100644 --- a/src/main/java/eu/siacs/conversations/entities/Contact.java +++ b/src/main/java/eu/siacs/conversations/entities/Contact.java @@ -4,6 +4,7 @@ import android.content.ComponentName; import android.content.ContentValues; import android.content.Context; import android.database.Cursor; +import android.graphics.drawable.Icon; import android.net.Uri; import android.os.Bundle; import android.telecom.PhoneAccount; @@ -30,7 +31,9 @@ import eu.siacs.conversations.Config; import eu.siacs.conversations.R; import eu.siacs.conversations.android.AbstractPhoneContact; import eu.siacs.conversations.android.JabberIdContact; +import eu.siacs.conversations.services.AvatarService; import eu.siacs.conversations.services.QuickConversationsService; +import eu.siacs.conversations.services.XmppConnectionService; import eu.siacs.conversations.utils.JidHelper; import eu.siacs.conversations.utils.UIHelper; import eu.siacs.conversations.xml.Element; @@ -578,11 +581,20 @@ public class Contact implements ListItem, Blockable { } // This Contact is a gateway to use for voice calls, register it with OS - public void registerAsPhoneAccount(Context ctx) { + public void registerAsPhoneAccount(XmppConnectionService ctx) { TelecomManager telecomManager = ctx.getSystemService(TelecomManager.class); PhoneAccount phoneAccount = PhoneAccount.builder( - phoneAccountHandle(), phoneAccountLabel() + phoneAccountHandle(), + account.getJid().asBareJid().toString() + ).setAddress( + Uri.fromParts("xmpp", account.getJid().asBareJid().toString(), null) + ).setIcon( + Icon.createWithBitmap(ctx.getAvatarService().get(this, AvatarService.getSystemUiAvatarSize(ctx) / 2, false)) + ).setHighlightColor( + 0x7401CF + ).setShortDescription( + getJid().asBareJid().toString() ).setCapabilities( PhoneAccount.CAPABILITY_CALL_PROVIDER ).build(); From 6168029d2ab3e4f5aeb172ee7f06bda8a1b824e2 Mon Sep 17 00:00:00 2001 From: Stephen Paul Weber Date: Mon, 21 Feb 2022 17:02:47 -0500 Subject: [PATCH 2/2] Mostly rewrite dialler integration 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). --- .../cheogram/android/ConnectionService.java | 236 ++++++++++++++---- .../services/XmppConnectionService.java | 6 + .../conversations/ui/RtpSessionActivity.java | 36 +-- .../xmpp/jingle/JingleConnectionManager.java | 18 +- 4 files changed, 198 insertions(+), 98 deletions(-) diff --git a/src/cheogram/java/com/cheogram/android/ConnectionService.java b/src/cheogram/java/com/cheogram/android/ConnectionService.java index c5e304ec78030aeebe70f1fa553a5a7e3708c92a..687020552abbdb839621b8ecd8de46d3c5ad5eba 100644 --- a/src/cheogram/java/com/cheogram/android/ConnectionService.java +++ b/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 postDial = new Stack(); + protected WeakReference 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 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); + } + } } } } diff --git a/src/main/java/eu/siacs/conversations/services/XmppConnectionService.java b/src/main/java/eu/siacs/conversations/services/XmppConnectionService.java index c6e79d3af47ae6db8c5063a675c4dacc970befa5..e0f1c2feea00f6e69d6f932327c0c91f1cd1f3d8 100644 --- a/src/main/java/eu/siacs/conversations/services/XmppConnectionService.java +++ b/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; diff --git a/src/main/java/eu/siacs/conversations/ui/RtpSessionActivity.java b/src/main/java/eu/siacs/conversations/ui/RtpSessionActivity.java index 7a94829c3ac34da311c64f4cb24f7e3916fcd309..759256c80c0d47ac5f40b0453f87408aacaa5184 100644 --- a/src/main/java/eu/siacs/conversations/ui/RtpSessionActivity.java +++ b/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 END_CARD = Arrays.asList( + public static final List 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); diff --git a/src/main/java/eu/siacs/conversations/xmpp/jingle/JingleConnectionManager.java b/src/main/java/eu/siacs/conversations/xmpp/jingle/JingleConnectionManager.java index b5667b645b800a42de0a434fe5bb5a9c4073c34e..806108c8531a95b6465ed6ed8bc03b9ff25c7f66 100644 --- a/src/main/java/eu/siacs/conversations/xmpp/jingle/JingleConnectionManager.java +++ b/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) { + private String hasMatchingRtpSession(final Account account, final Jid with, final Set 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) { + public String proposeJingleRtpSession(final Account account, final Jid with, final Set media) { synchronized (this.rtpSessionProposals) { for (Map.Entry 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; } }