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