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
 47    private List<CallEndpoint> availableEndpoints = Collections.emptyList();
 48
 49    private Callback callback = null;
 50
 51    public CallIntegration(final Context context) {
 52        this.context = context.getApplicationContext();
 53        if (selfManaged()) {
 54            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
 55                setConnectionProperties(Connection.PROPERTY_SELF_MANAGED);
 56            } else {
 57                throw new AssertionError(
 58                        "Trying to set connection properties on unsupported version");
 59            }
 60            this.appRTCAudioManager = null;
 61        } else {
 62            this.appRTCAudioManager = new AppRTCAudioManager(context);
 63            ContextCompat.getMainExecutor(context)
 64                    .execute(() -> this.appRTCAudioManager.start(this::onAudioDeviceChanged));
 65        }
 66        setRingbackRequested(true);
 67    }
 68
 69    public void setCallback(final Callback callback) {
 70        this.callback = callback;
 71    }
 72
 73    @Override
 74    public void onShowIncomingCallUi() {
 75        Log.d(Config.LOGTAG, "onShowIncomingCallUi");
 76        this.callback.onCallIntegrationShowIncomingCallUi();
 77    }
 78
 79    @Override
 80    public void onAnswer() {
 81        this.callback.onCallIntegrationAnswer();
 82    }
 83
 84    @Override
 85    public void onDisconnect() {
 86        Log.d(Config.LOGTAG, "onDisconnect()");
 87        this.callback.onCallIntegrationDisconnect();
 88    }
 89
 90    @Override
 91    public void onReject() {
 92        this.callback.onCallIntegrationReject();
 93    }
 94
 95    @Override
 96    public void onReject(final String replyMessage) {
 97        Log.d(Config.LOGTAG, "onReject(" + replyMessage + ")");
 98        this.callback.onCallIntegrationReject();
 99    }
