diff --git a/src/cheogram/java/com/cheogram/android/ConnectionService.java b/src/cheogram/java/com/cheogram/android/ConnectionService.java index 671578dd8..6d4c14c80 100644 --- a/src/cheogram/java/com/cheogram/android/ConnectionService.java +++ b/src/cheogram/java/com/cheogram/android/ConnectionService.java @@ -1,292 +1,340 @@ package com.cheogram.android; +import android.Manifest; +import android.content.ComponentName; +import android.content.Context; +import android.content.Intent; +import android.content.ServiceConnection; +import android.graphics.drawable.Icon; +import android.net.Uri; +import android.os.Bundle; +import android.os.IBinder; +import android.telecom.CallAudioState; +import android.telecom.Connection; +import android.telecom.ConnectionRequest; +import android.telecom.DisconnectCause; +import android.telecom.PhoneAccountHandle; +import android.telecom.StatusHints; +import android.telecom.TelecomManager; +import android.telephony.PhoneNumberUtils; +import android.util.Log; + +import com.google.common.collect.ImmutableSet; +import com.intentfilter.androidpermissions.NotificationSettings; +import com.intentfilter.androidpermissions.PermissionManager; +import com.intentfilter.androidpermissions.models.DeniedPermissions; + import java.lang.ref.WeakReference; import java.util.Collections; import java.util.HashSet; 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.telephony.PhoneNumberUtils; - -import android.Manifest; -import androidx.core.content.ContextCompat; -import android.content.ComponentName; -import android.content.Context; -import android.content.Intent; -import android.content.pm.PackageManager; -import android.content.ServiceConnection; -import android.graphics.drawable.Icon; -import android.net.Uri; -import android.os.Bundle; -import android.os.IBinder; -import android.os.Parcel; -import android.util.Log; - -import com.intentfilter.androidpermissions.PermissionManager; -import com.intentfilter.androidpermissions.NotificationSettings; -import com.intentfilter.androidpermissions.models.DeniedPermissions; -import io.michaelrocks.libphonenumber.android.NumberParseException; - import eu.siacs.conversations.R; import eu.siacs.conversations.entities.Account; import eu.siacs.conversations.services.AppRTCAudioManager; import eu.siacs.conversations.services.AvatarService; -import eu.siacs.conversations.services.XmppConnectionService.XmppConnectionBinder; import eu.siacs.conversations.services.XmppConnectionService; +import eu.siacs.conversations.services.XmppConnectionService.XmppConnectionBinder; import eu.siacs.conversations.ui.RtpSessionActivity; import eu.siacs.conversations.utils.PhoneNumberUtilWrapper; 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; +import io.michaelrocks.libphonenumber.android.NumberParseException; 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(); - } + 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 onServiceDisconnected(ComponentName arg0) { + xmppConnectionService = null; + } + }; + private PermissionManager mPermissionManager; - @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 onCreate() { + mPermissionManager = PermissionManager.getInstance(this); + mPermissionManager.setNotificationSettings( + new NotificationSettings.Builder() + .withMessage(R.string.microphone_permission_for_call) + .withSmallIcon(R.drawable.ic_notification).build() + ); - @Override - public void onDestroy() { - unbindService(mConnection); - } + // 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 Connection onCreateOutgoingConnection( - PhoneAccountHandle phoneAccountHandle, - ConnectionRequest request - ) { - String[] gateway = phoneAccountHandle.getId().split("/", 2); + @Override + public void onDestroy() { + unbindService(mConnection); + } - String rawTel = request.getAddress().getSchemeSpecificPart(); - String postDial = PhoneNumberUtils.extractPostDialPortion(rawTel); + @Override + public Connection onCreateOutgoingConnection( + PhoneAccountHandle phoneAccountHandle, + ConnectionRequest request + ) { + String[] gateway = phoneAccountHandle.getId().split("/", 2); - String tel = PhoneNumberUtils.extractNetworkPortion(rawTel); - try { - tel = PhoneNumberUtilWrapper.normalize(this, tel); - } catch (NumberParseException e) { - return Connection.createFailedConnection( - new DisconnectCause(DisconnectCause.ERROR) - ); - } + String rawTel = request.getAddress().getSchemeSpecificPart(); + String postDial = PhoneNumberUtils.extractPostDialPortion(rawTel); - if (xmppConnectionService.getJingleConnectionManager().isBusy() != null) { - return Connection.createFailedConnection( - new DisconnectCause(DisconnectCause.BUSY) - ); - } + String tel = PhoneNumberUtils.extractNetworkPortion(rawTel); + try { + tel = PhoneNumberUtilWrapper.normalize(this, tel); + } catch (NumberParseException e) { + return Connection.createFailedConnection( + new DisconnectCause(DisconnectCause.ERROR) + ); + } - Account account = xmppConnectionService.findAccountByJid(Jid.of(gateway[0])); - Jid with = Jid.ofLocalAndDomain(tel, gateway[1]); - CheogramConnection connection = new CheogramConnection(account, with, postDial); + if (xmppConnectionService.getJingleConnectionManager().isBusy() != null) { + return Connection.createFailedConnection( + new DisconnectCause(DisconnectCause.BUSY) + ); + } - PermissionManager permissionManager = PermissionManager.getInstance(this); - permissionManager.setNotificationSettings( - new NotificationSettings.Builder() - .withMessage(R.string.microphone_permission_for_call) - .withSmallIcon(R.drawable.ic_notification).build() - ); + Account account = xmppConnectionService.findAccountByJid(Jid.of(gateway[0])); + Jid with = Jid.ofLocalAndDomain(tel, gateway[1]); + CheogramConnection connection = new CheogramConnection(account, with, postDial); - Set permissions = new HashSet(); - permissions.add(Manifest.permission.RECORD_AUDIO); - permissionManager.checkPermissions(permissions, new PermissionManager.PermissionRequestListener() { - @Override - public void onPermissionGranted() { - connection.setSessionId(xmppConnectionService.getJingleConnectionManager().proposeJingleRtpSession( - account, - with, - ImmutableSet.of(Media.AUDIO) - )); - } + Set permissions = new HashSet<>(); + permissions.add(Manifest.permission.RECORD_AUDIO); + mPermissionManager.checkPermissions(permissions, new PermissionManager.PermissionRequestListener() { + @Override + public void onPermissionGranted() { + connection.setSessionId(xmppConnectionService.getJingleConnectionManager().proposeJingleRtpSession( + account, + with, + ImmutableSet.of(Media.AUDIO) + )); + } - @Override - public void onPermissionDenied(DeniedPermissions deniedPermissions) { - connection.setDisconnected(new DisconnectCause(DisconnectCause.ERROR)); - } - }); + @Override + public void onPermissionDenied(DeniedPermissions deniedPermissions) { + connection.setDisconnected(new DisconnectCause(DisconnectCause.ERROR)); + } + }); - connection.setInitializing(); - connection.setAddress( - Uri.fromParts("tel", tel, null), // Normalized tel as tel: URI - TelecomManager.PRESENTATION_ALLOWED - ); + connection.setInitializing(); + connection.setAddress( + Uri.fromParts("tel", tel, null), // Normalized tel as tel: URI + TelecomManager.PRESENTATION_ALLOWED + ); - xmppConnectionService.setOnRtpConnectionUpdateListener( - (XmppConnectionService.OnJingleRtpConnectionUpdate) connection - ); + xmppConnectionService.setOnRtpConnectionUpdateListener( + (XmppConnectionService.OnJingleRtpConnectionUpdate) connection + ); - return connection; - } + return connection; + } - public class CheogramConnection extends Connection implements XmppConnectionService.OnJingleRtpConnectionUpdate { - protected Account account; - protected Jid with; - protected String sessionId = null; - protected Stack postDial = new Stack(); - protected Icon gatewayIcon; - protected WeakReference rtpConnection = null; + @Override + public Connection onCreateIncomingConnection(PhoneAccountHandle handle, ConnectionRequest request) { + Bundle extras = request.getExtras(); + String accountJid = extras.getString("account"); + String withJid = extras.getString("with"); + String sessionId = extras.getString("sessionId"); - CheogramConnection(Account account, Jid with, String postDialString) { - super(); - this.account = account; - this.with = with; + Account account = xmppConnectionService.findAccountByJid(Jid.of(accountJid)); + Jid with = Jid.of(withJid); - gatewayIcon = Icon.createWithBitmap(xmppConnectionService.getAvatarService().get( - account.getRoster().getContact(Jid.of(with.getDomain())), - AvatarService.getSystemUiAvatarSize(xmppConnectionService), - false - )); + CheogramConnection connection = new CheogramConnection(account, with, null); + connection.setSessionId(sessionId); + connection.setAddress( + Uri.fromParts("tel", with.getLocal(), null), + TelecomManager.PRESENTATION_ALLOWED + ); + connection.setRinging(); - if (postDialString != null) { - for (int i = postDialString.length() - 1; i >= 0; i--) { - postDial.push("" + postDialString.charAt(i)); - } - } + xmppConnectionService.setOnRtpConnectionUpdateListener(connection); - setCallerDisplayName( - account.getDisplayName(), - TelecomManager.PRESENTATION_ALLOWED - ); - setAudioModeIsVoip(true); - setConnectionCapabilities( - Connection.CAPABILITY_CAN_SEND_RESPONSE_VIA_CONNECTION - ); - } + return connection; + } - public void setSessionId(final String sessionId) { - this.sessionId = sessionId; - } + public class CheogramConnection extends Connection implements XmppConnectionService.OnJingleRtpConnectionUpdate { + protected Account account; + protected Jid with; + protected String sessionId = null; + protected Stack postDial = new Stack<>(); + protected Icon gatewayIcon; + protected WeakReference rtpConnection = null; - @Override - public void onJingleRtpConnectionUpdate(final Account account, final Jid with, final String sessionId, final RtpEndUserState state) { - if (sessionId == null || !sessionId.equals(this.sessionId)) return; - if (rtpConnection == null) { - this.with = with; // Store full JID of connection - rtpConnection = xmppConnectionService.getJingleConnectionManager().findJingleRtpConnection(account, with, sessionId); - } + CheogramConnection(Account account, Jid with, String postDialString) { + super(); + this.account = account; + this.with = with; - setStatusHints(new StatusHints(null, gatewayIcon, null)); + gatewayIcon = Icon.createWithBitmap(xmppConnectionService.getAvatarService().get( + account.getRoster().getContact(Jid.of(with.getDomain())), + AvatarService.getSystemUiAvatarSize(xmppConnectionService), + false + )); - if (state == RtpEndUserState.FINDING_DEVICE) { - setInitialized(); - } else if (state == RtpEndUserState.RINGING) { - setDialing(); - } else if (state == RtpEndUserState.CONNECTED) { - xmppConnectionService.setDiallerIntegrationActive(true); - setActive(); + if (postDialString != null) { + for (int i = postDialString.length() - 1; i >= 0; i--) { + postDial.push("" + postDialString.charAt(i)); + } + } - 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)); - } - } + setCallerDisplayName( + account.getDisplayName(), + TelecomManager.PRESENTATION_ALLOWED + ); + setAudioModeIsVoip(true); + setConnectionCapabilities( + Connection.CAPABILITY_CAN_SEND_RESPONSE_VIA_CONNECTION + ); + } - @Override - 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: - setAudioRoute(CallAudioState.ROUTE_WIRED_OR_EARPIECE); - } - } + public void setSessionId(final String sessionId) { + this.sessionId = sessionId; + } - @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 + public void onJingleRtpConnectionUpdate(final Account account, final Jid with, final String sessionId, final RtpEndUserState state) { + if (sessionId == null || !sessionId.equals(this.sessionId)) return; + if (rtpConnection == null) { + this.with = with; // Store full JID of connection + rtpConnection = xmppConnectionService.getJingleConnectionManager().findJingleRtpConnection(account, with, sessionId); + } - @Override - public void onAbort() { - onDisconnect(); - } + setStatusHints(new StatusHints(null, gatewayIcon, null)); - @Override - public void onPlayDtmfTone(char c) { - rtpConnection.get().applyDtmfTone("" + c); - } + if (state == RtpEndUserState.FINDING_DEVICE) { + setInitialized(); + } else if (state == RtpEndUserState.RINGING) { + setDialing(); + } else if (state == RtpEndUserState.INCOMING_CALL) { + setRinging(); + } else if (state == RtpEndUserState.CONNECTED) { + xmppConnectionService.setDiallerIntegrationActive(true); + setActive(); - @Override - public void onPostDialContinue(boolean c) { - if (c) postDial(); - } + 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)); + } + } - protected void sleep(int ms) { - try { - Thread.sleep(ms); - } catch (InterruptedException ex) { - Thread.currentThread().interrupt(); - } - } + @Override + 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: + setAudioRoute(CallAudioState.ROUTE_WIRED_OR_EARPIECE); + } + } - 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); - } - } - } - } + @Override + public void onAnswer() { + // For incoming calls, a connection update may not have been triggered before answering + // so we have to acquire the rtp connection object here + this.rtpConnection = + xmppConnectionService.getJingleConnectionManager() + .findJingleRtpConnection(account, with, sessionId); + + // Request recording permission only when answering + Set permissions = new HashSet<>(); + permissions.add(Manifest.permission.RECORD_AUDIO); + mPermissionManager.checkPermissions(permissions, new PermissionManager.PermissionRequestListener() { + @Override + public void onPermissionGranted() { + if (rtpConnection == null || rtpConnection.get() == null) { + setDisconnected(new DisconnectCause(DisconnectCause.ERROR)); + } else { + rtpConnection.get().acceptCall(); + } + } + + @Override + public void onPermissionDenied(DeniedPermissions deniedPermissions) { + setDisconnected(new DisconnectCause(DisconnectCause.ERROR)); + } + }); + } + + @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 + public void onAbort() { + onDisconnect(); + } + + @Override + public void onPlayDtmfTone(char c) { + 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/entities/Contact.java b/src/main/java/eu/siacs/conversations/entities/Contact.java index 7e25f979c..bc1c809e4 100644 --- a/src/main/java/eu/siacs/conversations/entities/Contact.java +++ b/src/main/java/eu/siacs/conversations/entities/Contact.java @@ -599,7 +599,7 @@ public class Contact implements ListItem, Blockable { "/" + getJid().asBareJid().toString(); } - protected PhoneAccountHandle phoneAccountHandle() { + public PhoneAccountHandle phoneAccountHandle() { ComponentName componentName = new ComponentName( "com.cheogram.android", "com.cheogram.android.ConnectionService" diff --git a/src/main/java/eu/siacs/conversations/services/NotificationService.java b/src/main/java/eu/siacs/conversations/services/NotificationService.java index 75c9fa3cc..62baeccd5 100644 --- a/src/main/java/eu/siacs/conversations/services/NotificationService.java +++ b/src/main/java/eu/siacs/conversations/services/NotificationService.java @@ -16,9 +16,12 @@ import android.media.Ringtone; import android.media.RingtoneManager; import android.net.Uri; import android.os.Build; +import android.os.Bundle; import android.os.SystemClock; import android.os.Vibrator; import android.preference.PreferenceManager; +import android.telecom.PhoneAccountHandle; +import android.telecom.TelecomManager; import android.text.SpannableString; import android.text.style.StyleSpan; import android.util.DisplayMetrics; @@ -423,7 +426,52 @@ public class NotificationService { notify(DELIVERY_FAILED_NOTIFICATION_ID, summaryNotification); } + private synchronized boolean tryRingingWithDialerUI(final AbstractJingleConnection.Id id, final Set media) { + if (media.size() != 1 || !media.contains(Media.AUDIO)) { + // Currently our ConnectionService only handles single audio calls + Log.w(Config.LOGTAG, "only audio calls can be handled by cheogram connection service"); + return false; + } + + PhoneAccountHandle handle = null; + for (Contact contact : id.account.getRoster().getContacts()) { + if (!contact.getJid().getDomain().equals(id.with.getDomain())) + continue; + if (!contact.getPresences().anyIdentity("gateway", "pstn")) + continue; + handle = contact.phoneAccountHandle(); + break; + } + + if (handle == null) { + Log.w(Config.LOGTAG, "Could not find phone account handle for " + id.account.getJid().toString()); + return false; + } + + Bundle callInfo = new Bundle(); + callInfo.putString("account", id.account.getJid().toString()); + callInfo.putString("with", id.with.toString()); + callInfo.putString("sessionId", id.sessionId); + + TelecomManager telecomManager = mXmppConnectionService.getSystemService(TelecomManager.class); + + try { + telecomManager.addNewIncomingCall(handle, callInfo); + } catch (SecurityException e) { + // There *could* be race conditions where the account is not registered yet + // when an incoming call is already received + Log.w(Config.LOGTAG, e); + return false; + } + + return true; + } + public synchronized void startRinging(final AbstractJingleConnection.Id id, final Set media) { + if (tryRingingWithDialerUI(id, media)) { + return; + } + showIncomingCallNotification(id, media); final NotificationManager notificationManager = (NotificationManager) mXmppConnectionService.getSystemService(Context.NOTIFICATION_SERVICE); final int currentInterruptionFilter;