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