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