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