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