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