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