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