CallIntegrationConnectionService.java

  1package eu.siacs.conversations.services;
  2
  3import android.Manifest;
  4import android.content.ComponentName;
  5import android.content.Context;
  6import android.content.Intent;
  7import android.content.ServiceConnection;
  8import android.content.pm.PackageManager;
  9import android.net.Uri;
 10import android.os.Build;
 11import android.os.Bundle;
 12import android.os.IBinder;
 13import android.preference.PreferenceManager;
 14import android.telecom.Connection;
 15import android.telecom.ConnectionRequest;
 16import android.telecom.ConnectionService;
 17import android.telecom.DisconnectCause;
 18import android.telecom.PhoneAccount;
 19import android.telecom.PhoneAccountHandle;
 20import android.telecom.TelecomManager;
 21import android.telecom.VideoProfile;
 22import android.util.Log;
 23import android.widget.Toast;
 24
 25import androidx.annotation.NonNull;
 26import androidx.core.content.ContextCompat;
 27
 28import com.google.common.collect.ImmutableSet;
 29import com.google.common.util.concurrent.ListenableFuture;
 30import com.google.common.util.concurrent.SettableFuture;
 31
 32import eu.siacs.conversations.Config;
 33import eu.siacs.conversations.R;
 34import eu.siacs.conversations.entities.Account;
 35import eu.siacs.conversations.entities.Contact;
 36import eu.siacs.conversations.ui.RtpSessionActivity;
 37import eu.siacs.conversations.xmpp.Jid;
 38import eu.siacs.conversations.xmpp.jingle.AbstractJingleConnection;
 39import eu.siacs.conversations.xmpp.jingle.JingleConnectionManager;
 40import eu.siacs.conversations.xmpp.jingle.JingleRtpConnection;
 41import eu.siacs.conversations.xmpp.jingle.Media;
 42import eu.siacs.conversations.xmpp.jingle.RtpEndUserState;
 43import eu.siacs.conversations.xmpp.jingle.stanzas.Reason;
 44
 45import java.util.Arrays;
 46import java.util.Collection;
 47import java.util.Collections;
 48import java.util.Set;
 49import java.util.UUID;
 50import java.util.concurrent.ExecutionException;
 51import java.util.concurrent.ExecutorService;
 52import java.util.concurrent.Executors;
 53import java.util.concurrent.TimeUnit;
 54import java.util.concurrent.TimeoutException;
 55
 56public class CallIntegrationConnectionService extends ConnectionService {
 57
 58    private static final String EXTRA_ADDRESS = "eu.siacs.conversations.address";
 59    public static final String EXTRA_SESSION_ID = "eu.siacs.conversations.sid";
 60
 61    private static final ExecutorService ACCOUNT_REGISTRATION_EXECUTOR =
 62            Executors.newSingleThreadExecutor();
 63
 64    private ListenableFuture<ServiceConnectionService> serviceFuture;
 65
 66    @Override
 67    public void onCreate() {
 68        Log.d(Config.LOGTAG, "CallIntegrationService.onCreate()");
 69        super.onCreate();
 70        this.serviceFuture = ServiceConnectionService.bindService(this);
 71    }
 72
 73    @Override
 74    public void onDestroy() {
 75        Log.d(Config.LOGTAG, "destroying CallIntegrationConnectionService");
 76        super.onDestroy();
 77        final ServiceConnection serviceConnection;
 78        try {
 79            serviceConnection = serviceFuture.get().serviceConnection;
 80        } catch (final Exception e) {
 81            Log.d(Config.LOGTAG, "could not fetch service connection", e);
 82            return;
 83        }
 84        this.unbindService(serviceConnection);
 85    }
 86
 87    private static Connection createOutgoingRtpConnection(
 88            final XmppConnectionService service,
 89            final String phoneAccountHandle,
 90            final Jid with,
 91            final Set<Media> media) {
 92        if (service == null) {
 93            Log.d(
 94                    Config.LOGTAG,
 95                    "CallIntegrationConnection service was unable to bind to XmppConnectionService");
 96            return Connection.createFailedConnection(
 97                    new DisconnectCause(DisconnectCause.ERROR, "service connection not found"));
 98        }
 99        final var account = service.findAccountByUuid(phoneAccountHandle);
100        return createOutgoingRtpConnection(service, account, with, media);
101    }
102
103    private static Connection createOutgoingRtpConnection(
104            @NonNull final XmppConnectionService service,
105            @NonNull final Account account,
106            final Jid with,
107            final Set<Media> media) {
108        Log.d(Config.LOGTAG, "create outgoing rtp connection!");
109        final Intent intent = new Intent(service, RtpSessionActivity.class);
110        intent.setAction(Intent.ACTION_VIEW);
111        intent.putExtra(RtpSessionActivity.EXTRA_ACCOUNT, account.getJid().toEscapedString());
112        intent.putExtra(RtpSessionActivity.EXTRA_WITH, with.toEscapedString());
113        intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
114        intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK);
115        final Connection callIntegration;
116        if (with.isBareJid()) {
117            final var contact = account.getRoster().getContact(with);
118            if (Config.JINGLE_MESSAGE_INIT_STRICT_OFFLINE_CHECK
119                    && contact.getPresences().isEmpty()) {
120                intent.putExtra(
121                        RtpSessionActivity.EXTRA_LAST_REPORTED_STATE,
122                        RtpEndUserState.CONTACT_OFFLINE.toString());
123                callIntegration =
124                        Connection.createFailedConnection(
125                                new DisconnectCause(DisconnectCause.ERROR, "contact is offline"));
126                // we can use a JMI 'finish' message to notify the contact of a call we never
127                // actually attempted
128                // sendJingleFinishMessage(service, contact, Reason.CONNECTIVITY_ERROR);
129            } else {
130                final JingleConnectionManager.RtpSessionProposal proposal;
131                try {
132                    proposal =
133                            service.getJingleConnectionManager()
134                                    .proposeJingleRtpSession(account, with, media);
135                } catch (final IllegalStateException e) {
136                    return Connection.createFailedConnection(
137                            new DisconnectCause(
138                                    DisconnectCause.ERROR,
139                                    "Phone is busy. Probably race condition. Try again in a moment"));
140                }
141                if (proposal == null) {
142                    // TODO instead of just null checking try to get the sessionID
143                    return Connection.createFailedConnection(
144                            new DisconnectCause(
145                                    DisconnectCause.ERROR, "a call is already in progress"));
146                }
147                intent.putExtra(
148                        RtpSessionActivity.EXTRA_LAST_REPORTED_STATE,
149                        RtpEndUserState.FINDING_DEVICE.toString());
150                intent.putExtra(RtpSessionActivity.EXTRA_PROPOSED_SESSION_ID, proposal.sessionId);
151                callIntegration = proposal.getCallIntegration();
152            }
153            if (Media.audioOnly(media)) {
154                intent.putExtra(
155                        RtpSessionActivity.EXTRA_LAST_ACTION,
156                        RtpSessionActivity.ACTION_MAKE_VOICE_CALL);
157            } else {
158                intent.putExtra(
159                        RtpSessionActivity.EXTRA_LAST_ACTION,
160                        RtpSessionActivity.ACTION_MAKE_VIDEO_CALL);
161            }
162        } else {
163            final JingleRtpConnection jingleRtpConnection =
164                    service.getJingleConnectionManager().initializeRtpSession(account, with, media);
165            final String sessionId = jingleRtpConnection.getId().sessionId;
166            intent.putExtra(RtpSessionActivity.EXTRA_SESSION_ID, sessionId);
167            callIntegration = jingleRtpConnection.getCallIntegration();
168        }
169        service.startActivity(intent);
170        return callIntegration;
171    }
172
173    private static void sendJingleFinishMessage(
174            final XmppConnectionService service, final Contact contact, final Reason reason) {
175        service.getJingleConnectionManager()
176                .sendJingleMessageFinish(contact, UUID.randomUUID().toString(), reason);
177    }
178
179    @Override
180    public Connection onCreateOutgoingConnection(
181            final PhoneAccountHandle phoneAccountHandle, final ConnectionRequest request) {
182        Log.d(Config.LOGTAG, "onCreateOutgoingConnection(" + request.getAddress() + ")");
183        final var uri = request.getAddress();
184        final var extras = request.getExtras();
185        if (uri == null || !Arrays.asList("xmpp", "tel").contains(uri.getScheme())) {
186            return Connection.createFailedConnection(
187                    new DisconnectCause(DisconnectCause.ERROR, "invalid address"));
188        }
189        final Jid jid;
190        if ("tel".equals(uri.getScheme())) {
191            jid = Jid.ofEscaped(extras.getString(EXTRA_ADDRESS));
192        } else {
193            jid = Jid.ofEscaped(uri.getSchemeSpecificPart());
194        }
195        final int videoState = extras.getInt(TelecomManager.EXTRA_START_CALL_WITH_VIDEO_STATE);
196        final Set<Media> media =
197                videoState == VideoProfile.STATE_AUDIO_ONLY
198                        ? ImmutableSet.of(Media.AUDIO)
199                        : ImmutableSet.of(Media.AUDIO, Media.VIDEO);
200        Log.d(Config.LOGTAG, "jid=" + jid);
201        Log.d(Config.LOGTAG, "phoneAccountHandle:" + phoneAccountHandle.getId());
202        Log.d(Config.LOGTAG, "media " + media);
203        final var service = ServiceConnectionService.get(this.serviceFuture);
204        return createOutgoingRtpConnection(service, phoneAccountHandle.getId(), jid, media);
205    }
206
207    @Override
208    public Connection onCreateIncomingConnection(
209            final PhoneAccountHandle phoneAccountHandle, final ConnectionRequest request) {
210        Log.d(Config.LOGTAG, "onCreateIncomingConnection()");
211        final var service = ServiceConnectionService.get(this.serviceFuture);
212        final Bundle extras = request.getExtras();
213        final Bundle extraExtras = extras.getBundle(TelecomManager.EXTRA_INCOMING_CALL_EXTRAS);
214        final String incomingCallAddress =
215                extras.getString(TelecomManager.EXTRA_INCOMING_CALL_ADDRESS);
216        final String sid = extraExtras == null ? null : extraExtras.getString(EXTRA_SESSION_ID);
217        Log.d(Config.LOGTAG, "sid " + sid);
218        final Uri uri = incomingCallAddress == null ? null : Uri.parse(incomingCallAddress);
219        Log.d(Config.LOGTAG, "uri=" + uri);
220        if (uri == null || sid == null) {
221            return Connection.createFailedConnection(
222                    new DisconnectCause(
223                            DisconnectCause.ERROR,
224                            "connection request is missing required information"));
225        }
226        if (service == null) {
227            return Connection.createFailedConnection(
228                    new DisconnectCause(DisconnectCause.ERROR, "service connection not found"));
229        }
230        final var jid = Jid.ofEscaped(uri.getSchemeSpecificPart());
231        final Account account = service.findAccountByUuid(phoneAccountHandle.getId());
232        final var weakReference =
233                service.getJingleConnectionManager().findJingleRtpConnection(account, jid, sid);
234        if (weakReference == null) {
235            Log.d(Config.LOGTAG, "no connection found for " + jid + " and sid=" + sid);
236            return Connection.createFailedConnection(
237                    new DisconnectCause(DisconnectCause.ERROR, "no incoming connection found"));
238        }
239        final var jingleRtpConnection = weakReference.get();
240        if (jingleRtpConnection == null) {
241            Log.d(Config.LOGTAG, "connection has been terminated");
242            return Connection.createFailedConnection(
243                    new DisconnectCause(DisconnectCause.ERROR, "connection has been terminated"));
244        }
245        Log.d(Config.LOGTAG, "registering call integration for incoming call");
246        return jingleRtpConnection.getCallIntegration();
247    }
248
249    public static void togglePhoneAccountAsync(final Context context, final Account account) {
250        ACCOUNT_REGISTRATION_EXECUTOR.execute(() -> togglePhoneAccount(context, account));
251    }
252
253    private static void togglePhoneAccount(final Context context, final Account account) {
254        if (account.isEnabled()) {
255            registerPhoneAccount(context, account);
256        } else {
257            unregisterPhoneAccount(context, account);
258        }
259    }
260
261    private static void registerPhoneAccount(final Context context, final Account account) {
262        try {
263            registerPhoneAccountOrThrow(context, account);
264        } catch (final IllegalArgumentException | SecurityException e) {
265            Log.w(
266                    Config.LOGTAG,
267                    "could not register phone account for " + account.getJid().asBareJid(),
268                    e);
269            ContextCompat.getMainExecutor(context)
270                    .execute(() -> showCallIntegrationNotAvailable(context));
271        }
272    }
273
274    private static void showCallIntegrationNotAvailable(final Context context) {
275        Toast.makeText(context, R.string.call_integration_not_available, Toast.LENGTH_LONG).show();
276    }
277
278    private static void registerPhoneAccountOrThrow(final Context context, final Account account) {
279        final var handle = getHandle(context, account);
280        final var telecomManager = context.getSystemService(TelecomManager.class);
281        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
282            if (telecomManager.getOwnSelfManagedPhoneAccounts().contains(handle)) {
283                Log.d(
284                        Config.LOGTAG,
285                        "a phone account for " + account.getJid().asBareJid() + " already exists");
286                return;
287            }
288        }
289        final var builder =
290                PhoneAccount.builder(getHandle(context, account), account.getJid().asBareJid());
291        builder.setSupportedUriSchemes(Collections.singletonList("xmpp"));
292        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
293            builder.setCapabilities(
294                    PhoneAccount.CAPABILITY_SELF_MANAGED
295                            | PhoneAccount.CAPABILITY_SUPPORTS_VIDEO_CALLING);
296        }
297        final var phoneAccount = builder.build();
298        telecomManager.registerPhoneAccount(phoneAccount);
299    }
300
301    public static void togglePhoneAccountsAsync(
302            final Context context, final Collection<Account> accounts) {
303        ACCOUNT_REGISTRATION_EXECUTOR.execute(() -> togglePhoneAccounts(context, accounts));
304    }
305
306    private static void togglePhoneAccounts(
307            final Context context, final Collection<Account> accounts) {
308        for (final Account account : accounts) {
309            if (account.isEnabled()) {
310                try {
311                    registerPhoneAccountOrThrow(context, account);
312                } catch (final IllegalArgumentException | SecurityException e) {
313                    Log.w(
314                            Config.LOGTAG,
315                            "could not register phone account for " + account.getJid().asBareJid(),
316                            e);
317                }
318            } else {
319                unregisterPhoneAccount(context, account);
320            }
321        }
322    }
323
324    public static void unregisterPhoneAccount(final Context context, final Account account) {
325        final var handle = getHandle(context, account);
326        final var telecomManager = context.getSystemService(TelecomManager.class);
327        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
328            if (telecomManager.getOwnSelfManagedPhoneAccounts().contains(handle)) {
329                telecomManager.unregisterPhoneAccount(handle);
330            }
331        } else {
332            telecomManager.unregisterPhoneAccount(handle);
333        }
334    }
335
336    public static PhoneAccountHandle getHandle(final Context context, final Account account) {
337        final var competentName =
338                new ComponentName(context, CallIntegrationConnectionService.class);
339        return new PhoneAccountHandle(competentName, account.getUuid());
340    }
341
342    public static void placeCall(
343            final XmppConnectionService service,
344            final Account account,
345            final Jid with,
346            final Set<Media> media) {
347        if (CallIntegration.selfManaged(service)) {
348            final var extras = new Bundle();
349            extras.putParcelable(
350                    TelecomManager.EXTRA_PHONE_ACCOUNT_HANDLE, getHandle(service, account));
351            extras.putInt(
352                    TelecomManager.EXTRA_START_CALL_WITH_VIDEO_STATE,
353                    Media.audioOnly(media)
354                            ? VideoProfile.STATE_AUDIO_ONLY
355                            : VideoProfile.STATE_BIDIRECTIONAL);
356            if (service.checkSelfPermission(Manifest.permission.MANAGE_OWN_CALLS)
357                    != PackageManager.PERMISSION_GRANTED) {
358                Toast.makeText(service, R.string.no_permission_to_place_call, Toast.LENGTH_SHORT)
359                        .show();
360                return;
361            }
362            final Uri address;
363            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
364                // Android 9+ supports putting xmpp uris into the address
365                address = CallIntegration.address(with);
366            } else {
367                // for Android 8 we need to put in a fake tel uri
368                final var outgoingCallExtras = new Bundle();
369                outgoingCallExtras.putString(EXTRA_ADDRESS, with.toEscapedString());
370                extras.putBundle(TelecomManager.EXTRA_OUTGOING_CALL_EXTRAS, outgoingCallExtras);
371                address = Uri.parse("tel:0");
372            }
373            try {
374                service.getSystemService(TelecomManager.class).placeCall(address, extras);
375                return;
376            } catch (final SecurityException e) {
377                Log.e(Config.LOGTAG, "call integration not available", e);
378            }
379        }
380
381        final var connection = createOutgoingRtpConnection(service, account, with, media);
382        if (connection != null) {
383            Log.d(
384                    Config.LOGTAG,
385                    "not adding outgoing call to TelecomManager on Android "
386                            + Build.VERSION.RELEASE
387                            + " ("
388                            + Build.DEVICE
389                            + ")");
390        }
391    }
392
393    private static PhoneAccountHandle findPhoneAccount(final Context context, final AbstractJingleConnection.Id id) {
394        final var def = CallIntegrationConnectionService.getHandle(context, id.account);
395        if (Build.VERSION.SDK_INT < 23) return def;
396
397        final var prefs = PreferenceManager.getDefaultSharedPreferences(context);
398        if (!prefs.getBoolean("dialler_integration_incoming", true)) return def;
399
400        if (context.checkSelfPermission(Manifest.permission.RECORD_AUDIO) != PackageManager.PERMISSION_GRANTED) {
401            // We cannot request audio permission in Dialer UI
402            // when Dialer is shown over keyguard, the user cannot even necessarily
403            // see notifications.
404            return def;
405        }
406
407        /* Are video calls really coming in from a PSTN gateway?
408        if (media.size() != 1 || !media.contains(Media.AUDIO)) {
409            // Currently our ConnectionService only handles single audio calls
410            Log.w(Config.LOGTAG, "only audio calls can be handled by cheogram connection service");
411            return def;
412        }*/
413
414        for (Contact contact : id.account.getRoster().getContacts()) {
415            if (!contact.getJid().getDomain().equals(id.with.getDomain())) {
416                continue;
417            }
418
419            if (!contact.getPresences().anyIdentity("gateway", "pstn")) {
420                continue;
421            }
422
423            final var handle = contact.phoneAccountHandle();
424            if (handle != null) return handle;
425        }
426
427        return def;
428    }
429
430    public static boolean addNewIncomingCall(
431            final Context context, final AbstractJingleConnection.Id id) {
432        if (NotificationService.isQuietHours(context, id.getContact().getAccount())) return true;
433        if (CallIntegration.notSelfManaged(context)) {
434            Log.d(
435                    Config.LOGTAG,
436                    "not adding incoming call to TelecomManager on Android "
437                            + Build.VERSION.RELEASE
438                            + " ("
439                            + Build.DEVICE
440                            + ")");
441            return true;
442        }
443        final var phoneAccountHandle = findPhoneAccount(context, id);
444        final var bundle = new Bundle();
445        bundle.putString(
446                TelecomManager.EXTRA_INCOMING_CALL_ADDRESS,
447                CallIntegration.address(id.with).toString());
448        final var extras = new Bundle();
449        extras.putString(EXTRA_SESSION_ID, id.sessionId);
450        extras.putString("account", id.account.getJid().toString());
451        extras.putString("with", id.with.toString());
452        bundle.putBundle(TelecomManager.EXTRA_INCOMING_CALL_EXTRAS, extras);
453        try {
454            context.getSystemService(TelecomManager.class)
455                    .addNewIncomingCall(phoneAccountHandle, bundle);
456        } catch (final SecurityException e) {
457            Log.e(
458                    Config.LOGTAG,
459                    id.account.getJid().asBareJid() + ": call integration not available",
460                    e);
461            return false;
462        }
463        return true;
464    }
465
466    public static class ServiceConnectionService {
467        private final ServiceConnection serviceConnection;
468        private final XmppConnectionService service;
469
470        public ServiceConnectionService(
471                final ServiceConnection serviceConnection, final XmppConnectionService service) {
472            this.serviceConnection = serviceConnection;
473            this.service = service;
474        }
475
476        public static XmppConnectionService get(
477                final ListenableFuture<ServiceConnectionService> future) {
478            try {
479                return future.get(2, TimeUnit.SECONDS).service;
480            } catch (final ExecutionException | InterruptedException | TimeoutException e) {
481                return null;
482            }
483        }
484
485        public static ListenableFuture<ServiceConnectionService> bindService(
486                final Context context) {
487            final SettableFuture<ServiceConnectionService> serviceConnectionFuture =
488                    SettableFuture.create();
489            final var intent = new Intent(context, XmppConnectionService.class);
490            intent.setAction(XmppConnectionService.ACTION_CALL_INTEGRATION_SERVICE_STARTED);
491            final var serviceConnection =
492                    new ServiceConnection() {
493
494                        @Override
495                        public void onServiceConnected(
496                                final ComponentName name, final IBinder iBinder) {
497                            final XmppConnectionService.XmppConnectionBinder binder =
498                                    (XmppConnectionService.XmppConnectionBinder) iBinder;
499                            serviceConnectionFuture.set(
500                                    new ServiceConnectionService(this, binder.getService()));
501                        }
502
503                        @Override
504                        public void onServiceDisconnected(final ComponentName name) {}
505                    };
506            context.bindService(intent, serviceConnection, Context.BIND_AUTO_CREATE);
507            return serviceConnectionFuture;
508        }
509    }
510}