CallIntegration.java

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