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    @RequiresApi(api = Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
149    private Set<AudioDevice> getAudioDevicesUpsideDownCake() {
150        return ImmutableSet.copyOf(
151                Lists.transform(
152                        this.availableEndpoints, CallIntegration::getAudioDeviceUpsideDownCake));
153    }
154
155    @RequiresApi(api = Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
156    private AudioDevice getAudioDeviceUpsideDownCake() {
157        return getAudioDeviceUpsideDownCake(getCurrentCallEndpoint());
158    }
159
160    @RequiresApi(api = Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
161    private static AudioDevice getAudioDeviceUpsideDownCake(final CallEndpoint callEndpoint) {
162        if (callEndpoint == null) {
163            return AudioDevice.NONE;
164        }
165        final var endpointType = callEndpoint.getEndpointType();
166        return switch (endpointType) {
167            case CallEndpoint.TYPE_BLUETOOTH -> AudioDevice.BLUETOOTH;
168            case CallEndpoint.TYPE_EARPIECE -> AudioDevice.EARPIECE;
169            case CallEndpoint.TYPE_SPEAKER -> AudioDevice.SPEAKER_PHONE;
170            case CallEndpoint.TYPE_WIRED_HEADSET -> AudioDevice.WIRED_HEADSET;
171            case CallEndpoint.TYPE_STREAMING -> AudioDevice.STREAMING;
172            case CallEndpoint.TYPE_UNKNOWN -> AudioDevice.NONE;
173            default -> throw new IllegalStateException("Unknown endpoint type " + endpointType);
174        };
175    }
176
177    @RequiresApi(api = Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
178    private void setAudioDeviceUpsideDownCake(final AudioDevice audioDevice) {
179        final var callEndpointOptional =
180                Iterables.tryFind(
181                        this.availableEndpoints,
182                        e -> getAudioDeviceUpsideDownCake(e) == audioDevice);
183        if (callEndpointOptional.isPresent()) {
184            final var endpoint = callEndpointOptional.get();
185            requestCallEndpointChange(
186                    endpoint,
187                    MainThreadExecutor.getInstance(),
188                    result -> Log.d(Config.LOGTAG, "switched to endpoint " + endpoint));
189        } else {
190            Log.w(Config.LOGTAG, "no endpoint found matching " + audioDevice);
191        }
192    }
193
194    private Set<AudioDevice> getAudioDevicesOreo() {
195        final var audioState = getCallAudioState();
196        if (audioState == null) {
197            Log.d(
198                    Config.LOGTAG,
199                    "no CallAudioState available. returning empty set for audio devices");
200            return Collections.emptySet();
201        }
202        return getAudioDevicesOreo(audioState);
203    }
204
205    private static Set<AudioDevice> getAudioDevicesOreo(final CallAudioState callAudioState) {
206        final ImmutableSet.Builder<AudioDevice> supportedAudioDevicesBuilder =
207                new ImmutableSet.Builder<>();
208        final var supportedRouteMask = callAudioState.getSupportedRouteMask();
209        if ((supportedRouteMask & CallAudioState.ROUTE_BLUETOOTH)
210                == CallAudioState.ROUTE_BLUETOOTH) {
211            supportedAudioDevicesBuilder.add(AudioDevice.BLUETOOTH);
212        }
213        if ((supportedRouteMask & CallAudioState.ROUTE_EARPIECE) == CallAudioState.ROUTE_EARPIECE) {
214            supportedAudioDevicesBuilder.add(AudioDevice.EARPIECE);
215        }
216        if ((supportedRouteMask & CallAudioState.ROUTE_SPEAKER) == CallAudioState.ROUTE_SPEAKER) {
217            supportedAudioDevicesBuilder.add(AudioDevice.SPEAKER_PHONE);
218        }
219        if ((supportedRouteMask & CallAudioState.ROUTE_WIRED_HEADSET)
220                == CallAudioState.ROUTE_WIRED_HEADSET) {
221            supportedAudioDevicesBuilder.add(AudioDevice.WIRED_HEADSET);
222        }
223        return supportedAudioDevicesBuilder.build();
224    }
225
226    private AudioDevice getAudioDeviceOreo() {
227        final var audioState = getCallAudioState();
228        if (audioState == null) {
229            Log.d(Config.LOGTAG, "no CallAudioState available. returning NONE as audio device");
230            return AudioDevice.NONE;
231        }
232        return getAudioDeviceOreo(audioState);
233    }
234
235    private static AudioDevice getAudioDeviceOreo(final CallAudioState audioState) {
236        // technically we get a mask here; maybe we should query the mask instead
237        return switch (audioState.getRoute()) {
238            case CallAudioState.ROUTE_BLUETOOTH -> AudioDevice.BLUETOOTH;
239            case CallAudioState.ROUTE_EARPIECE -> AudioDevice.EARPIECE;
240            case CallAudioState.ROUTE_SPEAKER -> AudioDevice.SPEAKER_PHONE;
241            case CallAudioState.ROUTE_WIRED_HEADSET -> AudioDevice.WIRED_HEADSET;
242            default -> AudioDevice.NONE;
243        };
244    }
245
246    @RequiresApi(api = Build.VERSION_CODES.O)
247    private void setAudioDeviceOreo(final AudioDevice audioDevice) {
248        switch (audioDevice) {
249            case EARPIECE -> setAudioRoute(CallAudioState.ROUTE_EARPIECE);
250            case BLUETOOTH -> setAudioRoute(CallAudioState.ROUTE_BLUETOOTH);
251            case WIRED_HEADSET -> setAudioRoute(CallAudioState.ROUTE_WIRED_HEADSET);
252            case SPEAKER_PHONE -> setAudioRoute(CallAudioState.ROUTE_SPEAKER);
253        }
254    }
255
256    private Set<AudioDevice> getAudioDevicesFallback() {
257        return requireAppRtcAudioManager().getAudioDevices();
258    }
259
260    private AudioDevice getAudioDeviceFallback() {
261        return requireAppRtcAudioManager().getSelectedAudioDevice();
262    }
263
264    private void setAudioDeviceFallback(final AudioDevice audioDevice) {
265        requireAppRtcAudioManager().setDefaultAudioDevice(audioDevice);
266    }
267
268    @NonNull
269    private AppRTCAudioManager requireAppRtcAudioManager() {
270        if (this.appRTCAudioManager == null) {
271            throw new IllegalStateException(
272                    "You are trying to access the fallback audio manager on a modern device");
273        }
274        return this.appRTCAudioManager;
275    }
276
277    @Override
278    public void onStateChanged(final int state) {
279        Log.d(Config.LOGTAG, "onStateChanged(" + state + ")");
280        if (state == STATE_DISCONNECTED) {
281            final var audioManager = this.appRTCAudioManager;
282            if (audioManager != null) {
283                audioManager.stop();
284            }
285        }
286    }
287
288    public void success() {
289        Log.d(Config.LOGTAG, "CallIntegration.success()");
290        this.destroyWith(new DisconnectCause(DisconnectCause.LOCAL, null));
291    }
292
293    public void accepted() {
294        Log.d(Config.LOGTAG, "CallIntegration.accepted()");
295        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N_MR1) {
296            this.destroyWith(new DisconnectCause(DisconnectCause.ANSWERED_ELSEWHERE, null));
297        } else {
298            this.destroyWith(new DisconnectCause(DisconnectCause.CANCELED, null));
299        }
300    }
301
302    public void error() {
303        Log.d(Config.LOGTAG, "CallIntegration.error()");
304        this.destroyWith(new DisconnectCause(DisconnectCause.ERROR, null));
305    }
306
307    public void retracted() {
308        Log.d(Config.LOGTAG, "CallIntegration.retracted()");
309        // an alternative cause would be LOCAL
310        this.destroyWith(new DisconnectCause(DisconnectCause.CANCELED, null));
311    }
312
313    public void rejected() {
314        Log.d(Config.LOGTAG, "CallIntegration.rejected()");
315        this.destroyWith(new DisconnectCause(DisconnectCause.REJECTED, null));
316    }
317
318    public void busy() {
319        Log.d(Config.LOGTAG, "CallIntegration.busy()");
320        this.destroyWith(new DisconnectCause(DisconnectCause.BUSY, null));
321    }
322
323    private void destroyWith(final DisconnectCause disconnectCause) {
324        if (this.getState() == STATE_DISCONNECTED) {
325            Log.d(Config.LOGTAG, "CallIntegration has already been destroyed");
326            return;
327        }
328        this.setDisconnected(disconnectCause);
329        this.destroy();
330    }
331
332    public static Uri address(final Jid contact) {
333        return Uri.parse(String.format("xmpp:%s", contact.toEscapedString()));
334    }
335
336    public void verifyDisconnected() {
337        if (this.getState() == STATE_DISCONNECTED) {
338            return;
339        }
340        throw new AssertionError("CallIntegration has not been disconnected");
341    }
342
343    private void onAudioDeviceChanged(
344            final CallIntegration.AudioDevice selectedAudioDevice,
345            final Set<CallIntegration.AudioDevice> availableAudioDevices) {
346        if (this.initialAudioDevice != null
347                && this.initialAudioDeviceConfigured.compareAndSet(false, true)) {
348            if (availableAudioDevices.contains(this.initialAudioDevice)) {
349                setAudioDevice(this.initialAudioDevice);
350                Log.d(Config.LOGTAG, "configured initial audio device");
351            } else {
352                Log.d(
353                        Config.LOGTAG,
354                        "initial audio device not available. available devices: "
355                                + availableAudioDevices);
356            }
357        }
358        final var callback = this.callback;
359        if (callback == null) {
360            return;
361        }
362        callback.onAudioDeviceChanged(selectedAudioDevice, availableAudioDevices);
363    }
364
365    public static boolean selfManaged() {
366        return Build.VERSION.SDK_INT >= Build.VERSION_CODES.O;
367    }
368
369    public void setInitialAudioDevice(final AudioDevice audioDevice) {
370        Log.d(Config.LOGTAG, "setInitialAudioDevice(" + audioDevice + ")");
371        this.initialAudioDevice = audioDevice;
372        if (CallIntegration.selfManaged()) {
373            // once the 'CallIntegration' gets added to the system we receive calls to update audio
374            // state
375            return;
376        }
377        final var audioManager = requireAppRtcAudioManager();
378        this.onAudioDeviceChanged(
379                audioManager.getSelectedAudioDevice(), audioManager.getAudioDevices());
380    }
381
382    /** AudioDevice is the names of possible audio devices that we currently support. */
383    public enum AudioDevice {
384        NONE,
385        SPEAKER_PHONE,
386        WIRED_HEADSET,
387        EARPIECE,
388        BLUETOOTH,
389        STREAMING
390    }
391
392    public static AudioDevice initialAudioDevice(final Set<Media> media) {
393        if (Media.audioOnly(media)) {
394            return AudioDevice.EARPIECE;
395        } else {
396            return AudioDevice.SPEAKER_PHONE;
397        }
398    }
399
400    public interface Callback {
401        void onCallIntegrationShowIncomingCallUi();
402
403        void onCallIntegrationDisconnect();
404
405        void onAudioDeviceChanged(
406                CallIntegration.AudioDevice selectedAudioDevice,
407                Set<CallIntegration.AudioDevice> availableAudioDevices);
408
409        void onCallIntegrationReject();
410
411        void onCallIntegrationAnswer();
412    }
413}