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