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