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