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 final var audioDevice = requireAppRtcAudioManager().getSelectedAudioDevice();
301 return audioDevice == null ? AudioDevice.NONE : audioDevice;
302 }
303
304 private void setAudioDeviceFallback(final AudioDevice audioDevice) {
305 final var audioManager = requireAppRtcAudioManager();
306 audioManager.executeOnMain(() -> audioManager.setDefaultAudioDevice(audioDevice));
307 }
308
309 @NonNull
310 private AppRTCAudioManager requireAppRtcAudioManager() {
311 if (this.appRTCAudioManager == null) {
312 throw new IllegalStateException(
313 "You are trying to access the fallback audio manager on a modern device");
314 }
315 return this.appRTCAudioManager;
316 }
317
318 @Override
319 public void onSilence() {
320 this.callback.onCallIntegrationSilence();
321 }
322
323 @Override
324 public void onStateChanged(final int state) {
325 Log.d(Config.LOGTAG, "onStateChanged(" + state + ")");
326 if (notSelfManaged(context)) {
327 if (state == STATE_DIALING) {
328 requireAppRtcAudioManager().startRingBack();
329 } else {
330 requireAppRtcAudioManager().stopRingBack();
331 }
332 }
333 if (state == STATE_ACTIVE) {
334 playConnectedSound();
335 } else if (state == STATE_DISCONNECTED) {
336 final var audioManager = this.appRTCAudioManager;
337 if (audioManager != null) {
338 audioManager.executeOnMain(audioManager::stop);
339 }
340 }
341 }
342
343 private void playConnectedSound() {
344 final var audioAttributes =
345 new AudioAttributes.Builder()
346 .setLegacyStreamType(AudioManager.STREAM_VOICE_CALL)
347 .build();
348 final var mediaPlayer =
349 MediaPlayer.create(
350 context,
351 R.raw.connected,
352 audioAttributes,
353 AudioManager.AUDIO_SESSION_ID_GENERATE);
354 mediaPlayer.setVolume(
355 DEFAULT_MEDIA_PLAYER_VOLUME / 100f, DEFAULT_MEDIA_PLAYER_VOLUME / 100f);
356 mediaPlayer.start();
357 }
358
359 public void success() {
360 Log.d(Config.LOGTAG, "CallIntegration.success()");
361 final var toneGenerator =
362 new ToneGenerator(AudioManager.STREAM_VOICE_CALL, DEFAULT_TONE_VOLUME);
363 toneGenerator.startTone(ToneGenerator.TONE_CDMA_CALLDROP_LITE, 375);
364 this.destroyWithDelay(new DisconnectCause(DisconnectCause.LOCAL, null), 375);
365 }
366
367 public void accepted() {
368 Log.d(Config.LOGTAG, "CallIntegration.accepted()");
369 if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N_MR1) {
370 this.destroyWith(new DisconnectCause(DisconnectCause.ANSWERED_ELSEWHERE, null));
371 } else {
372 this.destroyWith(new DisconnectCause(DisconnectCause.CANCELED, null));
373 }
374 }
375
376 public void error() {
377 Log.d(Config.LOGTAG, "CallIntegration.error()");
378 final var toneGenerator =
379 new ToneGenerator(AudioManager.STREAM_VOICE_CALL, DEFAULT_TONE_VOLUME);
380 toneGenerator.startTone(ToneGenerator.TONE_CDMA_CALLDROP_LITE, 375);
381 this.destroyWithDelay(new DisconnectCause(DisconnectCause.ERROR, null), 375);
382 }
383
384 public void retracted() {
385 Log.d(Config.LOGTAG, "CallIntegration.retracted()");
386 // an alternative cause would be LOCAL
387 this.destroyWith(new DisconnectCause(DisconnectCause.CANCELED, null));
388 }
389
390 public void rejected() {
391 Log.d(Config.LOGTAG, "CallIntegration.rejected()");
392 this.destroyWith(new DisconnectCause(DisconnectCause.REJECTED, null));
393 }
394
395 public void busy() {
396 Log.d(Config.LOGTAG, "CallIntegration.busy()");
397 final var toneGenerator = new ToneGenerator(AudioManager.STREAM_VOICE_CALL, 80);
398 toneGenerator.startTone(ToneGenerator.TONE_CDMA_NETWORK_BUSY, 2500);
399 this.destroyWithDelay(new DisconnectCause(DisconnectCause.BUSY, null), 2500);
400 }
401
402 private void destroyWithDelay(final DisconnectCause disconnectCause, final int delay) {
403 if (this.delayedDestructionInitiated.compareAndSet(false, true)) {
404 JingleConnectionManager.SCHEDULED_EXECUTOR_SERVICE.schedule(
405 () -> {
406 this.setDisconnected(disconnectCause);
407 this.destroyCallIntegration();
408 },
409 delay,
410 TimeUnit.MILLISECONDS);
411 } else {
412 Log.w(Config.LOGTAG, "CallIntegration destruction has already been scheduled!");
413 }
414 }
415
416 private void destroyWith(final DisconnectCause disconnectCause) {
417 if (this.getState() == STATE_DISCONNECTED || this.delayedDestructionInitiated.get()) {
418 Log.d(Config.LOGTAG, "CallIntegration has already been destroyed");
419 return;
420 }
421 this.setDisconnected(disconnectCause);
422 this.destroyCallIntegration();
423 Log.d(Config.LOGTAG, "destroyed!");
424 }
425
426 public static Uri address(final Jid contact) {
427 return Uri.parse(String.format("xmpp:%s", contact.toEscapedString()));
428 }
429
430 public void verifyDisconnected() {
431 if (this.getState() == STATE_DISCONNECTED || this.delayedDestructionInitiated.get()) {
432 return;
433 }
434 throw new AssertionError("CallIntegration has not been disconnected");
435 }
436
437 private void onAudioDeviceChanged(
438 final CallIntegration.AudioDevice selectedAudioDevice,
439 final Set<CallIntegration.AudioDevice> availableAudioDevices) {
440 if (this.initialAudioDevice != null
441 && this.initialAudioDeviceConfigured.compareAndSet(false, true)) {
442 if (availableAudioDevices.contains(this.initialAudioDevice)
443 && !availableAudioDevices.contains(AudioDevice.BLUETOOTH)) {
444 setAudioDevice(this.initialAudioDevice);
445 Log.d(Config.LOGTAG, "configured initial audio device");
446 } else {
447 Log.d(
448 Config.LOGTAG,
449 "not setting initial audio device. available devices: "
450 + availableAudioDevices);
451 }
452 }
453 final var callback = this.callback;
454 if (callback == null) {
455 return;
456 }
457 callback.onAudioDeviceChanged(selectedAudioDevice, availableAudioDevices);
458 }
459
460 private boolean selfManaged() {
461 return selfManaged(context);
462 }
463
464 public static boolean selfManaged(final Context context) {
465 return Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && hasSystemFeature(context);
466 }
467
468 public static boolean hasSystemFeature(final Context context) {
469 final var packageManager = context.getPackageManager();
470 if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
471 return packageManager.hasSystemFeature(PackageManager.FEATURE_TELECOM);
472 } else {
473 return packageManager.hasSystemFeature(PackageManager.FEATURE_CONNECTION_SERVICE);
474 }
475 }
476
477 public static boolean notSelfManaged(final Context context) {
478 return !selfManaged(context);
479 }
480
481 public void setInitialAudioDevice(final AudioDevice audioDevice) {
482 Log.d(Config.LOGTAG, "setInitialAudioDevice(" + audioDevice + ")");
483 this.initialAudioDevice = audioDevice;
484 if (selfManaged()) {
485 // once the 'CallIntegration' gets added to the system we receive calls to update audio
486 // state
487 return;
488 }
489 final var audioManager = requireAppRtcAudioManager();
490 audioManager.executeOnMain(
491 () ->
492 this.onAudioDeviceChanged(
493 audioManager.getSelectedAudioDevice(),
494 audioManager.getAudioDevices()));
495 }
496
497 private void destroyCallIntegration() {
498 super.destroy();
499 this.isDestroyed.set(true);
500 }
501
502 public boolean isDestroyed() {
503 return this.isDestroyed.get();
504 }
505
506 /** AudioDevice is the names of possible audio devices that we currently support. */
507 public enum AudioDevice {
508 NONE,
509 SPEAKER_PHONE,
510 WIRED_HEADSET,
511 EARPIECE,
512 BLUETOOTH,
513 STREAMING
514 }
515
516 public static AudioDevice initialAudioDevice(final Set<Media> media) {
517 if (Media.audioOnly(media)) {
518 return AudioDevice.EARPIECE;
519 } else {
520 return AudioDevice.SPEAKER_PHONE;
521 }
522 }
523
524 public interface Callback {
525 void onCallIntegrationShowIncomingCallUi();
526
527 void onCallIntegrationDisconnect();
528
529 void onAudioDeviceChanged(
530 CallIntegration.AudioDevice selectedAudioDevice,
531 Set<CallIntegration.AudioDevice> availableAudioDevices);
532
533 void onCallIntegrationReject();
534
535 void onCallIntegrationAnswer();
536
537 void onCallIntegrationSilence();
538 }
539}