1package eu.siacs.conversations.services;
2
3import android.content.Context;
4import android.content.pm.PackageManager;
5import android.media.AudioAttributes;
6import android.media.AudioManager;
7import android.media.ToneGenerator;
8import android.net.Uri;
9import android.os.Build;
10import android.telecom.CallAudioState;
11import android.telecom.CallEndpoint;
12import android.telecom.Connection;
13import android.telecom.DisconnectCause;
14import android.util.Log;
15
16import androidx.annotation.NonNull;
17import androidx.annotation.RequiresApi;
18
19import com.google.common.collect.ImmutableSet;
20import com.google.common.collect.Iterables;
21import com.google.common.collect.Lists;
22
23import eu.siacs.conversations.Config;
24import eu.siacs.conversations.R;
25import eu.siacs.conversations.ui.util.MainThreadExecutor;
26import eu.siacs.conversations.xmpp.Jid;
27import eu.siacs.conversations.xmpp.jingle.JingleConnectionManager;
28import eu.siacs.conversations.xmpp.jingle.Media;
29
30import java.util.Arrays;
31import java.util.Collections;
32import java.util.List;
33import java.util.Set;
34import java.util.concurrent.TimeUnit;
35import java.util.concurrent.atomic.AtomicBoolean;
36
37public class CallIntegration extends Connection {
38
39 private static final List<String> BROKEN_DEVICE_MODELS =
40 Arrays.asList(
41 "OnePlus6", // OnePlus 6 (Android 8.1-11) Device is buggy and always starts the
42 // operating system call screen even though we want to be self
43 // managed
44 "RMX1921", // Realme XT (Android 9-10) shows "Call not sent" dialog
45 "RMX1971", // Realme 5 Pro (Android 9-11), show "Call not sent" dialog
46 "RMX1973" // Realme 5 Pro (see above)
47 );
48
49 public static final int DEFAULT_TONE_VOLUME = 60;
50 private static final int DEFAULT_MEDIA_PLAYER_VOLUME = 90;
51
52 private final Context context;
53
54 private final AppRTCAudioManager appRTCAudioManager;
55 private AudioDevice initialAudioDevice = null;
56
57 private boolean isAudioRoutingRequested = false;
58 private final AtomicBoolean initialAudioDeviceConfigured = new AtomicBoolean(false);
59 private final AtomicBoolean delayedDestructionInitiated = new AtomicBoolean(false);
60 private final AtomicBoolean isDestroyed = new AtomicBoolean(false);
61
62 private List<CallEndpoint> availableEndpoints = Collections.emptyList();
63
64 private Callback callback = null;
65
66 public CallIntegration(final Context context) {
67 this.context = context.getApplicationContext();
68 if (selfManaged()) {
69 if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
70 setConnectionProperties(Connection.PROPERTY_SELF_MANAGED);
71 } else {
72 throw new AssertionError(
73 "Trying to set connection properties on unsupported version");
74 }
75 this.appRTCAudioManager = null;
76 } else {
77 this.appRTCAudioManager = new AppRTCAudioManager(context);
78 this.appRTCAudioManager.setAudioManagerEvents(this::onAudioDeviceChanged);
79 }
80 setRingbackRequested(true);
81 }
82
83 public void setCallback(final Callback callback) {
84 this.callback = callback;
85 }
86
87 @Override
88 public void onShowIncomingCallUi() {
89 Log.d(Config.LOGTAG, "onShowIncomingCallUi");
90 this.callback.onCallIntegrationShowIncomingCallUi();
91 }
92
93 @Override
94 public void onAnswer() {
95 this.callback.onCallIntegrationAnswer();
96 }
97
98 @Override
99 public void onDisconnect() {
100 Log.d(Config.LOGTAG, "onDisconnect()");
101 this.callback.onCallIntegrationDisconnect();
102 }
103
104 @Override
105 public void onReject() {
106 this.callback.onCallIntegrationReject();
107 }
108
109 @Override
110 public void onReject(final String replyMessage) {
111 Log.d(Config.LOGTAG, "onReject(" + replyMessage + ")");
112 this.callback.onCallIntegrationReject();
113 }
114
115 @RequiresApi(api = Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
116 @Override
117 public void onAvailableCallEndpointsChanged(@NonNull List<CallEndpoint> availableEndpoints) {
118 Log.d(Config.LOGTAG, "onAvailableCallEndpointsChanged(" + availableEndpoints + ")");
119 this.availableEndpoints = availableEndpoints;
120 this.onAudioDeviceChanged(
121 getAudioDeviceUpsideDownCake(getCurrentCallEndpoint()),
122 ImmutableSet.copyOf(
123 Lists.transform(
124 availableEndpoints,
125 CallIntegration::getAudioDeviceUpsideDownCake)));
126 }
127
128 @RequiresApi(api = Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
129 @Override
130 public void onCallEndpointChanged(@NonNull final CallEndpoint callEndpoint) {
131 Log.d(Config.LOGTAG, "onCallEndpointChanged()");
132 this.onAudioDeviceChanged(
133 getAudioDeviceUpsideDownCake(callEndpoint),
134 ImmutableSet.copyOf(
135 Lists.transform(
136 this.availableEndpoints,
137 CallIntegration::getAudioDeviceUpsideDownCake)));
138 }
139
140 @Override
141 public void onCallAudioStateChanged(final CallAudioState state) {
142 if (selfManaged() && Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
143 Log.d(Config.LOGTAG, "ignoring onCallAudioStateChange() on Upside Down Cake");
144 return;
145 }
146 Log.d(Config.LOGTAG, "onCallAudioStateChange(" + state + ")");
147 this.onAudioDeviceChanged(getAudioDeviceOreo(state), getAudioDevicesOreo(state));
148 }
149
150 public Set<AudioDevice> getAudioDevices() {
151 if (notSelfManaged(context)) {
152 return getAudioDevicesFallback();
153 } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
154 return getAudioDevicesUpsideDownCake();
155 } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
156 return getAudioDevicesOreo();
157 } else {
158 throw new AssertionError("Trying to get audio devices on unsupported version");
159 }
160 }
161
162 public AudioDevice getSelectedAudioDevice() {
163 if (notSelfManaged(context)) {
164 return getAudioDeviceFallback();
165 } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
166 return getAudioDeviceUpsideDownCake();
167 } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
168 return getAudioDeviceOreo();
169 } else {
170 throw new AssertionError("Trying to get selected audio device on unsupported version");
171 }
172 }
173
174 public void setAudioDevice(final AudioDevice audioDevice) {
175 if (notSelfManaged(context)) {
176 setAudioDeviceFallback(audioDevice);
177 } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
178 setAudioDeviceUpsideDownCake(audioDevice);
179 } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
180 setAudioDeviceOreo(audioDevice);
181 } else {
182 throw new AssertionError("Trying to set audio devices on unsupported version");
183 }
184 }
185
186 public void setAudioDeviceWhenAvailable(final AudioDevice audioDevice) {
187 final var available = getAudioDevices();
188 if (available.contains(audioDevice) && !available.contains(AudioDevice.BLUETOOTH)) {
189 this.setAudioDevice(audioDevice);
190 } else {
191 Log.d(
192 Config.LOGTAG,
193 "application requested to switch to "
194 + audioDevice
195 + " but we won't because available devices are "
196 + available);
197 }
198 }
199
200 @RequiresApi(api = Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
201 private Set<AudioDevice> getAudioDevicesUpsideDownCake() {
202 return ImmutableSet.copyOf(
203 Lists.transform(
204 this.availableEndpoints, CallIntegration::getAudioDeviceUpsideDownCake));
205 }
206
207 @RequiresApi(api = Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
208 private AudioDevice getAudioDeviceUpsideDownCake() {
209 return getAudioDeviceUpsideDownCake(getCurrentCallEndpoint());
210 }
211
212 @RequiresApi(api = Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
213 private static AudioDevice getAudioDeviceUpsideDownCake(final CallEndpoint callEndpoint) {
214 if (callEndpoint == null) {
215 return AudioDevice.NONE;
216 }
217 final var endpointType = callEndpoint.getEndpointType();
218 return switch (endpointType) {
219 case CallEndpoint.TYPE_BLUETOOTH -> AudioDevice.BLUETOOTH;
220 case CallEndpoint.TYPE_EARPIECE -> AudioDevice.EARPIECE;
221 case CallEndpoint.TYPE_SPEAKER -> AudioDevice.SPEAKER_PHONE;
222 case CallEndpoint.TYPE_WIRED_HEADSET -> AudioDevice.WIRED_HEADSET;
223 case CallEndpoint.TYPE_STREAMING -> AudioDevice.STREAMING;
224 case CallEndpoint.TYPE_UNKNOWN -> AudioDevice.NONE;
225 default -> throw new IllegalStateException("Unknown endpoint type " + endpointType);
226 };
227 }
228
229 @RequiresApi(api = Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
230 private void setAudioDeviceUpsideDownCake(final AudioDevice audioDevice) {
231 final var callEndpointOptional =
232 Iterables.tryFind(
233 this.availableEndpoints,
234 e -> getAudioDeviceUpsideDownCake(e) == audioDevice);
235 if (callEndpointOptional.isPresent()) {
236 final var endpoint = callEndpointOptional.get();
237 requestCallEndpointChange(
238 endpoint,
239 MainThreadExecutor.getInstance(),
240 result -> Log.d(Config.LOGTAG, "switched to endpoint " + endpoint));
241 } else {
242 Log.w(Config.LOGTAG, "no endpoint found matching " + audioDevice);
243 }
244 }
245
246 private Set<AudioDevice> getAudioDevicesOreo() {
247 final var audioState = getCallAudioState();
248 if (audioState == null) {
249 Log.d(
250 Config.LOGTAG,
251 "no CallAudioState available. returning empty set for audio devices");
252 return Collections.emptySet();
253 }
254 return getAudioDevicesOreo(audioState);
255 }
256
257 private static Set<AudioDevice> getAudioDevicesOreo(final CallAudioState callAudioState) {
258 final ImmutableSet.Builder<AudioDevice> supportedAudioDevicesBuilder =
259 new ImmutableSet.Builder<>();
260 final var supportedRouteMask = callAudioState.getSupportedRouteMask();
261 if ((supportedRouteMask & CallAudioState.ROUTE_BLUETOOTH)
262 == CallAudioState.ROUTE_BLUETOOTH) {
263 supportedAudioDevicesBuilder.add(AudioDevice.BLUETOOTH);
264 }
265 if ((supportedRouteMask & CallAudioState.ROUTE_EARPIECE) == CallAudioState.ROUTE_EARPIECE) {
266 supportedAudioDevicesBuilder.add(AudioDevice.EARPIECE);
267 }
268 if ((supportedRouteMask & CallAudioState.ROUTE_SPEAKER) == CallAudioState.ROUTE_SPEAKER) {
269 supportedAudioDevicesBuilder.add(AudioDevice.SPEAKER_PHONE);
270 }
271 if ((supportedRouteMask & CallAudioState.ROUTE_WIRED_HEADSET)
272 == CallAudioState.ROUTE_WIRED_HEADSET) {
273 supportedAudioDevicesBuilder.add(AudioDevice.WIRED_HEADSET);
274 }
275 return supportedAudioDevicesBuilder.build();
276 }
277
278 private AudioDevice getAudioDeviceOreo() {
279 final var audioState = getCallAudioState();
280 if (audioState == null) {
281 Log.d(Config.LOGTAG, "no CallAudioState available. returning NONE as audio device");
282 return AudioDevice.NONE;
283 }
284 return getAudioDeviceOreo(audioState);
285 }
286
287 private static AudioDevice getAudioDeviceOreo(final CallAudioState audioState) {
288 // technically we get a mask here; maybe we should query the mask instead
289 return switch (audioState.getRoute()) {
290 case CallAudioState.ROUTE_BLUETOOTH -> AudioDevice.BLUETOOTH;
291 case CallAudioState.ROUTE_EARPIECE -> AudioDevice.EARPIECE;
292 case CallAudioState.ROUTE_SPEAKER -> AudioDevice.SPEAKER_PHONE;
293 case CallAudioState.ROUTE_WIRED_HEADSET -> AudioDevice.WIRED_HEADSET;
294 default -> AudioDevice.NONE;
295 };
296 }
297
298 @RequiresApi(api = Build.VERSION_CODES.O)
299 private void setAudioDeviceOreo(final AudioDevice audioDevice) {
300 switch (audioDevice) {
301 case EARPIECE -> setAudioRoute(CallAudioState.ROUTE_EARPIECE);
302 case BLUETOOTH -> setAudioRoute(CallAudioState.ROUTE_BLUETOOTH);
303 case WIRED_HEADSET -> setAudioRoute(CallAudioState.ROUTE_WIRED_HEADSET);
304 case SPEAKER_PHONE -> setAudioRoute(CallAudioState.ROUTE_SPEAKER);
305 }
306 }
307
308 private Set<AudioDevice> getAudioDevicesFallback() {
309 return requireAppRtcAudioManager().getAudioDevices();
310 }
311
312 private AudioDevice getAudioDeviceFallback() {
313 final var audioDevice = requireAppRtcAudioManager().getSelectedAudioDevice();
314 return audioDevice == null ? AudioDevice.NONE : audioDevice;
315 }
316
317 private void setAudioDeviceFallback(final AudioDevice audioDevice) {
318 final var audioManager = requireAppRtcAudioManager();
319 audioManager.executeOnMain(() -> audioManager.setDefaultAudioDevice(audioDevice));
320 }
321
322 @NonNull
323 private AppRTCAudioManager requireAppRtcAudioManager() {
324 if (this.appRTCAudioManager == null) {
325 throw new IllegalStateException(
326 "You are trying to access the fallback audio manager on a modern device");
327 }
328 return this.appRTCAudioManager;
329 }
330
331 @Override
332 public void onSilence() {
333 this.callback.onCallIntegrationSilence();
334 }
335
336 @Override
337 public void onStateChanged(final int state) {
338 Log.d(Config.LOGTAG, "onStateChanged(" + state + ")");
339 if (notSelfManaged(context)) {
340 if (state == STATE_DIALING) {
341 requireAppRtcAudioManager().startRingBack();
342 } else {
343 requireAppRtcAudioManager().stopRingBack();
344 }
345 }
346 if (state == STATE_ACTIVE) {
347 playConnectedSound();
348 } else if (state == STATE_DISCONNECTED) {
349 final var audioManager = this.appRTCAudioManager;
350 if (audioManager != null) {
351 audioManager.executeOnMain(audioManager::stop);
352 }
353 }
354 }
355
356 private void playConnectedSound() {
357 final var audioAttributes =
358 new AudioAttributes.Builder()
359 .setLegacyStreamType(AudioManager.STREAM_VOICE_CALL)
360 .build();
361 final var mediaPlayer =
362 MediaPlayer.create(
363 context,
364 R.raw.connected,
365 audioAttributes,
366 AudioManager.AUDIO_SESSION_ID_GENERATE);
367 mediaPlayer.setVolume(
368 DEFAULT_MEDIA_PLAYER_VOLUME / 100f, DEFAULT_MEDIA_PLAYER_VOLUME / 100f);
369 mediaPlayer.start();
370 }
371
372 public void success() {
373 Log.d(Config.LOGTAG, "CallIntegration.success()");
374 final var toneGenerator =
375 new ToneGenerator(AudioManager.STREAM_VOICE_CALL, DEFAULT_TONE_VOLUME);
376 toneGenerator.startTone(ToneGenerator.TONE_CDMA_CALLDROP_LITE, 375);
377 this.destroyWithDelay(new DisconnectCause(DisconnectCause.LOCAL, null), 375);
378 }
379
380 public void accepted() {
381 Log.d(Config.LOGTAG, "CallIntegration.accepted()");
382 if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N_MR1) {
383 this.destroyWith(new DisconnectCause(DisconnectCause.ANSWERED_ELSEWHERE, null));
384 } else {
385 this.destroyWith(new DisconnectCause(DisconnectCause.CANCELED, null));
386 }
387 }
388
389 public void error() {
390 Log.d(Config.LOGTAG, "CallIntegration.error()");
391 final var toneGenerator =
392 new ToneGenerator(AudioManager.STREAM_VOICE_CALL, DEFAULT_TONE_VOLUME);
393 toneGenerator.startTone(ToneGenerator.TONE_CDMA_CALLDROP_LITE, 375);
394 this.destroyWithDelay(new DisconnectCause(DisconnectCause.ERROR, null), 375);
395 }
396
397 public void retracted() {
398 Log.d(Config.LOGTAG, "CallIntegration.retracted()");
399 // an alternative cause would be LOCAL
400 this.destroyWith(new DisconnectCause(DisconnectCause.CANCELED, null));
401 }
402
403 public void rejected() {
404 Log.d(Config.LOGTAG, "CallIntegration.rejected()");
405 this.destroyWith(new DisconnectCause(DisconnectCause.REJECTED, null));
406 }
407
408 public void busy() {
409 Log.d(Config.LOGTAG, "CallIntegration.busy()");
410 final var toneGenerator = new ToneGenerator(AudioManager.STREAM_VOICE_CALL, 80);
411 toneGenerator.startTone(ToneGenerator.TONE_CDMA_NETWORK_BUSY, 2500);
412 this.destroyWithDelay(new DisconnectCause(DisconnectCause.BUSY, null), 2500);
413 }
414
415 private void destroyWithDelay(final DisconnectCause disconnectCause, final int delay) {
416 if (this.delayedDestructionInitiated.compareAndSet(false, true)) {
417 JingleConnectionManager.SCHEDULED_EXECUTOR_SERVICE.schedule(
418 () -> {
419 this.setDisconnected(disconnectCause);
420 this.destroyCallIntegration();
421 },
422 delay,
423 TimeUnit.MILLISECONDS);
424 } else {
425 Log.w(Config.LOGTAG, "CallIntegration destruction has already been scheduled!");
426 }
427 }
428
429 private void destroyWith(final DisconnectCause disconnectCause) {
430 if (this.getState() == STATE_DISCONNECTED || this.delayedDestructionInitiated.get()) {
431 Log.d(Config.LOGTAG, "CallIntegration has already been destroyed");
432 return;
433 }
434 this.setDisconnected(disconnectCause);
435 this.destroyCallIntegration();
436 Log.d(Config.LOGTAG, "destroyed!");
437 }
438
439 public static Uri address(final Jid contact) {
440 return Uri.parse(String.format("xmpp:%s", contact.toEscapedString()));
441 }
442
443 public void verifyDisconnected() {
444 if (this.getState() == STATE_DISCONNECTED || this.delayedDestructionInitiated.get()) {
445 return;
446 }
447 throw new AssertionError("CallIntegration has not been disconnected");
448 }
449
450 private void onAudioDeviceChanged(
451 final CallIntegration.AudioDevice selectedAudioDevice,
452 final Set<CallIntegration.AudioDevice> availableAudioDevices) {
453 if (isAudioRoutingRequested) {
454 configureInitialAudioDevice(availableAudioDevices);
455 }
456 final var callback = this.callback;
457 if (callback == null) {
458 return;
459 }
460 callback.onAudioDeviceChanged(selectedAudioDevice, availableAudioDevices);
461 }
462
463 private void configureInitialAudioDevice(final Set<AudioDevice> availableAudioDevices) {
464 final var initialAudioDevice = this.initialAudioDevice;
465 if (initialAudioDevice == null) {
466 Log.d(Config.LOGTAG, "skipping configureInitialAudioDevice()");
467 return;
468 }
469 final var target = this.initialAudioDevice;
470 if (this.initialAudioDeviceConfigured.compareAndSet(false, true)) {
471 if (availableAudioDevices.contains(target)
472 && !availableAudioDevices.contains(AudioDevice.BLUETOOTH)) {
473 setAudioDevice(target);
474 Log.d(Config.LOGTAG, "configured initial audio device: " + target);
475 } else {
476 Log.d(
477 Config.LOGTAG,
478 "not setting initial audio device. available devices: "
479 + availableAudioDevices);
480 }
481 }
482 }
483
484 private boolean selfManaged() {
485 return selfManaged(context);
486 }
487
488 public static boolean selfManaged(final Context context) {
489 return Build.VERSION.SDK_INT >= Build.VERSION_CODES.O
490 && hasSystemFeature(context)
491 && !BROKEN_DEVICE_MODELS.contains(Build.DEVICE);
492 }
493
494 public static boolean hasSystemFeature(final Context context) {
495 final var packageManager = context.getPackageManager();
496 if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
497 return packageManager.hasSystemFeature(PackageManager.FEATURE_TELECOM);
498 } else {
499 //noinspection deprecation
500 return packageManager.hasSystemFeature(PackageManager.FEATURE_CONNECTION_SERVICE);
501 }
502 }
503
504 public static boolean notSelfManaged(final Context context) {
505 return !selfManaged(context);
506 }
507
508 public void setInitialAudioDevice(final AudioDevice audioDevice) {
509 Log.d(Config.LOGTAG, "setInitialAudioDevice(" + audioDevice + ")");
510 this.initialAudioDevice = audioDevice;
511 }
512
513 public void startAudioRouting() {
514 this.isAudioRoutingRequested = true;
515 if (selfManaged()) {
516 final var devices = getAudioDevices();
517 if (devices.isEmpty()) {
518 return;
519 }
520 configureInitialAudioDevice(devices);
521 return;
522 }
523 final var audioManager = requireAppRtcAudioManager();
524 audioManager.executeOnMain(
525 () -> {
526 audioManager.start();
527 this.onAudioDeviceChanged(
528 audioManager.getSelectedAudioDevice(), audioManager.getAudioDevices());
529 });
530 }
531
532 private void destroyCallIntegration() {
533 super.destroy();
534 this.isDestroyed.set(true);
535 }
536
537 public boolean isDestroyed() {
538 return this.isDestroyed.get();
539 }
540
541 /** AudioDevice is the names of possible audio devices that we currently support. */
542 public enum AudioDevice {
543 NONE,
544 SPEAKER_PHONE,
545 WIRED_HEADSET,
546 EARPIECE,
547 BLUETOOTH,
548 STREAMING
549 }
550
551 public static AudioDevice initialAudioDevice(final Set<Media> media) {
552 if (Media.audioOnly(media)) {
553 return AudioDevice.EARPIECE;
554 } else {
555 return AudioDevice.SPEAKER_PHONE;
556 }
557 }
558
559 public interface Callback {
560 void onCallIntegrationShowIncomingCallUi();
561
562 void onCallIntegrationDisconnect();
563
564 void onAudioDeviceChanged(
565 CallIntegration.AudioDevice selectedAudioDevice,
566 Set<CallIntegration.AudioDevice> availableAudioDevices);
567
568 void onCallIntegrationReject();
569
570 void onCallIntegrationAnswer();
571
572 void onCallIntegrationSilence();
573 }
574}