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