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