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