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