1package eu.siacs.conversations.services;
2
3import android.content.Context;
4import android.content.pm.PackageManager;
5import android.media.AudioAttributes;
6import android.media.AudioManager;
7import android.media.ToneGenerator;
8import android.net.Uri;
9import android.os.Build;
10import android.telecom.CallAudioState;
11import android.telecom.CallEndpoint;
12import android.telecom.Connection;
13import android.telecom.DisconnectCause;
14import android.util.Log;
15
16import androidx.annotation.NonNull;
17import androidx.annotation.RequiresApi;
18
19import com.google.common.collect.ImmutableSet;
20import com.google.common.collect.Iterables;
21import com.google.common.collect.Lists;
22
23import eu.siacs.conversations.Config;
24import eu.siacs.conversations.R;
25import eu.siacs.conversations.ui.util.MainThreadExecutor;
26import eu.siacs.conversations.xmpp.Jid;
27import eu.siacs.conversations.xmpp.jingle.JingleConnectionManager;
28import eu.siacs.conversations.xmpp.jingle.Media;
29
30import java.util.Arrays;
31import java.util.Collections;
32import java.util.List;
33import java.util.Set;
34import java.util.concurrent.TimeUnit;
35import java.util.concurrent.atomic.AtomicBoolean;
36
37public class CallIntegration extends Connection {
38
39 private static final List<String> BROKEN_DEVICE_MODELS =
40 Arrays.asList(
41 "OnePlus6" // OnePlus 6 (Android 8.1-11) Device is buggy and always starts the
42 // OS call screen even though we want to be self managed
43 );
44
45 public static final int DEFAULT_TONE_VOLUME = 60;
46 private static final int DEFAULT_MEDIA_PLAYER_VOLUME = 90;
47
48 private final Context context;
49
50 private final AppRTCAudioManager appRTCAudioManager;
51 private AudioDevice initialAudioDevice = null;
52
53 private boolean isAudioRoutingRequested = false;
54 private final AtomicBoolean initialAudioDeviceConfigured = new AtomicBoolean(false);
55 private final AtomicBoolean delayedDestructionInitiated = new AtomicBoolean(false);
56 private final AtomicBoolean isDestroyed = new AtomicBoolean(false);
57
58 private List<CallEndpoint> availableEndpoints = Collections.emptyList();
59
60 private Callback callback = null;
61
62 public CallIntegration(final Context context) {
63 this.context = context.getApplicationContext();
64 if (selfManaged()) {
65 if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
66 setConnectionProperties(Connection.PROPERTY_SELF_MANAGED);
67 } else {
68 throw new AssertionError(
69 "Trying to set connection properties on unsupported version");
70 }
71 this.appRTCAudioManager = null;
72 } else {
73 this.appRTCAudioManager = new AppRTCAudioManager(context);
74 this.appRTCAudioManager.setAudioManagerEvents(this::onAudioDeviceChanged);
75 }
76 setRingbackRequested(true);
77 }
78
79 public void setCallback(final Callback callback) {
80 this.callback = callback;
81 }
82
83 @Override
84 public void onShowIncomingCallUi() {
85 Log.d(Config.LOGTAG, "onShowIncomingCallUi");
86 this.callback.onCallIntegrationShowIncomingCallUi();
87 }
88
89 @Override
90 public void onAnswer() {
91 this.callback.onCallIntegrationAnswer();
92 }
93
94 @Override
95 public void onDisconnect() {
96 Log.d(Config.LOGTAG, "onDisconnect()");
97 this.callback.onCallIntegrationDisconnect();
98 }
99
100 @Override
101 public void onReject() {
102 this.callback.onCallIntegrationReject();
103 }
104
105 @Override
106 public void onReject(final String replyMessage) {
107 Log.d(Config.LOGTAG, "onReject(" + replyMessage + ")");
108 this.callback.onCallIntegrationReject();
109 }
110
111 @RequiresApi(api = Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
112 @Override
113 public void onAvailableCallEndpointsChanged(@NonNull List<CallEndpoint> availableEndpoints) {
114 Log.d(Config.LOGTAG, "onAvailableCallEndpointsChanged(" + availableEndpoints + ")");
115 this.availableEndpoints = availableEndpoints;
116 this.onAudioDeviceChanged(
117 getAudioDeviceUpsideDownCake(getCurrentCallEndpoint()),
118 ImmutableSet.copyOf(
119 Lists.transform(
120 availableEndpoints,
121 CallIntegration::getAudioDeviceUpsideDownCake)));
122 }
123
124 @RequiresApi(api = Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
125 @Override
126 public void onCallEndpointChanged(@NonNull final CallEndpoint callEndpoint) {
127 Log.d(Config.LOGTAG, "onCallEndpointChanged()");
128 this.onAudioDeviceChanged(
129 getAudioDeviceUpsideDownCake(callEndpoint),
130 ImmutableSet.copyOf(
131 Lists.transform(
132 this.availableEndpoints,
133 CallIntegration::getAudioDeviceUpsideDownCake)));
134 }
135
136 @Override
137 public void onCallAudioStateChanged(final CallAudioState state) {
138 if (selfManaged() && Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
139 Log.d(Config.LOGTAG, "ignoring onCallAudioStateChange() on Upside Down Cake");
140 return;
141 }
142 Log.d(Config.LOGTAG, "onCallAudioStateChange(" + state + ")");
143 this.onAudioDeviceChanged(getAudioDeviceOreo(state), getAudioDevicesOreo(state));
144 }
145
146 public Set<AudioDevice> getAudioDevices() {
147 if (notSelfManaged(context)) {
148 return getAudioDevicesFallback();
149 } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
150 return getAudioDevicesUpsideDownCake();
151 } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
152 return getAudioDevicesOreo();
153 } else {
154 throw new AssertionError("Trying to get audio devices on unsupported version");
155 }
156 }
157
158 public AudioDevice getSelectedAudioDevice() {
159 if (notSelfManaged(context)) {
160 return getAudioDeviceFallback();
161 } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
162 return getAudioDeviceUpsideDownCake();
163 } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
164 return getAudioDeviceOreo();
165 } else {
166 throw new AssertionError("Trying to get selected audio device on unsupported version");
167 }
168 }
169
170 public void setAudioDevice(final AudioDevice audioDevice) {
171 if (notSelfManaged(context)) {
172 setAudioDeviceFallback(audioDevice);
173 } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
174 setAudioDeviceUpsideDownCake(audioDevice);
175 } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
176 setAudioDeviceOreo(audioDevice);
177 } else {
178 throw new AssertionError("Trying to set audio devices on unsupported version");
179 }
180 }
181
182 public void setAudioDeviceWhenAvailable(final AudioDevice audioDevice) {
183 final var available = getAudioDevices();
184 if (available.contains(audioDevice) && !available.contains(AudioDevice.BLUETOOTH)) {
185 this.setAudioDevice(audioDevice);
186 } else {
187 Log.d(
188 Config.LOGTAG,
189 "application requested to switch to "
190 + audioDevice
191 + " but we won't because available devices are "
192 + available);
193 }
194 }
195
196 @RequiresApi(api = Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
197 private Set<AudioDevice> getAudioDevicesUpsideDownCake() {
198 return ImmutableSet.copyOf(
199 Lists.transform(
200 this.availableEndpoints, CallIntegration::getAudioDeviceUpsideDownCake));
201 }
202
203 @RequiresApi(api = Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
204 private AudioDevice getAudioDeviceUpsideDownCake() {
205 return getAudioDeviceUpsideDownCake(getCurrentCallEndpoint());
206 }
207
208 @RequiresApi(api = Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
209 private static AudioDevice getAudioDeviceUpsideDownCake(final CallEndpoint callEndpoint) {
210 if (callEndpoint == null) {
211 return AudioDevice.NONE;
212 }
213 final var endpointType = callEndpoint.getEndpointType();
214 return switch (endpointType) {
215 case CallEndpoint.TYPE_BLUETOOTH -> AudioDevice.BLUETOOTH;
216 case CallEndpoint.TYPE_EARPIECE -> AudioDevice.EARPIECE;
217 case CallEndpoint.TYPE_SPEAKER -> AudioDevice.SPEAKER_PHONE;
218 case CallEndpoint.TYPE_WIRED_HEADSET -> AudioDevice.WIRED_HEADSET;
219 case CallEndpoint.TYPE_STREAMING -> AudioDevice.STREAMING;
220 case CallEndpoint.TYPE_UNKNOWN -> AudioDevice.NONE;
221 default -> throw new IllegalStateException("Unknown endpoint type " + endpointType);
222 };
223 }
224
225 @RequiresApi(api = Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
226 private void setAudioDeviceUpsideDownCake(final AudioDevice audioDevice) {
227 final var callEndpointOptional =
228 Iterables.tryFind(
229 this.availableEndpoints,
230 e -> getAudioDeviceUpsideDownCake(e) == audioDevice);
231 if (callEndpointOptional.isPresent()) {
232 final var endpoint = callEndpointOptional.get();
233 requestCallEndpointChange(
234 endpoint,
235 MainThreadExecutor.getInstance(),
236 result -> Log.d(Config.LOGTAG, "switched to endpoint " + endpoint));
237 } else {
238 Log.w(Config.LOGTAG, "no endpoint found matching " + audioDevice);
239 }
240 }
241
242 private Set<AudioDevice> getAudioDevicesOreo() {
243 final var audioState = getCallAudioState();
244 if (audioState == null) {
245 Log.d(
246 Config.LOGTAG,
247 "no CallAudioState available. returning empty set for audio devices");
248 return Collections.emptySet();
249 }
250 return getAudioDevicesOreo(audioState);
251 }
252
253 private static Set<AudioDevice> getAudioDevicesOreo(final CallAudioState callAudioState) {
254 final ImmutableSet.Builder<AudioDevice> supportedAudioDevicesBuilder =
255 new ImmutableSet.Builder<>();
256 final var supportedRouteMask = callAudioState.getSupportedRouteMask();
257 if ((supportedRouteMask & CallAudioState.ROUTE_BLUETOOTH)
258 == CallAudioState.ROUTE_BLUETOOTH) {
259 supportedAudioDevicesBuilder.add(AudioDevice.BLUETOOTH);
260 }
261 if ((supportedRouteMask & CallAudioState.ROUTE_EARPIECE) == CallAudioState.ROUTE_EARPIECE) {
262 supportedAudioDevicesBuilder.add(AudioDevice.EARPIECE);
263 }
264 if ((supportedRouteMask & CallAudioState.ROUTE_SPEAKER) == CallAudioState.ROUTE_SPEAKER) {
265 supportedAudioDevicesBuilder.add(AudioDevice.SPEAKER_PHONE);
266 }
267 if ((supportedRouteMask & CallAudioState.ROUTE_WIRED_HEADSET)
268 == CallAudioState.ROUTE_WIRED_HEADSET) {
269 supportedAudioDevicesBuilder.add(AudioDevice.WIRED_HEADSET);
270 }
271 return supportedAudioDevicesBuilder.build();
272 }
273
274 private AudioDevice getAudioDeviceOreo() {
275 final var audioState = getCallAudioState();
276 if (audioState == null) {
277 Log.d(Config.LOGTAG, "no CallAudioState available. returning NONE as audio device");
278 return AudioDevice.NONE;
279 }
280 return getAudioDeviceOreo(audioState);
281 }
282
283 private static AudioDevice getAudioDeviceOreo(final CallAudioState audioState) {
284 // technically we get a mask here; maybe we should query the mask instead
285 return switch (audioState.getRoute()) {
286 case CallAudioState.ROUTE_BLUETOOTH -> AudioDevice.BLUETOOTH;
287 case CallAudioState.ROUTE_EARPIECE -> AudioDevice.EARPIECE;
288 case CallAudioState.ROUTE_SPEAKER -> AudioDevice.SPEAKER_PHONE;
289 case CallAudioState.ROUTE_WIRED_HEADSET -> AudioDevice.WIRED_HEADSET;
290 default -> AudioDevice.NONE;
291 };
292 }
293
294 @RequiresApi(api = Build.VERSION_CODES.O)
295 private void setAudioDeviceOreo(final AudioDevice audioDevice) {
296 switch (audioDevice) {
297 case EARPIECE -> setAudioRoute(CallAudioState.ROUTE_EARPIECE);
298 case BLUETOOTH -> setAudioRoute(CallAudioState.ROUTE_BLUETOOTH);
299 case WIRED_HEADSET -> setAudioRoute(CallAudioState.ROUTE_WIRED_HEADSET);
300 case SPEAKER_PHONE -> setAudioRoute(CallAudioState.ROUTE_SPEAKER);
301 }
302 }
303
304 private Set<AudioDevice> getAudioDevicesFallback() {
305 return requireAppRtcAudioManager().getAudioDevices();
306 }
307
308 private AudioDevice getAudioDeviceFallback() {
309 final var audioDevice = requireAppRtcAudioManager().getSelectedAudioDevice();
310 return audioDevice == null ? AudioDevice.NONE : audioDevice;
311 }
312
313 private void setAudioDeviceFallback(final AudioDevice audioDevice) {
314 final var audioManager = requireAppRtcAudioManager();
315 audioManager.executeOnMain(() -> audioManager.setDefaultAudioDevice(audioDevice));
316 }
317
318 @NonNull
319 private AppRTCAudioManager requireAppRtcAudioManager() {
320 if (this.appRTCAudioManager == null) {
321 throw new IllegalStateException(
322 "You are trying to access the fallback audio manager on a modern device");
323 }
324 return this.appRTCAudioManager;
325 }
326
327 @Override
328 public void onSilence() {
329 this.callback.onCallIntegrationSilence();
330 }
331
332 @Override
333 public void onStateChanged(final int state) {
334 Log.d(Config.LOGTAG, "onStateChanged(" + state + ")");
335 if (notSelfManaged(context)) {
336 if (state == STATE_DIALING) {
337 requireAppRtcAudioManager().startRingBack();
338 } else {
339 requireAppRtcAudioManager().stopRingBack();
340 }
341 }
342 if (state == STATE_ACTIVE) {
343 playConnectedSound();
344 } else if (state == STATE_DISCONNECTED) {
345 final var audioManager = this.appRTCAudioManager;
346 if (audioManager != null) {
347 audioManager.executeOnMain(audioManager::stop);
348 }
349 }
350 }
351
352 private void playConnectedSound() {
353 final var audioAttributes =
354 new AudioAttributes.Builder()
355 .setLegacyStreamType(AudioManager.STREAM_VOICE_CALL)
356 .build();
357 final var mediaPlayer =
358 MediaPlayer.create(
359 context,
360 R.raw.connected,
361 audioAttributes,
362 AudioManager.AUDIO_SESSION_ID_GENERATE);
363 mediaPlayer.setVolume(
364 DEFAULT_MEDIA_PLAYER_VOLUME / 100f, DEFAULT_MEDIA_PLAYER_VOLUME / 100f);
365 mediaPlayer.start();
366 }
367
368 public void success() {
369 Log.d(Config.LOGTAG, "CallIntegration.success()");
370 final var toneGenerator =
371 new ToneGenerator(AudioManager.STREAM_VOICE_CALL, DEFAULT_TONE_VOLUME);
372 toneGenerator.startTone(ToneGenerator.TONE_CDMA_CALLDROP_LITE, 375);
373 this.destroyWithDelay(new DisconnectCause(DisconnectCause.LOCAL, null), 375);
374 }
375
376 public void accepted() {
377 Log.d(Config.LOGTAG, "CallIntegration.accepted()");
378 if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N_MR1) {
379 this.destroyWith(new DisconnectCause(DisconnectCause.ANSWERED_ELSEWHERE, null));
380 } else {
381 this.destroyWith(new DisconnectCause(DisconnectCause.CANCELED, null));
382 }
383 }
384
385 public void error() {
386 Log.d(Config.LOGTAG, "CallIntegration.error()");
387 final var toneGenerator =
388 new ToneGenerator(AudioManager.STREAM_VOICE_CALL, DEFAULT_TONE_VOLUME);
389 toneGenerator.startTone(ToneGenerator.TONE_CDMA_CALLDROP_LITE, 375);
390 this.destroyWithDelay(new DisconnectCause(DisconnectCause.ERROR, null), 375);
391 }
392
393 public void retracted() {
394 Log.d(Config.LOGTAG, "CallIntegration.retracted()");
395 // an alternative cause would be LOCAL
396 this.destroyWith(new DisconnectCause(DisconnectCause.CANCELED, null));
397 }
398
399 public void rejected() {
400 Log.d(Config.LOGTAG, "CallIntegration.rejected()");
401 this.destroyWith(new DisconnectCause(DisconnectCause.REJECTED, null));
402 }
403
404 public void busy() {
405 Log.d(Config.LOGTAG, "CallIntegration.busy()");
406 final var toneGenerator = new ToneGenerator(AudioManager.STREAM_VOICE_CALL, 80);
407 toneGenerator.startTone(ToneGenerator.TONE_CDMA_NETWORK_BUSY, 2500);
408 this.destroyWithDelay(new DisconnectCause(DisconnectCause.BUSY, null), 2500);
409 }
410
411 private void destroyWithDelay(final DisconnectCause disconnectCause, final int delay) {
412 if (this.delayedDestructionInitiated.compareAndSet(false, true)) {
413 JingleConnectionManager.SCHEDULED_EXECUTOR_SERVICE.schedule(
414 () -> {
415 this.setDisconnected(disconnectCause);
416 this.destroyCallIntegration();
417 },
418 delay,
419 TimeUnit.MILLISECONDS);
420 } else {
421 Log.w(Config.LOGTAG, "CallIntegration destruction has already been scheduled!");
422 }
423 }
424
425 private void destroyWith(final DisconnectCause disconnectCause) {
426 if (this.getState() == STATE_DISCONNECTED || this.delayedDestructionInitiated.get()) {
427 Log.d(Config.LOGTAG, "CallIntegration has already been destroyed");
428 return;
429 }
430 this.setDisconnected(disconnectCause);
431 this.destroyCallIntegration();
432 Log.d(Config.LOGTAG, "destroyed!");
433 }
434
435 public static Uri address(final Jid contact) {
436 return Uri.parse(String.format("xmpp:%s", contact.toEscapedString()));
437 }
438
439 public void verifyDisconnected() {
440 if (this.getState() == STATE_DISCONNECTED || this.delayedDestructionInitiated.get()) {
441 return;
442 }
443 throw new AssertionError("CallIntegration has not been disconnected");
444 }
445
446 private void onAudioDeviceChanged(
447 final CallIntegration.AudioDevice selectedAudioDevice,
448 final Set<CallIntegration.AudioDevice> availableAudioDevices) {
449 if (isAudioRoutingRequested) {
450 configureInitialAudioDevice(availableAudioDevices);
451 }
452 final var callback = this.callback;
453 if (callback == null) {
454 return;
455 }
456 callback.onAudioDeviceChanged(selectedAudioDevice, availableAudioDevices);
457 }
458
459 private void configureInitialAudioDevice(final Set<AudioDevice> availableAudioDevices) {
460 final var initialAudioDevice = this.initialAudioDevice;
461 if (initialAudioDevice == null) {
462 Log.d(Config.LOGTAG, "skipping configureInitialAudioDevice()");
463 return;
464 }
465 final var target = this.initialAudioDevice;
466 if (this.initialAudioDeviceConfigured.compareAndSet(false, true)) {
467 if (availableAudioDevices.contains(target)
468 && !availableAudioDevices.contains(AudioDevice.BLUETOOTH)) {
469 setAudioDevice(target);
470 Log.d(Config.LOGTAG, "configured initial audio device: " + target);
471 } else {
472 Log.d(
473 Config.LOGTAG,
474 "not setting initial audio device. available devices: "
475 + availableAudioDevices);
476 }
477 }
478 }
479
480 private boolean selfManaged() {
481 return selfManaged(context);
482 }
483
484 public static boolean selfManaged(final Context context) {
485 return Build.VERSION.SDK_INT >= Build.VERSION_CODES.O
486 && hasSystemFeature(context)
487 && isDeviceModelSupported();
488 }
489
490 public static boolean hasSystemFeature(final Context context) {
491 final var packageManager = context.getPackageManager();
492 if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
493 return packageManager.hasSystemFeature(PackageManager.FEATURE_TELECOM);
494 } else {
495 //noinspection deprecation
496 return packageManager.hasSystemFeature(PackageManager.FEATURE_CONNECTION_SERVICE);
497 }
498 }
499
500 private static boolean isDeviceModelSupported() {
501 if (BROKEN_DEVICE_MODELS.contains(Build.DEVICE)) {
502 return false;
503 }
504 // all Realme devices at least up to and including Android 11 are broken
505 if ("realme".equalsIgnoreCase(Build.MANUFACTURER)
506 && Build.VERSION.SDK_INT <= Build.VERSION_CODES.R) {
507 return false;
508 }
509 return true;
510 }
511
512 public static boolean notSelfManaged(final Context context) {
513 return !selfManaged(context);
514 }
515
516 public void setInitialAudioDevice(final AudioDevice audioDevice) {
517 Log.d(Config.LOGTAG, "setInitialAudioDevice(" + audioDevice + ")");
518 this.initialAudioDevice = audioDevice;
519 }
520
521 public void startAudioRouting() {
522 this.isAudioRoutingRequested = true;
523 if (selfManaged()) {
524 final var devices = getAudioDevices();
525 if (devices.isEmpty()) {
526 return;
527 }
528 configureInitialAudioDevice(devices);
529 return;
530 }
531 final var audioManager = requireAppRtcAudioManager();
532 audioManager.executeOnMain(
533 () -> {
534 audioManager.start();
535 this.onAudioDeviceChanged(
536 audioManager.getSelectedAudioDevice(), audioManager.getAudioDevices());
537 });
538 }
539
540 private void destroyCallIntegration() {
541 super.destroy();
542 this.isDestroyed.set(true);
543 }
544
545 public boolean isDestroyed() {
546 return this.isDestroyed.get();
547 }
548
549 /** AudioDevice is the names of possible audio devices that we currently support. */
550 public enum AudioDevice {
551 NONE,
552 SPEAKER_PHONE,
553 WIRED_HEADSET,
554 EARPIECE,
555 BLUETOOTH,
556 STREAMING
557 }
558
559 public static AudioDevice initialAudioDevice(final Set<Media> media) {
560 if (Media.audioOnly(media)) {
561 return AudioDevice.EARPIECE;
562 } else {
563 return AudioDevice.SPEAKER_PHONE;
564 }
565 }
566
567 public interface Callback {
568 void onCallIntegrationShowIncomingCallUi();
569
570 void onCallIntegrationDisconnect();
571
572 void onAudioDeviceChanged(
573 CallIntegration.AudioDevice selectedAudioDevice,
574 Set<CallIntegration.AudioDevice> availableAudioDevices);
575
576 void onCallIntegrationReject();
577
578 void onCallIntegrationAnswer();
579
580 void onCallIntegrationSilence();
581 }
582}