CallIntegration.java

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