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
47 private List<CallEndpoint> availableEndpoints = Collections.emptyList();
48
49 private Callback callback = null;
50
51 public CallIntegration(final Context context) {
52 this.context = context.getApplicationContext();
53 if (selfManaged()) {
54 if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
55 setConnectionProperties(Connection.PROPERTY_SELF_MANAGED);
56 } else {
57 throw new AssertionError(
58 "Trying to set connection properties on unsupported version");
59 }
60 this.appRTCAudioManager = null;
61 } else {
62 this.appRTCAudioManager = new AppRTCAudioManager(context);
63 ContextCompat.getMainExecutor(context)
64 .execute(() -> this.appRTCAudioManager.start(this::onAudioDeviceChanged));
65 }
66 setRingbackRequested(true);
67 }
68
69 public void setCallback(final Callback callback) {
70 this.callback = callback;
71 }
72
73 @Override
74 public void onShowIncomingCallUi() {
75 Log.d(Config.LOGTAG, "onShowIncomingCallUi");
76 this.callback.onCallIntegrationShowIncomingCallUi();
77 }
78
79 @Override
80 public void onAnswer() {
81 this.callback.onCallIntegrationAnswer();
82 }
83
84 @Override
85 public void onDisconnect() {
86 Log.d(Config.LOGTAG, "onDisconnect()");
87 this.callback.onCallIntegrationDisconnect();
88 }
89
90 @Override
91 public void onReject() {
92 this.callback.onCallIntegrationReject();
93 }
94
95 @Override
96 public void onReject(final String replyMessage) {
97 Log.d(Config.LOGTAG, "onReject(" + replyMessage + ")");
98 this.callback.onCallIntegrationReject();
99 }
100
101 @RequiresApi(api = Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
102 @Override
103 public void onAvailableCallEndpointsChanged(@NonNull List<CallEndpoint> availableEndpoints) {
104 Log.d(Config.LOGTAG, "onAvailableCallEndpointsChanged(" + availableEndpoints + ")");
105 this.availableEndpoints = availableEndpoints;
106 this.onAudioDeviceChanged(
107 getAudioDeviceUpsideDownCake(getCurrentCallEndpoint()),
108 ImmutableSet.copyOf(
109 Lists.transform(
110 availableEndpoints,
111 CallIntegration::getAudioDeviceUpsideDownCake)));
112 }
113
114 @RequiresApi(api = Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
115 @Override
116 public void onCallEndpointChanged(@NonNull final CallEndpoint callEndpoint) {
117 Log.d(Config.LOGTAG, "onCallEndpointChanged()");
118 this.onAudioDeviceChanged(
119 getAudioDeviceUpsideDownCake(callEndpoint),
120 ImmutableSet.copyOf(
121 Lists.transform(
122 this.availableEndpoints,
123 CallIntegration::getAudioDeviceUpsideDownCake)));
124 }
125
126 @Override
127 public void onCallAudioStateChanged(final CallAudioState state) {
128 if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
129 Log.d(Config.LOGTAG, "ignoring onCallAudioStateChange() on Upside Down Cake");
130 return;
131 }
132 Log.d(Config.LOGTAG, "onCallAudioStateChange(" + state + ")");
133 this.onAudioDeviceChanged(getAudioDeviceOreo(state), getAudioDevicesOreo(state));
134 }
135
136 public Set<AudioDevice> getAudioDevices() {
137 if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
138 return getAudioDevicesUpsideDownCake();
139 } else if (selfManaged()) {
140 return getAudioDevicesOreo();
141 } else {
142 return getAudioDevicesFallback();
143 }
144 }
145
146 public AudioDevice getSelectedAudioDevice() {
147 if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
148 return getAudioDeviceUpsideDownCake();
149 } else if (selfManaged()) {
150 return getAudioDeviceOreo();
151 } else {
152 return getAudioDeviceFallback();
153 }
154 }
155
156 public void setAudioDevice(final AudioDevice audioDevice) {
157 if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
158 setAudioDeviceUpsideDownCake(audioDevice);
159 } else if (selfManaged()) {
160 if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
161 setAudioDeviceOreo(audioDevice);
162 } else {
163 throw new AssertionError("Trying to set audio devices on unsupported version");
164 }
165 } else {
166 setAudioDeviceFallback(audioDevice);
167 }
168 }
169
170 public void setAudioDeviceWhenAvailable(final AudioDevice audioDevice) {
171 final var available = getAudioDevices();
172 if (available.contains(audioDevice) && !available.contains(AudioDevice.BLUETOOTH)) {
173 this.setAudioDevice(audioDevice);
174 } else {
175 Log.d(
176 Config.LOGTAG,
177 "application requested to switch to "
178 + audioDevice
179 + " but we won't because available devices are "
180 + available);
181 }
182 }
183
184 @RequiresApi(api = Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
185 private Set<AudioDevice> getAudioDevicesUpsideDownCake() {
186 return ImmutableSet.copyOf(
187 Lists.transform(
188 this.availableEndpoints, CallIntegration::getAudioDeviceUpsideDownCake));
189 }
190
191 @RequiresApi(api = Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
192 private AudioDevice getAudioDeviceUpsideDownCake() {
193 return getAudioDeviceUpsideDownCake(getCurrentCallEndpoint());
194 }
195
196 @RequiresApi(api = Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
197 private static AudioDevice getAudioDeviceUpsideDownCake(final CallEndpoint callEndpoint) {
198 if (callEndpoint == null) {
199 return AudioDevice.NONE;
200 }
201 final var endpointType = callEndpoint.getEndpointType();
202 return switch (endpointType) {
203 case CallEndpoint.TYPE_BLUETOOTH -> AudioDevice.BLUETOOTH;
204 case CallEndpoint.TYPE_EARPIECE -> AudioDevice.EARPIECE;
205 case CallEndpoint.TYPE_SPEAKER -> AudioDevice.SPEAKER_PHONE;
206 case CallEndpoint.TYPE_WIRED_HEADSET -> AudioDevice.WIRED_HEADSET;
207 case CallEndpoint.TYPE_STREAMING -> AudioDevice.STREAMING;
208 case CallEndpoint.TYPE_UNKNOWN -> AudioDevice.NONE;
209 default -> throw new IllegalStateException("Unknown endpoint type " + endpointType);
210 };
211 }
212
213 @RequiresApi(api = Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
214 private void setAudioDeviceUpsideDownCake(final AudioDevice audioDevice) {
215 final var callEndpointOptional =
216 Iterables.tryFind(
217 this.availableEndpoints,
218 e -> getAudioDeviceUpsideDownCake(e) == audioDevice);
219 if (callEndpointOptional.isPresent()) {
220 final var endpoint = callEndpointOptional.get();
221 requestCallEndpointChange(
222 endpoint,
223 MainThreadExecutor.getInstance(),
224 result -> Log.d(Config.LOGTAG, "switched to endpoint " + endpoint));
225 } else {
226 Log.w(Config.LOGTAG, "no endpoint found matching " + audioDevice);
227 }
228 }
229
230 private Set<AudioDevice> getAudioDevicesOreo() {
231 final var audioState = getCallAudioState();
232 if (audioState == null) {
233 Log.d(
234 Config.LOGTAG,
235 "no CallAudioState available. returning empty set for audio devices");
236 return Collections.emptySet();
237 }
238 return getAudioDevicesOreo(audioState);
239 }
240
241 private static Set<AudioDevice> getAudioDevicesOreo(final CallAudioState callAudioState) {
242 final ImmutableSet.Builder<AudioDevice> supportedAudioDevicesBuilder =
243 new ImmutableSet.Builder<>();
244 final var supportedRouteMask = callAudioState.getSupportedRouteMask();
245 if ((supportedRouteMask & CallAudioState.ROUTE_BLUETOOTH)
246 == CallAudioState.ROUTE_BLUETOOTH) {
247 supportedAudioDevicesBuilder.add(AudioDevice.BLUETOOTH);
248 }
249 if ((supportedRouteMask & CallAudioState.ROUTE_EARPIECE) == CallAudioState.ROUTE_EARPIECE) {
250 supportedAudioDevicesBuilder.add(AudioDevice.EARPIECE);
251 }
252 if ((supportedRouteMask & CallAudioState.ROUTE_SPEAKER) == CallAudioState.ROUTE_SPEAKER) {
253 supportedAudioDevicesBuilder.add(AudioDevice.SPEAKER_PHONE);
254 }
255 if ((supportedRouteMask & CallAudioState.ROUTE_WIRED_HEADSET)
256 == CallAudioState.ROUTE_WIRED_HEADSET) {
257 supportedAudioDevicesBuilder.add(AudioDevice.WIRED_HEADSET);
258 }
259 return supportedAudioDevicesBuilder.build();
260 }
261
262 private AudioDevice getAudioDeviceOreo() {
263 final var audioState = getCallAudioState();
264 if (audioState == null) {
265 Log.d(Config.LOGTAG, "no CallAudioState available. returning NONE as audio device");
266 return AudioDevice.NONE;
267 }
268 return getAudioDeviceOreo(audioState);
269 }
270
271 private static AudioDevice getAudioDeviceOreo(final CallAudioState audioState) {
272 // technically we get a mask here; maybe we should query the mask instead
273 return switch (audioState.getRoute()) {
274 case CallAudioState.ROUTE_BLUETOOTH -> AudioDevice.BLUETOOTH;
275 case CallAudioState.ROUTE_EARPIECE -> AudioDevice.EARPIECE;
276 case CallAudioState.ROUTE_SPEAKER -> AudioDevice.SPEAKER_PHONE;
277 case CallAudioState.ROUTE_WIRED_HEADSET -> AudioDevice.WIRED_HEADSET;
278 default -> AudioDevice.NONE;
279 };
280 }
281
282 @RequiresApi(api = Build.VERSION_CODES.O)
283 private void setAudioDeviceOreo(final AudioDevice audioDevice) {
284 switch (audioDevice) {
285 case EARPIECE -> setAudioRoute(CallAudioState.ROUTE_EARPIECE);
286 case BLUETOOTH -> setAudioRoute(CallAudioState.ROUTE_BLUETOOTH);
287 case WIRED_HEADSET -> setAudioRoute(CallAudioState.ROUTE_WIRED_HEADSET);
288 case SPEAKER_PHONE -> setAudioRoute(CallAudioState.ROUTE_SPEAKER);
289 }
290 }
291
292 private Set<AudioDevice> getAudioDevicesFallback() {
293 return requireAppRtcAudioManager().getAudioDevices();
294 }
295
296 private AudioDevice getAudioDeviceFallback() {
297 return requireAppRtcAudioManager().getSelectedAudioDevice();
298 }
299
300 private void setAudioDeviceFallback(final AudioDevice audioDevice) {
301 final var audioManager = requireAppRtcAudioManager();
302 audioManager.executeOnMain(() -> audioManager.setDefaultAudioDevice(audioDevice));
303 }
304
305 @NonNull
306 private AppRTCAudioManager requireAppRtcAudioManager() {
307 if (this.appRTCAudioManager == null) {
308 throw new IllegalStateException(
309 "You are trying to access the fallback audio manager on a modern device");
310 }
311 return this.appRTCAudioManager;
312 }
313
314 @Override
315 public void onSilence() {
316 this.callback.onCallIntegrationSilence();
317 }
318
319 @Override
320 public void onStateChanged(final int state) {
321 Log.d(Config.LOGTAG, "onStateChanged(" + state + ")");
322 if (notSelfManaged(context)) {
323 if (state == STATE_DIALING) {
324 requireAppRtcAudioManager().startRingBack();
325 } else {
326 requireAppRtcAudioManager().stopRingBack();
327 }
328 }
329 if (state == STATE_ACTIVE) {
330 playConnectedSound();
331 } else if (state == STATE_DISCONNECTED) {
332 final var audioManager = this.appRTCAudioManager;
333 if (audioManager != null) {
334 audioManager.executeOnMain(audioManager::stop);
335 }
336 }
337 }
338
339 private void playConnectedSound() {
340 final var mediaPlayer = MediaPlayer.create(context, R.raw.connected);
341 mediaPlayer.setVolume(DEFAULT_VOLUME / 100f, DEFAULT_VOLUME / 100f);
342 mediaPlayer.start();
343 }
344
345 public void success() {
346 Log.d(Config.LOGTAG, "CallIntegration.success()");
347 final var toneGenerator = new ToneGenerator(AudioManager.STREAM_MUSIC, DEFAULT_VOLUME);
348 toneGenerator.startTone(ToneGenerator.TONE_CDMA_CALLDROP_LITE, 375);
349 this.destroyWithDelay(new DisconnectCause(DisconnectCause.LOCAL, null), 375);
350 }
351
352 public void accepted() {
353 Log.d(Config.LOGTAG, "CallIntegration.accepted()");
354 if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N_MR1) {
355 this.destroyWith(new DisconnectCause(DisconnectCause.ANSWERED_ELSEWHERE, null));
356 } else {
357 this.destroyWith(new DisconnectCause(DisconnectCause.CANCELED, null));
358 }
359 }
360
361 public void error() {
362 Log.d(Config.LOGTAG, "CallIntegration.error()");
363 final var toneGenerator = new ToneGenerator(AudioManager.STREAM_MUSIC, DEFAULT_VOLUME);
364 toneGenerator.startTone(ToneGenerator.TONE_CDMA_CALLDROP_LITE, 375);
365 this.destroyWithDelay(new DisconnectCause(DisconnectCause.ERROR, null), 375);
366 this.destroyWith(new DisconnectCause(DisconnectCause.ERROR, null));
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.destroy();
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.destroy();
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 /** AudioDevice is the names of possible audio devices that we currently support. */
476 public enum AudioDevice {
477 NONE,
478 SPEAKER_PHONE,
479 WIRED_HEADSET,
480 EARPIECE,
481 BLUETOOTH,
482 STREAMING
483 }
484
485 public static AudioDevice initialAudioDevice(final Set<Media> media) {
486 if (Media.audioOnly(media)) {
487 return AudioDevice.EARPIECE;
488 } else {
489 return AudioDevice.SPEAKER_PHONE;
490 }
491 }
492
493 public interface Callback {
494 void onCallIntegrationShowIncomingCallUi();
495
496 void onCallIntegrationDisconnect();
497
498 void onAudioDeviceChanged(
499 CallIntegration.AudioDevice selectedAudioDevice,
500 Set<CallIntegration.AudioDevice> availableAudioDevices);
501
502 void onCallIntegrationReject();
503
504 void onCallIntegrationAnswer();
505
506 void onCallIntegrationSilence();
507 }
508}