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    @Override
119    public void onPlayDtmfTone(char c) {
120        this.callback.applyDtmfTone("" + c);
121    }
122
123    @RequiresApi(api = Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
124    @Override
125    public void onAvailableCallEndpointsChanged(@NonNull List<CallEndpoint> availableEndpoints) {
126        Log.d(Config.LOGTAG, "onAvailableCallEndpointsChanged(" + availableEndpoints + ")");
127        this.availableEndpoints = availableEndpoints;
128        this.onAudioDeviceChanged(
129                getAudioDeviceUpsideDownCake(getCurrentCallEndpoint()),
130                ImmutableSet.copyOf(
131                        Lists.transform(
132                                availableEndpoints,
133                                CallIntegration::getAudioDeviceUpsideDownCake)));
134    }
135
136    @RequiresApi(api = Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
137    @Override
138    public void onCallEndpointChanged(@NonNull final CallEndpoint callEndpoint) {
139        Log.d(Config.LOGTAG, "onCallEndpointChanged()");
140        this.onAudioDeviceChanged(
141                getAudioDeviceUpsideDownCake(callEndpoint),
142                ImmutableSet.copyOf(
143                        Lists.transform(
144                                this.availableEndpoints,
145                                CallIntegration::getAudioDeviceUpsideDownCake)));
146    }
147
148    @Override
149    public void onCallAudioStateChanged(final CallAudioState state) {
150        if (selfManaged() && Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
151            Log.d(Config.LOGTAG, "ignoring onCallAudioStateChange() on Upside Down Cake");
152            return;
153        }
154        Log.d(Config.LOGTAG, "onCallAudioStateChange(" + state + ")");
155        this.onAudioDeviceChanged(getAudioDeviceOreo(state), getAudioDevicesOreo(state));
156    }
157
158    public Set<AudioDevice> getAudioDevices() {
159        if (notSelfManaged(context)) {
160            return getAudioDevicesFallback();
161        } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
162            return getAudioDevicesUpsideDownCake();
163        } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
164            return getAudioDevicesOreo();
165        } else {
166            throw new AssertionError("Trying to get audio devices on unsupported version");
167        }
168    }
169
170    public AudioDevice getSelectedAudioDevice() {
171        if (notSelfManaged(context)) {
172            return getAudioDeviceFallback();
173        } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
174            return getAudioDeviceUpsideDownCake();
175        } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
176            return getAudioDeviceOreo();
177        } else {
178            throw new AssertionError("Trying to get selected audio device on unsupported version");
179        }
180    }
181
182    public void setAudioDevice(final AudioDevice audioDevice) {
183        if (notSelfManaged(context)) {
184            setAudioDeviceFallback(audioDevice);
185        } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
186            setAudioDeviceUpsideDownCake(audioDevice);
187        } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
188            setAudioDeviceOreo(audioDevice);
189        } else {
190            throw new AssertionError("Trying to set audio devices on unsupported version");
191        }
192    }
193
194    public void setAudioDeviceWhenAvailable(final AudioDevice audioDevice) {
195        final var available = getAudioDevices();
196        if (available.contains(audioDevice) && !available.contains(AudioDevice.BLUETOOTH)) {
197            this.setAudioDevice(audioDevice);
198        } else {
199            Log.d(
200                    Config.LOGTAG,
201                    "application requested to switch to "
202                            + audioDevice
203                            + " but we won't because available devices are "
204                            + available);
205        }
206    }
207
208    @RequiresApi(api = Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
209    private Set<AudioDevice> getAudioDevicesUpsideDownCake() {
210        return ImmutableSet.copyOf(
211                Lists.transform(
212                        this.availableEndpoints, CallIntegration::getAudioDeviceUpsideDownCake));
213    }
214
215    @RequiresApi(api = Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
216    private AudioDevice getAudioDeviceUpsideDownCake() {
217        return getAudioDeviceUpsideDownCake(getCurrentCallEndpoint());
218    }
219
220    @RequiresApi(api = Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
221    private static AudioDevice getAudioDeviceUpsideDownCake(final CallEndpoint callEndpoint) {
222        if (callEndpoint == null) {
223            return AudioDevice.NONE;
224        }
225        final var endpointType = callEndpoint.getEndpointType();
226        return switch (endpointType) {
227            case CallEndpoint.TYPE_BLUETOOTH -> AudioDevice.BLUETOOTH;
228            case CallEndpoint.TYPE_EARPIECE -> AudioDevice.EARPIECE;
229            case CallEndpoint.TYPE_SPEAKER -> AudioDevice.SPEAKER_PHONE;
230            case CallEndpoint.TYPE_WIRED_HEADSET -> AudioDevice.WIRED_HEADSET;
231            case CallEndpoint.TYPE_STREAMING -> AudioDevice.STREAMING;
232            case CallEndpoint.TYPE_UNKNOWN -> AudioDevice.NONE;
233            default -> throw new IllegalStateException("Unknown endpoint type " + endpointType);
234        };
235    }
236
237    @RequiresApi(api = Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
238    private void setAudioDeviceUpsideDownCake(final AudioDevice audioDevice) {
239        final var callEndpointOptional =
240                Iterables.tryFind(
241                        this.availableEndpoints,
242                        e -> getAudioDeviceUpsideDownCake(e) == audioDevice);
243        if (callEndpointOptional.isPresent()) {
244            final var endpoint = callEndpointOptional.get();
245            requestCallEndpointChange(
246                    endpoint,
247                    MainThreadExecutor.getInstance(),
248                    result -> Log.d(Config.LOGTAG, "switched to endpoint " + endpoint));
249        } else {
250            Log.w(Config.LOGTAG, "no endpoint found matching " + audioDevice);
251        }
252    }
253
254    private Set<AudioDevice> getAudioDevicesOreo() {
255        final var audioState = getCallAudioState();
256        if (audioState == null) {
257            Log.d(
258                    Config.LOGTAG,
259                    "no CallAudioState available. returning empty set for audio devices");
260            return Collections.emptySet();
261        }
262        return getAudioDevicesOreo(audioState);
263    }
264
265    private static Set<AudioDevice> getAudioDevicesOreo(final CallAudioState callAudioState) {
266        final ImmutableSet.Builder<AudioDevice> supportedAudioDevicesBuilder =
267                new ImmutableSet.Builder<>();
268        final var supportedRouteMask = callAudioState.getSupportedRouteMask();
269        if ((supportedRouteMask & CallAudioState.ROUTE_BLUETOOTH)
270                == CallAudioState.ROUTE_BLUETOOTH) {
271            supportedAudioDevicesBuilder.add(AudioDevice.BLUETOOTH);
272        }
273        if ((supportedRouteMask & CallAudioState.ROUTE_EARPIECE) == CallAudioState.ROUTE_EARPIECE) {
274            supportedAudioDevicesBuilder.add(AudioDevice.EARPIECE);
275        }
276        if ((supportedRouteMask & CallAudioState.ROUTE_SPEAKER) == CallAudioState.ROUTE_SPEAKER) {
277            supportedAudioDevicesBuilder.add(AudioDevice.SPEAKER_PHONE);
278        }
279        if ((supportedRouteMask & CallAudioState.ROUTE_WIRED_HEADSET)
280                == CallAudioState.ROUTE_WIRED_HEADSET) {
281            supportedAudioDevicesBuilder.add(AudioDevice.WIRED_HEADSET);
282        }
283        return supportedAudioDevicesBuilder.build();
284    }
285
286    private AudioDevice getAudioDeviceOreo() {
287        final var audioState = getCallAudioState();
288        if (audioState == null) {
289            Log.d(Config.LOGTAG, "no CallAudioState available. returning NONE as audio device");
290            return AudioDevice.NONE;
291        }
292        return getAudioDeviceOreo(audioState);
293    }
294
295    private static AudioDevice getAudioDeviceOreo(final CallAudioState audioState) {
296        // technically we get a mask here; maybe we should query the mask instead
297        return switch (audioState.getRoute()) {
298            case CallAudioState.ROUTE_BLUETOOTH -> AudioDevice.BLUETOOTH;
299            case CallAudioState.ROUTE_EARPIECE -> AudioDevice.EARPIECE;
300            case CallAudioState.ROUTE_SPEAKER -> AudioDevice.SPEAKER_PHONE;
301            case CallAudioState.ROUTE_WIRED_HEADSET -> AudioDevice.WIRED_HEADSET;
302            default -> AudioDevice.NONE;
303        };
304    }
305
306    @RequiresApi(api = Build.VERSION_CODES.O)
307    private void setAudioDeviceOreo(final AudioDevice audioDevice) {
308        switch (audioDevice) {
309            case EARPIECE -> setAudioRoute(CallAudioState.ROUTE_EARPIECE);
310            case BLUETOOTH -> setAudioRoute(CallAudioState.ROUTE_BLUETOOTH);
311            case WIRED_HEADSET -> setAudioRoute(CallAudioState.ROUTE_WIRED_HEADSET);
312            case SPEAKER_PHONE -> setAudioRoute(CallAudioState.ROUTE_SPEAKER);
313        }
314    }
315
316    private Set<AudioDevice> getAudioDevicesFallback() {
317        return requireAppRtcAudioManager().getAudioDevices();
318    }
319
320    private AudioDevice getAudioDeviceFallback() {
321        final var audioDevice = requireAppRtcAudioManager().getSelectedAudioDevice();
322        return audioDevice == null ? AudioDevice.NONE : audioDevice;
323    }
324
325    private void setAudioDeviceFallback(final AudioDevice audioDevice) {
326        final var audioManager = requireAppRtcAudioManager();
327        audioManager.executeOnMain(() -> audioManager.setDefaultAudioDevice(audioDevice));
328    }
329
330    @NonNull
331    private AppRTCAudioManager requireAppRtcAudioManager() {
332        if (this.appRTCAudioManager == null) {
333            throw new IllegalStateException(
334                    "You are trying to access the fallback audio manager on a modern device");
335        }
336        return this.appRTCAudioManager;
337    }
338
339    @Override
340    public void onSilence() {
341        this.callback.onCallIntegrationSilence();
342    }
343
344    @Override
345    public void onStateChanged(final int state) {
346        Log.d(Config.LOGTAG, "onStateChanged(" + state + ")");
347        if (notSelfManaged(context)) {
348            if (state == STATE_DIALING) {
349                requireAppRtcAudioManager().startRingBack();
350            } else {
351                requireAppRtcAudioManager().stopRingBack();
352            }
353        }
354        if (state == STATE_ACTIVE) {
355            playConnectedSound();
356        } else if (state == STATE_DISCONNECTED) {
357            final var audioManager = this.appRTCAudioManager;
358            if (audioManager != null) {
359                audioManager.executeOnMain(audioManager::stop);
360            }
361        }
362    }
363
364    private void playConnectedSound() {
365        final var audioAttributes =
366                new AudioAttributes.Builder()
367                        .setLegacyStreamType(AudioManager.STREAM_VOICE_CALL)
368                        .build();
369        final var mediaPlayer =
370                MediaPlayer.create(
371                        context,
372                        R.raw.connected,
373                        audioAttributes,
374                        AudioManager.AUDIO_SESSION_ID_GENERATE);
375        mediaPlayer.setVolume(
376                DEFAULT_MEDIA_PLAYER_VOLUME / 100f, DEFAULT_MEDIA_PLAYER_VOLUME / 100f);
377        mediaPlayer.start();
378    }
379
380    public void success() {
381        Log.d(Config.LOGTAG, "CallIntegration.success()");
382        final var toneGenerator =
383                new ToneGenerator(AudioManager.STREAM_VOICE_CALL, DEFAULT_TONE_VOLUME);
384        toneGenerator.startTone(ToneGenerator.TONE_CDMA_CALLDROP_LITE, 375);
385        this.destroyWithDelay(new DisconnectCause(DisconnectCause.LOCAL, null), 375);
386    }
387
388    public void accepted() {
389        Log.d(Config.LOGTAG, "CallIntegration.accepted()");
390        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N_MR1) {
391            this.destroyWith(new DisconnectCause(DisconnectCause.ANSWERED_ELSEWHERE, null));
392        } else {
393            this.destroyWith(new DisconnectCause(DisconnectCause.CANCELED, null));
394        }
395    }
396
397    public void error() {
398        Log.d(Config.LOGTAG, "CallIntegration.error()");
399        final var toneGenerator =
400                new ToneGenerator(AudioManager.STREAM_VOICE_CALL, DEFAULT_TONE_VOLUME);
401        toneGenerator.startTone(ToneGenerator.TONE_CDMA_CALLDROP_LITE, 375);
402        this.destroyWithDelay(new DisconnectCause(DisconnectCause.ERROR, null), 375);
403    }
404
405    public void retracted() {
406        Log.d(Config.LOGTAG, "CallIntegration.retracted()");
407        // an alternative cause would be LOCAL
408        this.destroyWith(new DisconnectCause(DisconnectCause.CANCELED, null));
409    }
410
411    public void rejected() {
412        Log.d(Config.LOGTAG, "CallIntegration.rejected()");
413        this.destroyWith(new DisconnectCause(DisconnectCause.REJECTED, null));
414    }
415
416    public void busy() {
417        Log.d(Config.LOGTAG, "CallIntegration.busy()");
418        final var toneGenerator = new ToneGenerator(AudioManager.STREAM_VOICE_CALL, 80);
419        toneGenerator.startTone(ToneGenerator.TONE_CDMA_NETWORK_BUSY, 2500);
420        this.destroyWithDelay(new DisconnectCause(DisconnectCause.BUSY, null), 2500);
421    }
422
423    private void destroyWithDelay(final DisconnectCause disconnectCause, final int delay) {
424        if (this.delayedDestructionInitiated.compareAndSet(false, true)) {
425            JingleConnectionManager.SCHEDULED_EXECUTOR_SERVICE.schedule(
426                    () -> {
427                        this.setDisconnected(disconnectCause);
428                        this.destroyCallIntegration();
429                    },
430                    delay,
431                    TimeUnit.MILLISECONDS);
432        } else {
433            Log.w(Config.LOGTAG, "CallIntegration destruction has already been scheduled!");
434        }
435    }
436
437    private void destroyWith(final DisconnectCause disconnectCause) {
438        if (this.getState() == STATE_DISCONNECTED || this.delayedDestructionInitiated.get()) {
439            Log.d(Config.LOGTAG, "CallIntegration has already been destroyed");
440            return;
441        }
442        this.setDisconnected(disconnectCause);
443        this.destroyCallIntegration();
444        Log.d(Config.LOGTAG, "destroyed!");
445    }
446
447    public static Uri address(final Jid contact) {
448        return Uri.parse(String.format("xmpp:%s", contact.toEscapedString()));
449    }
450
451    public void verifyDisconnected() {
452        if (this.getState() == STATE_DISCONNECTED || this.delayedDestructionInitiated.get()) {
453            return;
454        }
455        throw new AssertionError("CallIntegration has not been disconnected");
456    }
457
458    private void onAudioDeviceChanged(
459            final CallIntegration.AudioDevice selectedAudioDevice,
460            final Set<CallIntegration.AudioDevice> availableAudioDevices) {
461        if (isAudioRoutingRequested) {
462            configureInitialAudioDevice(availableAudioDevices);
463        }
464        final var callback = this.callback;
465        if (callback == null) {
466            return;
467        }
468        callback.onAudioDeviceChanged(selectedAudioDevice, availableAudioDevices);
469    }
470
471    private void configureInitialAudioDevice(final Set<AudioDevice> availableAudioDevices) {
472        final var initialAudioDevice = this.initialAudioDevice;
473        if (initialAudioDevice == null) {
474            Log.d(Config.LOGTAG, "skipping configureInitialAudioDevice()");
475            return;
476        }
477        final var target = this.initialAudioDevice;
478        if (this.initialAudioDeviceConfigured.compareAndSet(false, true)) {
479            if (availableAudioDevices.contains(target)
480                    && !availableAudioDevices.contains(AudioDevice.BLUETOOTH)) {
481                setAudioDevice(target);
482                Log.d(Config.LOGTAG, "configured initial audio device: " + target);
483            } else {
484                Log.d(
485                        Config.LOGTAG,
486                        "not setting initial audio device. available devices: "
487                                + availableAudioDevices);
488            }
489        }
490    }
491
492    private boolean selfManaged() {
493        return selfManaged(context);
494    }
495
496    public static boolean selfManaged(final Context context) {
497        return Build.VERSION.SDK_INT >= Build.VERSION_CODES.O
498                && hasSystemFeature(context)
499                && !BROKEN_DEVICE_MODELS.contains(Build.DEVICE);
500    }
501
502    public static boolean hasSystemFeature(final Context context) {
503        final var packageManager = context.getPackageManager();
504        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
505            return packageManager.hasSystemFeature(PackageManager.FEATURE_TELECOM);
506        } else {
507            //noinspection deprecation
508            return packageManager.hasSystemFeature(PackageManager.FEATURE_CONNECTION_SERVICE);
509        }
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        boolean applyDtmfTone(final String dtmf);
583    }
584}