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