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