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}