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.JingleRtpConnection;
 39import eu.siacs.conversations.xmpp.jingle.Media;
 40import eu.siacs.conversations.xmpp.jingle.RtpEndUserState;
 41import eu.siacs.conversations.xmpp.jingle.stanzas.Reason;
 42
 43import java.util.Arrays;
 44import java.util.Collection;
 45import java.util.Collections;
 46import java.util.Set;
 47import java.util.UUID;
 48import java.util.concurrent.ExecutionException;
 49import java.util.concurrent.ExecutorService;
 50import java.util.concurrent.Executors;
 51import java.util.concurrent.TimeUnit;
 52import java.util.concurrent.TimeoutException;
 53
 54public class CallIntegrationConnectionService extends ConnectionService {
 55
 56    private static final String EXTRA_ADDRESS = "eu.siacs.conversations.address";
 57    private static final String EXTRA_SESSION_ID = "eu.siacs.conversations.sid";
 58
 59    private static final ExecutorService ACCOUNT_REGISTRATION_EXECUTOR =
 60            Executors.newSingleThreadExecutor();
 61
 62    private ListenableFuture<ServiceConnectionService> serviceFuture;
 63
 64    @Override
 65    public void onCreate() {
 66        Log.d(Config.LOGTAG, "CallIntegrationService.onCreate()");
 67        super.onCreate();
 68        this.serviceFuture = ServiceConnectionService.bindService(this);
 69    }
 70
 71    @Override
 72    public void onDestroy() {
 73        Log.d(Config.LOGTAG, "destroying CallIntegrationConnectionService");
 74        super.onDestroy();
 75        final ServiceConnection serviceConnection;
 76        try {
 77            serviceConnection = serviceFuture.get().serviceConnection;
 78        } catch (final Exception e) {
 79            Log.d(Config.LOGTAG, "could not fetch service connection", e);
 80            return;
 81        }
 82        this.unbindService(serviceConnection);
 83    }
 84
 85    private static Connection createOutgoingRtpConnection(
 86            final XmppConnectionService service,
 87            final String phoneAccountHandle,
 88            final Jid with,
 89            final Set<Media> media) {
 90        if (service == null) {
 91            Log.d(
 92                    Config.LOGTAG,
 93                    "CallIntegrationConnection service was unable to bind to 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().toEscapedString());
110        intent.putExtra(RtpSessionActivity.EXTRA_WITH, with.toEscapedString());
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 var proposal =
129                        service.getJingleConnectionManager()
130                                .proposeJingleRtpSession(account, with, media);
131                intent.putExtra(
132                        RtpSessionActivity.EXTRA_LAST_REPORTED_STATE,
133                        RtpEndUserState.FINDING_DEVICE.toString());
134                callIntegration = proposal.getCallIntegration();
135            }
136            if (Media.audioOnly(media)) {
137                intent.putExtra(
138                        RtpSessionActivity.EXTRA_LAST_ACTION,
139                        RtpSessionActivity.ACTION_MAKE_VOICE_CALL);
140            } else {
141                intent.putExtra(
142                        RtpSessionActivity.EXTRA_LAST_ACTION,
143                        RtpSessionActivity.ACTION_MAKE_VIDEO_CALL);
144            }
145        } else {
146            final JingleRtpConnection jingleRtpConnection =
147                    service.getJingleConnectionManager().initializeRtpSession(account, with, media);
148            final String sessionId = jingleRtpConnection.getId().sessionId;
149            intent.putExtra(RtpSessionActivity.EXTRA_SESSION_ID, sessionId);
150            callIntegration = jingleRtpConnection.getCallIntegration();
151        }
152        service.startActivity(intent);
153        return callIntegration;
154    }
155
156    private static void sendJingleFinishMessage(
157            final XmppConnectionService service, final Contact contact, final Reason reason) {
158        service.getJingleConnectionManager()
159                .sendJingleMessageFinish(contact, UUID.randomUUID().toString(), reason);
160    }
161
162    @Override
163    public Connection onCreateOutgoingConnection(
164            final PhoneAccountHandle phoneAccountHandle, final ConnectionRequest request) {
165        Log.d(Config.LOGTAG, "onCreateOutgoingConnection(" + request.getAddress() + ")");
166        final var uri = request.getAddress();
167        final var extras = request.getExtras();
168        if (uri == null || !Arrays.asList("xmpp", "tel").contains(uri.getScheme())) {
169            return Connection.createFailedConnection(
170                    new DisconnectCause(DisconnectCause.ERROR, "invalid address"));
171        }
172        final Jid jid;
173        if ("tel".equals(uri.getScheme())) {
174            jid = Jid.ofEscaped(extras.getString(EXTRA_ADDRESS));
175        } else {
176            jid = Jid.ofEscaped(uri.getSchemeSpecificPart());
177        }
178        final int videoState = extras.getInt(TelecomManager.EXTRA_START_CALL_WITH_VIDEO_STATE);
179        final Set<Media> media =
180                videoState == VideoProfile.STATE_AUDIO_ONLY
181                        ? ImmutableSet.of(Media.AUDIO)
182                        : ImmutableSet.of(Media.AUDIO, Media.VIDEO);
183        Log.d(Config.LOGTAG, "jid=" + jid);
184        Log.d(Config.LOGTAG, "phoneAccountHandle:" + phoneAccountHandle.getId());
185        Log.d(Config.LOGTAG, "media " + media);
186        final var service = ServiceConnectionService.get(this.serviceFuture);
187        return createOutgoingRtpConnection(service, phoneAccountHandle.getId(), jid, media);
188    }
189
190    @Override
191    public Connection onCreateIncomingConnection(
192            final PhoneAccountHandle phoneAccountHandle, final ConnectionRequest request) {
193        Log.d(Config.LOGTAG, "onCreateIncomingConnection()");
194        final var service = ServiceConnectionService.get(this.serviceFuture);
195        final Bundle extras = request.getExtras();
196        final Bundle extraExtras = extras.getBundle(TelecomManager.EXTRA_INCOMING_CALL_EXTRAS);
197        final String incomingCallAddress =
198                extras.getString(TelecomManager.EXTRA_INCOMING_CALL_ADDRESS);
199        final String sid = extraExtras == null ? null : extraExtras.getString(EXTRA_SESSION_ID);
200        Log.d(Config.LOGTAG, "sid " + sid);
201        final Uri uri = incomingCallAddress == null ? null : Uri.parse(incomingCallAddress);
202        Log.d(Config.LOGTAG, "uri=" + uri);
203        if (uri == null || sid == null) {
204            return Connection.createFailedConnection(
205                    new DisconnectCause(
206                            DisconnectCause.ERROR,
207                            "connection request is missing required information"));
208        }
209        if (service == null) {
210            return Connection.createFailedConnection(
211                    new DisconnectCause(DisconnectCause.ERROR, "service connection not found"));
212        }
213        final var jid = Jid.ofEscaped(uri.getSchemeSpecificPart());
214        final Account account = service.findAccountByUuid(phoneAccountHandle.getId());
215        final var weakReference =
216                service.getJingleConnectionManager().findJingleRtpConnection(account, jid, sid);
217        if (weakReference == null) {
218            Log.d(Config.LOGTAG, "no connection found for " + jid + " and sid=" + sid);
219            return Connection.createFailedConnection(
220                    new DisconnectCause(DisconnectCause.ERROR, "no incoming connection found"));
221        }
222        final var jingleRtpConnection = weakReference.get();
223        if (jingleRtpConnection == null) {
224            Log.d(Config.LOGTAG, "connection has been terminated");
225            return Connection.createFailedConnection(
226                    new DisconnectCause(DisconnectCause.ERROR, "connection has been terminated"));
227        }
228        Log.d(Config.LOGTAG, "registering call integration for incoming call");
229        return jingleRtpConnection.getCallIntegration();
230    }
231
232    public static void togglePhoneAccountAsync(final Context context, final Account account) {
233        ACCOUNT_REGISTRATION_EXECUTOR.execute(() -> togglePhoneAccount(context, account));
234    }
235
236    private static void togglePhoneAccount(final Context context, final Account account) {
237        if (account.isEnabled()) {
238            registerPhoneAccount(context, account);
239        } else {
240            unregisterPhoneAccount(context, account);
241        }
242    }
243
244    private static void registerPhoneAccount(final Context context, final Account account) {
245        try {
246            registerPhoneAccountOrThrow(context, account);
247        } catch (final IllegalArgumentException e) {
248            Log.w(
249                    Config.LOGTAG,
250                    "could not register phone account for " + account.getJid().asBareJid(),
251                    e);
252            ContextCompat.getMainExecutor(context)
253                    .execute(() -> showCallIntegrationNotAvailable(context));
254        }
255    }
256
257    private static void showCallIntegrationNotAvailable(final Context context) {
258        Toast.makeText(context, R.string.call_integration_not_available, Toast.LENGTH_LONG).show();
259    }
260
261    private static void registerPhoneAccountOrThrow(final Context context, final Account account) {
262        final var handle = getHandle(context, account);
263        final var telecomManager = context.getSystemService(TelecomManager.class);
264        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
265            if (telecomManager.getOwnSelfManagedPhoneAccounts().contains(handle)) {
266                Log.d(
267                        Config.LOGTAG,
268                        "a phone account for " + account.getJid().asBareJid() + " already exists");
269                return;
270            }
271        }
272        final var builder =
273                PhoneAccount.builder(getHandle(context, account), account.getJid().asBareJid());
274        builder.setSupportedUriSchemes(Collections.singletonList("xmpp"));
275        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
276            builder.setCapabilities(
277                    PhoneAccount.CAPABILITY_SELF_MANAGED
278                            | PhoneAccount.CAPABILITY_SUPPORTS_VIDEO_CALLING);
279        }
280        final var phoneAccount = builder.build();
281        telecomManager.registerPhoneAccount(phoneAccount);
282    }
283
284    public static void togglePhoneAccountsAsync(
285            final Context context, final Collection<Account> accounts) {
286        ACCOUNT_REGISTRATION_EXECUTOR.execute(() -> togglePhoneAccounts(context, accounts));
287    }
288
289    private static void togglePhoneAccounts(
290            final Context context, final Collection<Account> accounts) {
291        for (final Account account : accounts) {
292            if (account.isEnabled()) {
293                try {
294                    registerPhoneAccountOrThrow(context, account);
295                } catch (final IllegalArgumentException e) {
296                    Log.w(
297                            Config.LOGTAG,
298                            "could not register phone account for " + account.getJid().asBareJid(),
299                            e);
300                }
301            } else {
302                unregisterPhoneAccount(context, account);
303            }
304        }
305    }
306
307    public static void unregisterPhoneAccount(final Context context, final Account account) {
308        final var handle = getHandle(context, account);
309        final var telecomManager = context.getSystemService(TelecomManager.class);
310        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
311            if (telecomManager.getOwnSelfManagedPhoneAccounts().contains(handle)) {
312                telecomManager.unregisterPhoneAccount(handle);
313            }
314        } else {
315            telecomManager.unregisterPhoneAccount(handle);
316        }
317    }
318
319    public static PhoneAccountHandle getHandle(final Context context, final Account account) {
320        final var competentName =
321                new ComponentName(context, CallIntegrationConnectionService.class);
322        return new PhoneAccountHandle(competentName, account.getUuid());
323    }
324
325    public static void placeCall(
326            final XmppConnectionService service,
327            final Account account,
328            final Jid with,
329            final Set<Media> media) {
330        if (CallIntegration.selfManaged(service)) {
331            final var extras = new Bundle();
332            extras.putParcelable(
333                    TelecomManager.EXTRA_PHONE_ACCOUNT_HANDLE, getHandle(service, account));
334            extras.putInt(
335                    TelecomManager.EXTRA_START_CALL_WITH_VIDEO_STATE,
336                    Media.audioOnly(media)
337                            ? VideoProfile.STATE_AUDIO_ONLY
338                            : VideoProfile.STATE_BIDIRECTIONAL);
339            if (service.checkSelfPermission(Manifest.permission.MANAGE_OWN_CALLS)
340                    != PackageManager.PERMISSION_GRANTED) {
341                Toast.makeText(service, R.string.no_permission_to_place_call, Toast.LENGTH_SHORT)
342                        .show();
343                return;
344            }
345            final Uri address;
346            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
347                // Android 9+ supports putting xmpp uris into the address
348                address = CallIntegration.address(with);
349            } else {
350                // for Android 8 we need to put in a fake tel uri
351                final var outgoingCallExtras = new Bundle();
352                outgoingCallExtras.putString(EXTRA_ADDRESS, with.toEscapedString());
353                extras.putBundle(TelecomManager.EXTRA_OUTGOING_CALL_EXTRAS, outgoingCallExtras);
354                address = Uri.parse("tel:0");
355            }
356            try {
357                service.getSystemService(TelecomManager.class).placeCall(address, extras);
358            } catch (final SecurityException e) {
359                Toast.makeText(service, R.string.call_integration_not_available, Toast.LENGTH_LONG)
360                        .show();
361            }
362        } else {
363            final var connection = createOutgoingRtpConnection(service, account, with, media);
364            if (connection != null) {
365                Log.d(
366                        Config.LOGTAG,
367                        "not adding outgoing call to TelecomManager on Android "
368                                + Build.VERSION.RELEASE);
369            }
370        }
371    }
372
373    public static boolean addNewIncomingCall(
374            final Context context, final AbstractJingleConnection.Id id) {
375        if (CallIntegration.notSelfManaged(context)) {
376            Log.d(
377                    Config.LOGTAG,
378                    "not adding incoming call to TelecomManager on Android "
379                            + Build.VERSION.RELEASE);
380            return true;
381        }
382        final var phoneAccountHandle =
383                CallIntegrationConnectionService.getHandle(context, id.account);
384        final var bundle = new Bundle();
385        bundle.putString(
386                TelecomManager.EXTRA_INCOMING_CALL_ADDRESS,
387                CallIntegration.address(id.with).toString());
388        final var extras = new Bundle();
389        extras.putString(EXTRA_SESSION_ID, id.sessionId);
390        bundle.putBundle(TelecomManager.EXTRA_INCOMING_CALL_EXTRAS, extras);
391        try {
392            context.getSystemService(TelecomManager.class)
393                    .addNewIncomingCall(phoneAccountHandle, bundle);
394        } catch (final SecurityException e) {
395            Log.e(
396                    Config.LOGTAG,
397                    id.account.getJid().asBareJid() + ": call integration not available",
398                    e);
399            return false;
400        }
401        return true;
402    }
403
404    public static class ServiceConnectionService {
405        private final ServiceConnection serviceConnection;
406        private final XmppConnectionService service;
407
408        public ServiceConnectionService(
409                final ServiceConnection serviceConnection, final XmppConnectionService service) {
410            this.serviceConnection = serviceConnection;
411            this.service = service;
412        }
413
414        public static XmppConnectionService get(
415                final ListenableFuture<ServiceConnectionService> future) {
416            try {
417                return future.get(2, TimeUnit.SECONDS).service;
418            } catch (final ExecutionException | InterruptedException | TimeoutException e) {
419                return null;
420            }
421        }
422
423        public static ListenableFuture<ServiceConnectionService> bindService(
424                final Context context) {
425            final SettableFuture<ServiceConnectionService> serviceConnectionFuture =
426                    SettableFuture.create();
427            final var intent = new Intent(context, XmppConnectionService.class);
428            intent.setAction(XmppConnectionService.ACTION_CALL_INTEGRATION_SERVICE_STARTED);
429            final var serviceConnection =
430                    new ServiceConnection() {
431
432                        @Override
433                        public void onServiceConnected(
434                                final ComponentName name, final IBinder iBinder) {
435                            final XmppConnectionService.XmppConnectionBinder binder =
436                                    (XmppConnectionService.XmppConnectionBinder) iBinder;
437                            serviceConnectionFuture.set(
438                                    new ServiceConnectionService(this, binder.getService()));
439                        }
440
441                        @Override
442                        public void onServiceDisconnected(final ComponentName name) {}
443                    };
444            context.bindService(intent, serviceConnection, Context.BIND_AUTO_CREATE);
445            return serviceConnectionFuture;
446        }
447    }
448}