CallIntegration.java

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