CallIntegration.java

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