CallIntegration.java

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