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