CallIntegration.java

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