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