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