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