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