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