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