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