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