CallIntegration.java

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