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