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