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        return requireAppRtcAudioManager().getSelectedAudioDevice();
301    }
302
303    private void setAudioDeviceFallback(final AudioDevice audioDevice) {
304        final var audioManager = requireAppRtcAudioManager();
305        audioManager.executeOnMain(() -> audioManager.setDefaultAudioDevice(audioDevice));
306    }
307
308    @NonNull
309    private AppRTCAudioManager requireAppRtcAudioManager() {
310        if (this.appRTCAudioManager == null) {
311            throw new IllegalStateException(
312                    "You are trying to access the fallback audio manager on a modern device");
313        }
314        return this.appRTCAudioManager;
315    }
316
317    @Override
318    public void onSilence() {
319        this.callback.onCallIntegrationSilence();
320    }
321
322    @Override
323    public void onStateChanged(final int state) {
324        Log.d(Config.LOGTAG, "onStateChanged(" + state + ")");
325        if (notSelfManaged(context)) {
326            if (state == STATE_DIALING) {
327                requireAppRtcAudioManager().startRingBack();
328            } else {
329                requireAppRtcAudioManager().stopRingBack();
330            }
331        }
332        if (state == STATE_ACTIVE) {
333            playConnectedSound();
334        } else if (state == STATE_DISCONNECTED) {
335            final var audioManager = this.appRTCAudioManager;
336            if (audioManager != null) {
337                audioManager.executeOnMain(audioManager::stop);
338            }
339        }
340    }
341
342    private void playConnectedSound() {
343        final var audioAttributes =
344                new AudioAttributes.Builder()
345                        .setLegacyStreamType(AudioManager.STREAM_VOICE_CALL)
346                        .build();
347        final var mediaPlayer =
348                MediaPlayer.create(
349                        context,
350                        R.raw.connected,
351                        audioAttributes,
352                        AudioManager.AUDIO_SESSION_ID_GENERATE);
353        mediaPlayer.setVolume(
354                DEFAULT_MEDIA_PLAYER_VOLUME / 100f, DEFAULT_MEDIA_PLAYER_VOLUME / 100f);
355        mediaPlayer.start();
356    }
357
358    public void success() {
359        Log.d(Config.LOGTAG, "CallIntegration.success()");
360        final var toneGenerator =
361                new ToneGenerator(AudioManager.STREAM_VOICE_CALL, DEFAULT_TONE_VOLUME);
362        toneGenerator.startTone(ToneGenerator.TONE_CDMA_CALLDROP_LITE, 375);
363        this.destroyWithDelay(new DisconnectCause(DisconnectCause.LOCAL, null), 375);
364    }
365
366    public void accepted() {
367        Log.d(Config.LOGTAG, "CallIntegration.accepted()");
368        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N_MR1) {
369            this.destroyWith(new DisconnectCause(DisconnectCause.ANSWERED_ELSEWHERE, null));
370        } else {
371            this.destroyWith(new DisconnectCause(DisconnectCause.CANCELED, null));
372        }
373    }
374
375    public void error() {
376        Log.d(Config.LOGTAG, "CallIntegration.error()");
377        final var toneGenerator =
378                new ToneGenerator(AudioManager.STREAM_VOICE_CALL, DEFAULT_TONE_VOLUME);
379        toneGenerator.startTone(ToneGenerator.TONE_CDMA_CALLDROP_LITE, 375);
380        this.destroyWithDelay(new DisconnectCause(DisconnectCause.ERROR, null), 375);
381    }
382
383    public void retracted() {
384        Log.d(Config.LOGTAG, "CallIntegration.retracted()");
385        // an alternative cause would be LOCAL
386        this.destroyWith(new DisconnectCause(DisconnectCause.CANCELED, null));
387    }
388
389    public void rejected() {
390        Log.d(Config.LOGTAG, "CallIntegration.rejected()");
391        this.destroyWith(new DisconnectCause(DisconnectCause.REJECTED, null));
392    }
393
394    public void busy() {
395        Log.d(Config.LOGTAG, "CallIntegration.busy()");
396        final var toneGenerator = new ToneGenerator(AudioManager.STREAM_VOICE_CALL, 80);
397        toneGenerator.startTone(ToneGenerator.TONE_CDMA_NETWORK_BUSY, 2500);
398        this.destroyWithDelay(new DisconnectCause(DisconnectCause.BUSY, null), 2500);
399    }
400
401    private void destroyWithDelay(final DisconnectCause disconnectCause, final int delay) {
402        if (this.delayedDestructionInitiated.compareAndSet(false, true)) {
403            JingleConnectionManager.SCHEDULED_EXECUTOR_SERVICE.schedule(
404                    () -> {
405                        this.setDisconnected(disconnectCause);
406                        this.destroyCallIntegration();
407                    },
408                    delay,
409                    TimeUnit.MILLISECONDS);
410        } else {
411            Log.w(Config.LOGTAG, "CallIntegration destruction has already been scheduled!");
412        }
413    }
414
415    private void destroyWith(final DisconnectCause disconnectCause) {
416        if (this.getState() == STATE_DISCONNECTED || this.delayedDestructionInitiated.get()) {
417            Log.d(Config.LOGTAG, "CallIntegration has already been destroyed");
418            return;
419        }
420        this.setDisconnected(disconnectCause);
421        this.destroyCallIntegration();
422        Log.d(Config.LOGTAG, "destroyed!");
423    }
424
425    public static Uri address(final Jid contact) {
426        return Uri.parse(String.format("xmpp:%s", contact.toEscapedString()));
427    }
428
429    public void verifyDisconnected() {
430        if (this.getState() == STATE_DISCONNECTED || this.delayedDestructionInitiated.get()) {
431            return;
432        }
433        throw new AssertionError("CallIntegration has not been disconnected");
434    }
435
436    private void onAudioDeviceChanged(
437            final CallIntegration.AudioDevice selectedAudioDevice,
438            final Set<CallIntegration.AudioDevice> availableAudioDevices) {
439        if (this.initialAudioDevice != null
440                && this.initialAudioDeviceConfigured.compareAndSet(false, true)) {
441            if (availableAudioDevices.contains(this.initialAudioDevice)
442                    && !availableAudioDevices.contains(AudioDevice.BLUETOOTH)) {
443                setAudioDevice(this.initialAudioDevice);
444                Log.d(Config.LOGTAG, "configured initial audio device");
445            } else {
446                Log.d(
447                        Config.LOGTAG,
448                        "not setting initial audio device. available devices: "
449                                + availableAudioDevices);
450            }
451        }
452        final var callback = this.callback;
453        if (callback == null) {
454            return;
455        }
456        callback.onAudioDeviceChanged(selectedAudioDevice, availableAudioDevices);
457    }
458
459    private boolean selfManaged() {
460        return selfManaged(context);
461    }
462
463    public static boolean selfManaged(final Context context) {
464        return Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && hasSystemFeature(context);
465    }
466
467    public static boolean hasSystemFeature(final Context context) {
468        final var packageManager = context.getPackageManager();
469        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
470            return packageManager.hasSystemFeature(PackageManager.FEATURE_TELECOM);
471        } else {
472            return packageManager.hasSystemFeature(PackageManager.FEATURE_CONNECTION_SERVICE);
473        }
474    }
475
476    public static boolean notSelfManaged(final Context context) {
477        return !selfManaged(context);
478    }
479
480    public void setInitialAudioDevice(final AudioDevice audioDevice) {
481        Log.d(Config.LOGTAG, "setInitialAudioDevice(" + audioDevice + ")");
482        this.initialAudioDevice = audioDevice;
483        if (selfManaged()) {
484            // once the 'CallIntegration' gets added to the system we receive calls to update audio
485            // state
486            return;
487        }
488        final var audioManager = requireAppRtcAudioManager();
489        audioManager.executeOnMain(
490                () ->
491                        this.onAudioDeviceChanged(
492                                audioManager.getSelectedAudioDevice(),
493                                audioManager.getAudioDevices()));
494    }
495
496    private void destroyCallIntegration() {
497        super.destroy();
498        this.isDestroyed.set(true);
499    }
500
501    public boolean isDestroyed() {
502        return this.isDestroyed.get();
503    }
504
505    /** AudioDevice is the names of possible audio devices that we currently support. */
506    public enum AudioDevice {
507        NONE,
508        SPEAKER_PHONE,
509        WIRED_HEADSET,
510        EARPIECE,
511        BLUETOOTH,
512        STREAMING
513    }
514
515    public static AudioDevice initialAudioDevice(final Set<Media> media) {
516        if (Media.audioOnly(media)) {
517            return AudioDevice.EARPIECE;
518        } else {
519            return AudioDevice.SPEAKER_PHONE;
520        }
521    }
522
523    public interface Callback {
524        void onCallIntegrationShowIncomingCallUi();
525
526        void onCallIntegrationDisconnect();
527
528        void onAudioDeviceChanged(
529                CallIntegration.AudioDevice selectedAudioDevice,
530                Set<CallIntegration.AudioDevice> availableAudioDevices);
531
532        void onCallIntegrationReject();
533
534        void onCallIntegrationAnswer();
535
536        void onCallIntegrationSilence();
537    }
538}