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