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