100
101    @RequiresApi(api = Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
102    @Override
103    public void onAvailableCallEndpointsChanged(@NonNull List<CallEndpoint> availableEndpoints) {
104        Log.d(Config.LOGTAG, "onAvailableCallEndpointsChanged(" + availableEndpoints + ")");
105        this.availableEndpoints = availableEndpoints;
106        this.onAudioDeviceChanged(
107                getAudioDeviceUpsideDownCake(getCurrentCallEndpoint()),
108                ImmutableSet.copyOf(
109                        Lists.transform(
110                                availableEndpoints,
111                                CallIntegration::getAudioDeviceUpsideDownCake)));
112    }
113
114    @RequiresApi(api = Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
115    @Override
116    public void onCallEndpointChanged(@NonNull final CallEndpoint callEndpoint) {
117        Log.d(Config.LOGTAG, "onCallEndpointChanged()");
118        this.onAudioDeviceChanged(
119                getAudioDeviceUpsideDownCake(callEndpoint),
120                ImmutableSet.copyOf(
121                        Lists.transform(
122                                this.availableEndpoints,
123                                CallIntegration::getAudioDeviceUpsideDownCake)));
124    }
125
126    @Override
127    public void onCallAudioStateChanged(final CallAudioState state) {
128        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
129            Log.d(Config.LOGTAG, "ignoring onCallAudioStateChange() on Upside Down Cake");
130            return;
131        }
132        Log.d(Config.LOGTAG, "onCallAudioStateChange(" + state + ")");
133        this.onAudioDeviceChanged(getAudioDeviceOreo(state), getAudioDevicesOreo(state));
134    }
135
136    public Set<AudioDevice> getAudioDevices() {
137        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
138            return getAudioDevicesUpsideDownCake();
139        } else if (selfManaged()) {
140            return getAudioDevicesOreo();
141        } else {
142            return getAudioDevicesFallback();
143        }
144    }
145
146    public AudioDevice getSelectedAudioDevice() {
147        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
148            return getAudioDeviceUpsideDownCake();
149        } else if (selfManaged()) {
150            return getAudioDeviceOreo();
151        } else {
152            return getAudioDeviceFallback();
153        }
154    }
155
156    public void setAudioDevice(final AudioDevice audioDevice) {
157        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
158            setAudioDeviceUpsideDownCake(audioDevice);
159        } else if (selfManaged()) {
160            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
161                setAudioDeviceOreo(audioDevice);
162            } else {
163                throw new AssertionError("Trying to set audio devices on unsupported version");
164            }
165        } else {
166            setAudioDeviceFallback(audioDevice);
167        }
168    }
169
170    public void setAudioDeviceWhenAvailable(final AudioDevice audioDevice) {
171        final var available = getAudioDevices();
172        if (available.contains(audioDevice) && !available.contains(AudioDevice.BLUETOOTH)) {
173            this.setAudioDevice(audioDevice);
174        } else {
175            Log.d(
176                    Config.LOGTAG,
177                    "application requested to switch to "
178                            + audioDevice
179                            + " but we won't because available devices are "
180                            + available);
181        }
182    }
183
184    @RequiresApi(api = Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
185    private Set<AudioDevice> getAudioDevicesUpsideDownCake() {
186        return ImmutableSet.copyOf(
187                Lists.transform(
188                        this.availableEndpoints, CallIntegration::getAudioDeviceUpsideDownCake));
189    }
190
191    @RequiresApi(api = Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
192    private AudioDevice getAudioDeviceUpsideDownCake() {
193        return getAudioDeviceUpsideDownCake(getCurrentCallEndpoint());
194    }
195
196    @RequiresApi(api = Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
197    private static AudioDevice getAudioDeviceUpsideDownCake(final CallEndpoint callEndpoint) {
198        if (callEndpoint == null) {
199            return AudioDevice.NONE;
200        }
201        final var endpointType = callEndpoint.getEndpointType();
202        return switch (endpointType) {
203            case CallEndpoint.TYPE_BLUETOOTH -> AudioDevice.BLUETOOTH;
204            case CallEndpoint.TYPE_EARPIECE -> AudioDevice.EARPIECE;
205            case CallEndpoint.TYPE_SPEAKER -> AudioDevice.SPEAKER_PHONE;
206            case CallEndpoint.TYPE_WIRED_HEADSET -> AudioDevice.WIRED_HEADSET;
207            case CallEndpoint.TYPE_STREAMING -> AudioDevice.STREAMING;
208            case CallEndpoint.TYPE_UNKNOWN -> AudioDevice.NONE;
209            default -> throw new IllegalStateException("Unknown endpoint type " + endpointType);
210        };
211    }
212
213    @RequiresApi(api = Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
214    private void setAudioDeviceUpsideDownCake(final AudioDevice audioDevice) {
215        final var callEndpointOptional =
216                Iterables.tryFind(
217                        this.availableEndpoints,
218                        e -> getAudioDeviceUpsideDownCake(e) == audioDevice);
219        if (callEndpointOptional.isPresent()) {
220            final var endpoint = callEndpointOptional.get();
221            requestCallEndpointChange(
222                    endpoint,
223                    MainThreadExecutor.getInstance(),
224                    result -> Log.d(Config.LOGTAG, "switched to endpoint " + endpoint));
225        } else {
226            Log.w(Config.LOGTAG, "no endpoint found matching " + audioDevice);
227        }
228    }
229
230    private Set<AudioDevice> getAudioDevicesOreo() {
231        final var audioState = getCallAudioState();
232        if (audioState == null) {
233            Log.d(
234                    Config.LOGTAG,
235                    "no CallAudioState available. returning empty set for audio devices");
236            return Collections.emptySet();
237        }
238        return getAudioDevicesOreo(audioState);
239    }
240
241    private static Set<AudioDevice> getAudioDevicesOreo(final CallAudioState callAudioState) {
242        final ImmutableSet.Builder<AudioDevice> supportedAudioDevicesBuilder =
243                new ImmutableSet.Builder<>();
244        final var supportedRouteMask = callAudioState.getSupportedRouteMask();
245        if ((supportedRouteMask & CallAudioState.ROUTE_BLUETOOTH)
246                == CallAudioState.ROUTE_BLUETOOTH) {
247            supportedAudioDevicesBuilder.add(AudioDevice.BLUETOOTH);
248        }
249        if ((supportedRouteMask & CallAudioState.ROUTE_EARPIECE) == CallAudioState.ROUTE_EARPIECE) {
250            supportedAudioDevicesBuilder.add(AudioDevice.EARPIECE);
251        }
252        if ((supportedRouteMask & CallAudioState.ROUTE_SPEAKER) == CallAudioState.ROUTE_SPEAKER) {
253            supportedAudioDevicesBuilder.add(AudioDevice.SPEAKER_PHONE);
254        }
255        if ((supportedRouteMask & CallAudioState.ROUTE_WIRED_HEADSET)
256                == CallAudioState.ROUTE_WIRED_HEADSET) {
257            supportedAudioDevicesBuilder.add(AudioDevice.WIRED_HEADSET);
258        }
259        return supportedAudioDevicesBuilder.build();
260    }
261
262    private AudioDevice getAudioDeviceOreo() {
263        final var audioState = getCallAudioState();
264        if (audioState == null) {
265            Log.d(Config.LOGTAG, "no CallAudioState available. returning NONE as audio device");
266            return AudioDevice.NONE;
267        }
268        return getAudioDeviceOreo(audioState);
269    }
270
271    private static AudioDevice getAudioDeviceOreo(final CallAudioState audioState) {
272        // technically we get a mask here; maybe we should query the mask instead
273        return switch (audioState.getRoute()) {
274            case CallAudioState.ROUTE_BLUETOOTH -> AudioDevice.BLUETOOTH;
275            case CallAudioState.ROUTE_EARPIECE -> AudioDevice.EARPIECE;
276            case CallAudioState.ROUTE_SPEAKER -> AudioDevice.SPEAKER_PHONE;
277            case CallAudioState.ROUTE_WIRED_HEADSET -> AudioDevice.WIRED_HEADSET;
278            default -> AudioDevice.NONE;
279        };
280    }
281
282    @RequiresApi(api = Build.VERSION_CODES.O)
283    private void setAudioDeviceOreo(final AudioDevice audioDevice) {
284        switch (audioDevice) {
285            case EARPIECE -> setAudioRoute(CallAudioState.ROUTE_EARPIECE);
286            case BLUETOOTH -> setAudioRoute(CallAudioState.ROUTE_BLUETOOTH);
287            case WIRED_HEADSET -> setAudioRoute(CallAudioState.ROUTE_WIRED_HEADSET);
288            case SPEAKER_PHONE -> setAudioRoute(CallAudioState.ROUTE_SPEAKER);
289        }
290    }
291
292    private Set<AudioDevice> getAudioDevicesFallback() {
293        return requireAppRtcAudioManager().getAudioDevices();
294    }
295
296    private AudioDevice getAudioDeviceFallback() {
297        return requireAppRtcAudioManager().getSelectedAudioDevice();
298    }
299
300    private void setAudioDeviceFallback(final AudioDevice audioDevice) {
301        final var audioManager = requireAppRtcAudioManager();
302        audioManager.executeOnMain(() -> audioManager.setDefaultAudioDevice(audioDevice));
303    }
304
305    @NonNull
306    private AppRTCAudioManager requireAppRtcAudioManager() {
307        if (this.appRTCAudioManager == null) {
308            throw new IllegalStateException(
309                    "You are trying to access the fallback audio manager on a modern device");
310        }
311        return this.appRTCAudioManager;
312    }
313
314    @Override
315    public void onSilence() {
316        this.callback.onCallIntegrationSilence();
317    }
318
319    @Override
320    public void onStateChanged(final int state) {
321        Log.d(Config.LOGTAG, "onStateChanged(" + state + ")");
322        if (notSelfManaged(context)) {
323            if (state == STATE_DIALING) {
324                requireAppRtcAudioManager().startRingBack();
325            } else {
326                requireAppRtcAudioManager().stopRingBack();
327            }
328        }
329        if (state == STATE_ACTIVE) {
330            playConnectedSound();
331        } else if (state == STATE_DISCONNECTED) {
332            final var audioManager = this.appRTCAudioManager;
333            if (audioManager != null) {
334                audioManager.executeOnMain(audioManager::stop);
335            }
336        }
337    }
338
339    private void playConnectedSound() {
340        final var mediaPlayer = MediaPlayer.create(context, R.raw.connected);
341        mediaPlayer.setVolume(DEFAULT_VOLUME / 100f, DEFAULT_VOLUME / 100f);
342        mediaPlayer.start();
343    }
344
345    public void success() {
346        Log.d(Config.LOGTAG, "CallIntegration.success()");
347        final var toneGenerator = new ToneGenerator(AudioManager.STREAM_MUSIC, DEFAULT_VOLUME);
348        toneGenerator.startTone(ToneGenerator.TONE_CDMA_CALLDROP_LITE, 375);
349        this.destroyWithDelay(new DisconnectCause(DisconnectCause.LOCAL, null), 375);
350    }
351
352    public void accepted() {
353        Log.d(Config.LOGTAG, "CallIntegration.accepted()");
354        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N_MR1) {
355            this.destroyWith(new DisconnectCause(DisconnectCause.ANSWERED_ELSEWHERE, null));
356        } else {
357            this.destroyWith(new DisconnectCause(DisconnectCause.CANCELED, null));
358        }
359    }
360
361    public void error() {
362        Log.d(Config.LOGTAG, "CallIntegration.error()");
363        final var toneGenerator = new ToneGenerator(AudioManager.STREAM_MUSIC, DEFAULT_VOLUME);
364        toneGenerator.startTone(ToneGenerator.TONE_CDMA_CALLDROP_LITE, 375);
365        this.destroyWithDelay(new DisconnectCause(DisconnectCause.ERROR, null), 375);
366        this.destroyWith(new DisconnectCause(DisconnectCause.ERROR, null));
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.destroy();
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.destroy();
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        final var packageManager = context.getPackageManager();
451        return Build.VERSION.SDK_INT >= Build.VERSION_CODES.O
452                && packageManager.hasSystemFeature(PackageManager.FEATURE_TELEPHONY);
453    }
454
455    public static boolean notSelfManaged(final Context context) {
456        return !selfManaged(context);
457    }
458
459    public void setInitialAudioDevice(final AudioDevice audioDevice) {
460        Log.d(Config.LOGTAG, "setInitialAudioDevice(" + audioDevice + ")");
461        this.initialAudioDevice = audioDevice;
462        if (selfManaged()) {
463            // once the 'CallIntegration' gets added to the system we receive calls to update audio
464            // state
465            return;
466        }
467        final var audioManager = requireAppRtcAudioManager();
468        audioManager.executeOnMain(
469                () ->
470                        this.onAudioDeviceChanged(
471                                audioManager.getSelectedAudioDevice(),
472                                audioManager.getAudioDevices()));
473    }
474
475    /** AudioDevice is the names of possible audio devices that we currently support. */
476    public enum AudioDevice {
477        NONE,
478        SPEAKER_PHONE,
479        WIRED_HEADSET,
480        EARPIECE,
481        BLUETOOTH,
482        STREAMING
483    }
484
485    public static AudioDevice initialAudioDevice(final Set<Media> media) {
486        if (Media.audioOnly(media)) {
487            return AudioDevice.EARPIECE;
488        } else {
489            return AudioDevice.SPEAKER_PHONE;
490        }
491    }
492
493    public interface Callback {
494        void onCallIntegrationShowIncomingCallUi();
495
496        void onCallIntegrationDisconnect();
497
498        void onAudioDeviceChanged(
499                CallIntegration.AudioDevice selectedAudioDevice,
500                Set<CallIntegration.AudioDevice> availableAudioDevices);
501
502        void onCallIntegrationReject();
503
504        void onCallIntegrationAnswer();
505
506        void onCallIntegrationSilence();
507    }
508}