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