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