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}