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}