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.telecom.Connection;
 14import android.telecom.ConnectionRequest;
 15import android.telecom.ConnectionService;
 16import android.telecom.DisconnectCause;
 17import android.telecom.PhoneAccount;
 18import android.telecom.PhoneAccountHandle;
 19import android.telecom.TelecomManager;
 20import android.telecom.VideoProfile;
 21import android.util.Log;
 22import android.widget.Toast;
 23
 24import androidx.annotation.NonNull;
 25import androidx.core.content.ContextCompat;
 26
 27import com.google.common.collect.ImmutableSet;
 28import com.google.common.util.concurrent.ListenableFuture;
 29import com.google.common.util.concurrent.SettableFuture;
 30
 31import eu.siacs.conversations.Config;
 32import eu.siacs.conversations.R;
 33import eu.siacs.conversations.entities.Account;
 34import eu.siacs.conversations.entities.Contact;
 35import eu.siacs.conversations.ui.RtpSessionActivity;
 36import eu.siacs.conversations.xmpp.Jid;
 37import eu.siacs.conversations.xmpp.jingle.AbstractJingleConnection;
 38import eu.siacs.conversations.xmpp.jingle.JingleConnectionManager;
 39import eu.siacs.conversations.xmpp.jingle.JingleRtpConnection;
 40import eu.siacs.conversations.xmpp.jingle.Media;
 41import eu.siacs.conversations.xmpp.jingle.RtpEndUserState;
 42import eu.siacs.conversations.xmpp.jingle.stanzas.Reason;
 43
 44import java.util.Arrays;
 45import java.util.Collection;
 46import java.util.Collections;
 47import java.util.Set;
 48import java.util.UUID;
 49import java.util.concurrent.ExecutionException;
 50import java.util.concurrent.ExecutorService;
 51import java.util.concurrent.Executors;
 52import java.util.concurrent.TimeUnit;
 53import java.util.concurrent.TimeoutException;
 54
 55public class CallIntegrationConnectionService extends ConnectionService {
 56
 57    private static final String EXTRA_ADDRESS = "eu.siacs.conversations.address";
 58    private static final String EXTRA_SESSION_ID = "eu.siacs.conversations.sid";
 59
 60    private static final ExecutorService ACCOUNT_REGISTRATION_EXECUTOR =
 61            Executors.newSingleThreadExecutor();
 62
 63    private ListenableFuture<ServiceConnectionService> serviceFuture;
 64
 65    @Override
 66    public void onCreate() {
 67        Log.d(Config.LOGTAG, "CallIntegrationService.onCreate()");
 68        super.onCreate();
 69        this.serviceFuture = ServiceConnectionService.bindService(this);
 70    }
 71
 72    @Override
 73    public void onDestroy() {
 74        Log.d(Config.LOGTAG, "destroying CallIntegrationConnectionService");
 75        super.onDestroy();
 76        final ServiceConnection serviceConnection;
 77        try {
 78            serviceConnection = serviceFuture.get().serviceConnection;
 79        } catch (final Exception e) {
 80            Log.d(Config.LOGTAG, "could not fetch service connection", e);
 81            return;
 82        }
 83        this.unbindService(serviceConnection);
 84    }
 85
 86    private static Connection createOutgoingRtpConnection(
 87            final XmppConnectionService service,
 88            final String phoneAccountHandle,
 89            final Jid with,
 90            final Set<Media> media) {
 91        if (service == null) {
 92            Log.d(
 93                    Config.LOGTAG,
 94                    "CallIntegrationConnection service was unable to bind to XmppConnectionService");
 95            return Connection.createFailedConnection(
 96                    new DisconnectCause(DisconnectCause.ERROR, "service connection not found"));
 97        }
 98        final var account = service.findAccountByUuid(phoneAccountHandle);
 99        return createOutgoingRtpConnection(service, account, with, media);
100    }
101
102    private static Connection createOutgoingRtpConnection(
103            @NonNull final XmppConnectionService service,
104            @NonNull final Account account,
105            final Jid with,
106            final Set<Media> media) {
107        Log.d(Config.LOGTAG, "create outgoing rtp connection!");
108        final Intent intent = new Intent(service, RtpSessionActivity.class);
109        intent.setAction(Intent.ACTION_VIEW);
110        intent.putExtra(RtpSessionActivity.EXTRA_ACCOUNT, account.getJid().toEscapedString());
111        intent.putExtra(RtpSessionActivity.EXTRA_WITH, with.toEscapedString());
112        intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
113        intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK);
114        final Connection callIntegration;
115        if (with.isBareJid()) {
116            final var contact = account.getRoster().getContact(with);
117            if (Config.JINGLE_MESSAGE_INIT_STRICT_OFFLINE_CHECK
118                    && contact.getPresences().isEmpty()) {
119                intent.putExtra(
120                        RtpSessionActivity.EXTRA_LAST_REPORTED_STATE,
121                        RtpEndUserState.CONTACT_OFFLINE.toString());
122                callIntegration =
123                        Connection.createFailedConnection(
124                                new DisconnectCause(DisconnectCause.ERROR, "contact is offline"));
125                // we can use a JMI 'finish' message to notify the contact of a call we never
126                // actually attempted
127                // sendJingleFinishMessage(service, contact, Reason.CONNECTIVITY_ERROR);
128            } else {
129                final JingleConnectionManager.RtpSessionProposal proposal;
130                try {
131                    proposal =
132                            service.getJingleConnectionManager()
133                                    .proposeJingleRtpSession(account, with, media);
134                } catch (final IllegalStateException e) {
135                    return Connection.createFailedConnection(
136                            new DisconnectCause(
137                                    DisconnectCause.ERROR,
138                                    "Phone is busy. Probably race condition. Try again in a 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.ofEscaped(extras.getString(EXTRA_ADDRESS));
191        } else {
192            jid = Jid.ofEscaped(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.ofEscaped(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 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 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.toEscapedString());
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            } catch (final SecurityException e) {
375                Log.e(Config.LOGTAG, "call integration not available", e);
376                Toast.makeText(service, R.string.call_integration_not_available, Toast.LENGTH_LONG)
377                        .show();
378            }
379        } else {
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
393    public static boolean addNewIncomingCall(
394            final Context context, final AbstractJingleConnection.Id id) {
395        if (CallIntegration.notSelfManaged(context)) {
396            Log.d(
397                    Config.LOGTAG,
398                    "not adding incoming call to TelecomManager on Android "
399                            + Build.VERSION.RELEASE
400                            + " ("
401                            + Build.DEVICE
402                            + ")");
403            return true;
404        }
405        final var phoneAccountHandle =
406                CallIntegrationConnectionService.getHandle(context, id.account);
407        final var bundle = new Bundle();
408        bundle.putString(
409                TelecomManager.EXTRA_INCOMING_CALL_ADDRESS,
410                CallIntegration.address(id.with).toString());
411        final var extras = new Bundle();
412        extras.putString(EXTRA_SESSION_ID, id.sessionId);
413        bundle.putBundle(TelecomManager.EXTRA_INCOMING_CALL_EXTRAS, extras);
414        try {
415            context.getSystemService(TelecomManager.class)
416                    .addNewIncomingCall(phoneAccountHandle, bundle);
417        } catch (final SecurityException e) {
418            Log.e(
419                    Config.LOGTAG,
420                    id.account.getJid().asBareJid() + ": call integration not available",
421                    e);
422            return false;
423        }
424        return true;
425    }
426
427    public static class ServiceConnectionService {
428        private final ServiceConnection serviceConnection;
429        private final XmppConnectionService service;
430
431        public ServiceConnectionService(
432                final ServiceConnection serviceConnection, final XmppConnectionService service) {
433            this.serviceConnection = serviceConnection;
434            this.service = service;
435        }
436
437        public static XmppConnectionService get(
438                final ListenableFuture<ServiceConnectionService> future) {
439            try {
440                return future.get(2, TimeUnit.SECONDS).service;
441            } catch (final ExecutionException | InterruptedException | TimeoutException e) {
442                return null;
443            }
444        }
445
446        public static ListenableFuture<ServiceConnectionService> bindService(
447                final Context context) {
448            final SettableFuture<ServiceConnectionService> serviceConnectionFuture =
449                    SettableFuture.create();
450            final var intent = new Intent(context, XmppConnectionService.class);
451            intent.setAction(XmppConnectionService.ACTION_CALL_INTEGRATION_SERVICE_STARTED);
452            final var serviceConnection =
453                    new ServiceConnection() {
454
455                        @Override
456                        public void onServiceConnected(
457                                final ComponentName name, final IBinder iBinder) {
458                            final XmppConnectionService.XmppConnectionBinder binder =
459                                    (XmppConnectionService.XmppConnectionBinder) iBinder;
460                            serviceConnectionFuture.set(
461                                    new ServiceConnectionService(this, binder.getService()));
462                        }
463
464                        @Override
465                        public void onServiceDisconnected(final ComponentName name) {}
466                    };
467            context.bindService(intent, serviceConnection, Context.BIND_AUTO_CREATE);
468            return serviceConnectionFuture;
469        }
470    }
471}