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}