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