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