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