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