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