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