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 true;
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}