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