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