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