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;
25import androidx.core.content.ContextCompat;
26
27import com.google.common.collect.ImmutableSet;
28import com.google.common.util.concurrent.ListenableFuture;
29import com.google.common.util.concurrent.SettableFuture;
30
31import eu.siacs.conversations.Config;
32import eu.siacs.conversations.R;
33import eu.siacs.conversations.entities.Account;
34import eu.siacs.conversations.entities.Contact;
35import eu.siacs.conversations.ui.RtpSessionActivity;
36import eu.siacs.conversations.xmpp.Jid;
37import eu.siacs.conversations.xmpp.jingle.AbstractJingleConnection;
38import eu.siacs.conversations.xmpp.jingle.JingleConnectionManager;
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 private 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 JingleConnectionManager.RtpSessionProposal proposal;
130 try {
131 proposal =
132 service.getJingleConnectionManager()
133 .proposeJingleRtpSession(account, with, media);
134 } catch (final IllegalStateException e) {
135 return Connection.createFailedConnection(
136 new DisconnectCause(
137 DisconnectCause.ERROR,
138 "Phone is busy. Probably race condition. Try again in a 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.ofEscaped(extras.getString(EXTRA_ADDRESS));
191 } else {
192 jid = Jid.ofEscaped(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.ofEscaped(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 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 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.toEscapedString());
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 } catch (final SecurityException e) {
375 Log.e(Config.LOGTAG, "call integration not available", e);
376 Toast.makeText(service, R.string.call_integration_not_available, Toast.LENGTH_LONG)
377 .show();
378 }
379 } else {
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
393 public static boolean addNewIncomingCall(
394 final Context context, final AbstractJingleConnection.Id id) {
395 if (CallIntegration.notSelfManaged(context)) {
396 Log.d(
397 Config.LOGTAG,
398 "not adding incoming call to TelecomManager on Android "
399 + Build.VERSION.RELEASE
400 + " ("
401 + Build.DEVICE
402 + ")");
403 return true;
404 }
405 final var phoneAccountHandle =
406 CallIntegrationConnectionService.getHandle(context, id.account);
407 final var bundle = new Bundle();
408 bundle.putString(
409 TelecomManager.EXTRA_INCOMING_CALL_ADDRESS,
410 CallIntegration.address(id.with).toString());
411 final var extras = new Bundle();
412 extras.putString(EXTRA_SESSION_ID, id.sessionId);
413 bundle.putBundle(TelecomManager.EXTRA_INCOMING_CALL_EXTRAS, extras);
414 try {
415 context.getSystemService(TelecomManager.class)
416 .addNewIncomingCall(phoneAccountHandle, bundle);
417 } catch (final SecurityException e) {
418 Log.e(
419 Config.LOGTAG,
420 id.account.getJid().asBareJid() + ": call integration not available",
421 e);
422 return false;
423 }
424 return true;
425 }
426
427 public static class ServiceConnectionService {
428 private final ServiceConnection serviceConnection;
429 private final XmppConnectionService service;
430
431 public ServiceConnectionService(
432 final ServiceConnection serviceConnection, final XmppConnectionService service) {
433 this.serviceConnection = serviceConnection;
434 this.service = service;
435 }
436
437 public static XmppConnectionService get(
438 final ListenableFuture<ServiceConnectionService> future) {
439 try {
440 return future.get(2, TimeUnit.SECONDS).service;
441 } catch (final ExecutionException | InterruptedException | TimeoutException e) {
442 return null;
443 }
444 }
445
446 public static ListenableFuture<ServiceConnectionService> bindService(
447 final Context context) {
448 final SettableFuture<ServiceConnectionService> serviceConnectionFuture =
449 SettableFuture.create();
450 final var intent = new Intent(context, XmppConnectionService.class);
451 intent.setAction(XmppConnectionService.ACTION_CALL_INTEGRATION_SERVICE_STARTED);
452 final var serviceConnection =
453 new ServiceConnection() {
454
455 @Override
456 public void onServiceConnected(
457 final ComponentName name, final IBinder iBinder) {
458 final XmppConnectionService.XmppConnectionBinder binder =
459 (XmppConnectionService.XmppConnectionBinder) iBinder;
460 serviceConnectionFuture.set(
461 new ServiceConnectionService(this, binder.getService()));
462 }
463
464 @Override
465 public void onServiceDisconnected(final ComponentName name) {}
466 };
467 context.bindService(intent, serviceConnection, Context.BIND_AUTO_CREATE);
468 return serviceConnectionFuture;
469 }
470 }
471}