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