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