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