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