CallIntegration.java

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