1/*
2 * Copyright 2016 The WebRTC Project Authors. All rights reserved.
3 *
4 * Use of this source code is governed by a BSD-style license
5 * that can be found in the LICENSE file in the root of the source
6 * tree. An additional intellectual property rights grant can be found
7 * in the file PATENTS. All contributing project authors may
8 * be found in the AUTHORS file in the root of the source tree.
9 */
10package eu.siacs.conversations.services;
11
12import android.Manifest;
13import android.annotation.SuppressLint;
14import android.bluetooth.BluetoothAdapter;
15import android.bluetooth.BluetoothDevice;
16import android.bluetooth.BluetoothHeadset;
17import android.bluetooth.BluetoothProfile;
18import android.content.BroadcastReceiver;
19import android.content.Context;
20import android.content.Intent;
21import android.content.IntentFilter;
22import android.content.pm.PackageManager;
23import android.media.AudioManager;
24import android.os.Build;
25import android.os.Handler;
26import android.os.Looper;
27import android.util.Log;
28
29import androidx.annotation.Nullable;
30import androidx.core.app.ActivityCompat;
31
32import com.google.common.collect.ImmutableList;
33
34import org.webrtc.ThreadUtils;
35
36import java.util.Collections;
37import java.util.List;
38
39import eu.siacs.conversations.Config;
40import eu.siacs.conversations.utils.AppRTCUtils;
41
42/** AppRTCProximitySensor manages functions related to Bluetoth devices in the AppRTC demo. */
43public class AppRTCBluetoothManager {
44 // Timeout interval for starting or stopping audio to a Bluetooth SCO device.
45 private static final int BLUETOOTH_SCO_TIMEOUT_MS = 4000;
46 // Maximum number of SCO connection attempts.
47 private static final int MAX_SCO_CONNECTION_ATTEMPTS = 2;
48 private final Context apprtcContext;
49 private final AppRTCAudioManager apprtcAudioManager;
50 @Nullable private final AudioManager audioManager;
51 private final Handler handler;
52 private final BluetoothProfile.ServiceListener bluetoothServiceListener;
53 private final BroadcastReceiver bluetoothHeadsetReceiver;
54 int scoConnectionAttempts;
55 private State bluetoothState;
56 @Nullable private BluetoothAdapter bluetoothAdapter;
57 @Nullable private BluetoothHeadset bluetoothHeadset;
58 @Nullable private BluetoothDevice bluetoothDevice;
59 // Runs when the Bluetooth timeout expires. We use that timeout after calling
60 // startScoAudio() or stopScoAudio() because we're not guaranteed to get a
61 // callback after those calls.
62 private final Runnable bluetoothTimeoutRunnable =
63 new Runnable() {
64 @Override
65 public void run() {
66 bluetoothTimeout();
67 }
68 };
69
70 protected AppRTCBluetoothManager(Context context, AppRTCAudioManager audioManager) {
71 Log.d(Config.LOGTAG, "ctor");
72 ThreadUtils.checkIsOnMainThread();
73 apprtcContext = context;
74 apprtcAudioManager = audioManager;
75 this.audioManager = getAudioManager(context);
76 bluetoothState = State.UNINITIALIZED;
77 bluetoothServiceListener = new BluetoothServiceListener();
78 bluetoothHeadsetReceiver = new BluetoothHeadsetBroadcastReceiver();
79 handler = new Handler(Looper.getMainLooper());
80 }
81
82 /** Construction. */
83 static AppRTCBluetoothManager create(Context context, AppRTCAudioManager audioManager) {
84 Log.d(Config.LOGTAG, "create" + AppRTCUtils.getThreadInfo());
85 return new AppRTCBluetoothManager(context, audioManager);
86 }
87
88 /** Returns the internal state. */
89 public State getState() {
90 ThreadUtils.checkIsOnMainThread();
91 return bluetoothState;
92 }
93
94 /**
95 * Activates components required to detect Bluetooth devices and to enable BT SCO (audio is
96 * routed via BT SCO) for the headset profile. The end state will be HEADSET_UNAVAILABLE but a
97 * state machine has started which will start a state change sequence where the final outcome
98 * depends on if/when the BT headset is enabled. Example of state change sequence when start()
99 * is called while BT device is connected and enabled: UNINITIALIZED --> HEADSET_UNAVAILABLE -->
100 * HEADSET_AVAILABLE --> SCO_CONNECTING --> SCO_CONNECTED <==> audio is now routed via BT SCO.
101 * Note that the AppRTCAudioManager is also involved in driving this state change.
102 */
103 public void start() {
104 ThreadUtils.checkIsOnMainThread();
105 if (bluetoothState != State.UNINITIALIZED) {
106 Log.w(Config.LOGTAG, "Invalid BT state");
107 return;
108 }
109 bluetoothHeadset = null;
110 bluetoothDevice = null;
111 scoConnectionAttempts = 0;
112 // Get a handle to the default local Bluetooth adapter.
113 bluetoothAdapter = BluetoothAdapter.getDefaultAdapter();
114 if (bluetoothAdapter == null) {
115 Log.w(Config.LOGTAG, "Device does not support Bluetooth");
116 return;
117 }
118 // Ensure that the device supports use of BT SCO audio for off call use cases.
119 if (this.audioManager == null || !audioManager.isBluetoothScoAvailableOffCall()) {
120 Log.e(Config.LOGTAG, "Bluetooth SCO audio is not available off call");
121 return;
122 }
123 // Establish a connection to the HEADSET profile (includes both Bluetooth Headset and
124 // Hands-Free) proxy object and install a listener.
125 if (!getBluetoothProfileProxy(
126 apprtcContext, bluetoothServiceListener, BluetoothProfile.HEADSET)) {
127 Log.e(Config.LOGTAG, "BluetoothAdapter.getProfileProxy(HEADSET) failed");
128 return;
129 }
130 // Register receivers for BluetoothHeadset change notifications.
131 IntentFilter bluetoothHeadsetFilter = new IntentFilter();
132 // Register receiver for change in connection state of the Headset profile.
133 bluetoothHeadsetFilter.addAction(BluetoothHeadset.ACTION_CONNECTION_STATE_CHANGED);
134 // Register receiver for change in audio connection state of the Headset profile.
135 bluetoothHeadsetFilter.addAction(BluetoothHeadset.ACTION_AUDIO_STATE_CHANGED);
136 registerReceiver(bluetoothHeadsetReceiver, bluetoothHeadsetFilter);
137 if (hasBluetoothConnectPermission()) {
138 Log.d(
139 Config.LOGTAG,
140 "HEADSET profile state: "
141 + stateToString(
142 bluetoothAdapter.getProfileConnectionState(
143 BluetoothProfile.HEADSET)));
144 }
145 Log.d(Config.LOGTAG, "Bluetooth proxy for headset profile has started");
146 bluetoothState = State.HEADSET_UNAVAILABLE;
147 Log.d(Config.LOGTAG, "start done: BT state=" + bluetoothState);
148 }
149
150 /** Stops and closes all components related to Bluetooth audio. */
151 public void stop() {
152 ThreadUtils.checkIsOnMainThread();
153 Log.d(Config.LOGTAG, "stop: BT state=" + bluetoothState);
154 if (bluetoothAdapter == null) {
155 return;
156 }
157 // Stop BT SCO connection with remote device if needed.
158 stopScoAudio();
159 // Close down remaining BT resources.
160 if (bluetoothState == State.UNINITIALIZED) {
161 return;
162 }
163 unregisterReceiver(bluetoothHeadsetReceiver);
164 cancelTimer();
165 if (bluetoothHeadset != null) {
166 bluetoothAdapter.closeProfileProxy(BluetoothProfile.HEADSET, bluetoothHeadset);
167 bluetoothHeadset = null;
168 }
169 bluetoothAdapter = null;
170 bluetoothDevice = null;
171 bluetoothState = State.UNINITIALIZED;
172 Log.d(Config.LOGTAG, "stop done: BT state=" + bluetoothState);
173 }
174
175 /**
176 * Starts Bluetooth SCO connection with remote device. Note that the phone application always
177 * has the priority on the usage of the SCO connection for telephony. If this method is called
178 * while the phone is in call it will be ignored. Similarly, if a call is received or sent while
179 * an application is using the SCO connection, the connection will be lost for the application
180 * and NOT returned automatically when the call ends. Also note that: up to and including API
181 * version JELLY_BEAN_MR1, this method initiates a virtual voice call to the Bluetooth headset.
182 * After API version JELLY_BEAN_MR2 only a raw SCO audio connection is established.
183 * TODO(henrika): should we add support for virtual voice call to BT headset also for JBMR2 and
184 * higher. It might be required to initiates a virtual voice call since many devices do not
185 * accept SCO audio without a "call".
186 */
187 public boolean startScoAudio() {
188 ThreadUtils.checkIsOnMainThread();
189 Log.d(
190 Config.LOGTAG,
191 "startSco: BT state="
192 + bluetoothState
193 + ", "
194 + "attempts: "
195 + scoConnectionAttempts
196 + ", "
197 + "SCO is on: "
198 + isScoOn());
199 if (scoConnectionAttempts >= MAX_SCO_CONNECTION_ATTEMPTS) {
200 Log.e(Config.LOGTAG, "BT SCO connection fails - no more attempts");
201 return false;
202 }
203 if (bluetoothState != State.HEADSET_AVAILABLE) {
204 Log.e(Config.LOGTAG, "BT SCO connection fails - no headset available");
205 return false;
206 }
207 // Start BT SCO channel and wait for ACTION_AUDIO_STATE_CHANGED.
208 Log.d(Config.LOGTAG, "Starting Bluetooth SCO and waits for ACTION_AUDIO_STATE_CHANGED...");
209 // The SCO connection establishment can take several seconds, hence we cannot rely on the
210 // connection to be available when the method returns but instead register to receive the
211 // intent ACTION_SCO_AUDIO_STATE_UPDATED and wait for the state to be
212 // SCO_AUDIO_STATE_CONNECTED.
213 bluetoothState = State.SCO_CONNECTING;
214 audioManager.startBluetoothSco();
215 audioManager.setBluetoothScoOn(true);
216 scoConnectionAttempts++;
217 startTimer();
218 Log.d(
219 Config.LOGTAG,
220 "startScoAudio done: BT state="
221 + bluetoothState
222 + ", "
223 + "SCO is on: "
224 + isScoOn());
225 return true;
226 }
227
228 /** Stops Bluetooth SCO connection with remote device. */
229 public void stopScoAudio() {
230 ThreadUtils.checkIsOnMainThread();
231 Log.d(
232 Config.LOGTAG,
233 "stopScoAudio: BT state=" + bluetoothState + ", " + "SCO is on: " + isScoOn());
234 if (bluetoothState != State.SCO_CONNECTING && bluetoothState != State.SCO_CONNECTED) {
235 return;
236 }
237 cancelTimer();
238 audioManager.stopBluetoothSco();
239 audioManager.setBluetoothScoOn(false);
240 bluetoothState = State.SCO_DISCONNECTING;
241 Log.d(
242 Config.LOGTAG,
243 "stopScoAudio done: BT state=" + bluetoothState + ", " + "SCO is on: " + isScoOn());
244 }
245
246 /**
247 * Use the BluetoothHeadset proxy object (controls the Bluetooth Headset Service via IPC) to
248 * update the list of connected devices for the HEADSET profile. The internal state will change
249 * to HEADSET_UNAVAILABLE or to HEADSET_AVAILABLE and |bluetoothDevice| will be mapped to the
250 * connected device if available.
251 */
252 @SuppressLint("MissingPermission")
253 public void updateDevice() {
254 if (bluetoothState == State.UNINITIALIZED || bluetoothHeadset == null) {
255 return;
256 }
257 Log.d(Config.LOGTAG, "updateDevice");
258 // Get connected devices for the headset profile. Returns the set of
259 // devices which are in state STATE_CONNECTED. The BluetoothDevice class
260 // is just a thin wrapper for a Bluetooth hardware address.
261 final List<BluetoothDevice> devices;
262 if (hasBluetoothConnectPermission()) {
263 devices = bluetoothHeadset.getConnectedDevices();
264 } else {
265 devices = ImmutableList.of();
266 }
267 if (devices.isEmpty()) {
268 bluetoothDevice = null;
269 bluetoothState = State.HEADSET_UNAVAILABLE;
270 Log.d(Config.LOGTAG, "No connected bluetooth headset");
271 } else {
272 // Always use first device in list. Android only supports one device.
273 bluetoothDevice = devices.get(0);
274 bluetoothState = State.HEADSET_AVAILABLE;
275 Log.d(
276 Config.LOGTAG,
277 "Connected bluetooth headset: "
278 + "name="
279 + bluetoothDevice.getName()
280 + ", "
281 + "state="
282 + stateToString(bluetoothHeadset.getConnectionState(bluetoothDevice))
283 + ", SCO audio="
284 + bluetoothHeadset.isAudioConnected(bluetoothDevice));
285 }
286 Log.d(Config.LOGTAG, "updateDevice done: BT state=" + bluetoothState);
287 }
288
289 /** Stubs for test mocks. */
290 @Nullable
291 protected AudioManager getAudioManager(Context context) {
292 return (AudioManager) context.getSystemService(Context.AUDIO_SERVICE);
293 }
294
295 protected void registerReceiver(BroadcastReceiver receiver, IntentFilter filter) {
296 apprtcContext.registerReceiver(receiver, filter);
297 }
298
299 protected void unregisterReceiver(BroadcastReceiver receiver) {
300 apprtcContext.unregisterReceiver(receiver);
301 }
302
303 protected boolean getBluetoothProfileProxy(
304 Context context, BluetoothProfile.ServiceListener listener, int profile) {
305 return bluetoothAdapter.getProfileProxy(context, listener, profile);
306 }
307
308 protected boolean hasBluetoothConnectPermission() {
309 if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
310 return ActivityCompat.checkSelfPermission(
311 apprtcContext, Manifest.permission.BLUETOOTH_CONNECT)
312 == PackageManager.PERMISSION_GRANTED;
313 } else {
314 return true;
315 }
316 }
317
318 /** Ensures that the audio manager updates its list of available audio devices. */
319 private void updateAudioDeviceState() {
320 ThreadUtils.checkIsOnMainThread();
321 Log.d(Config.LOGTAG, "updateAudioDeviceState");
322 apprtcAudioManager.updateAudioDeviceState();
323 }
324
325 /** Starts timer which times out after BLUETOOTH_SCO_TIMEOUT_MS milliseconds. */
326 private void startTimer() {
327 ThreadUtils.checkIsOnMainThread();
328 Log.d(Config.LOGTAG, "startTimer");
329 handler.postDelayed(bluetoothTimeoutRunnable, BLUETOOTH_SCO_TIMEOUT_MS);
330 }
331
332 /** Cancels any outstanding timer tasks. */
333 private void cancelTimer() {
334 ThreadUtils.checkIsOnMainThread();
335 Log.d(Config.LOGTAG, "cancelTimer");
336 handler.removeCallbacks(bluetoothTimeoutRunnable);
337 }
338
339 /**
340 * Called when start of the BT SCO channel takes too long time. Usually happens when the BT
341 * device has been turned on during an ongoing call.
342 */
343 @SuppressLint("MissingPermission")
344 private void bluetoothTimeout() {
345 ThreadUtils.checkIsOnMainThread();
346 if (bluetoothState == State.UNINITIALIZED || bluetoothHeadset == null) {
347 return;
348 }
349 Log.d(
350 Config.LOGTAG,
351 "bluetoothTimeout: BT state="
352 + bluetoothState
353 + ", "
354 + "attempts: "
355 + scoConnectionAttempts
356 + ", "
357 + "SCO is on: "
358 + isScoOn());
359 if (bluetoothState != State.SCO_CONNECTING) {
360 return;
361 }
362 // Bluetooth SCO should be connecting; check the latest result.
363 boolean scoConnected = false;
364 final List<BluetoothDevice> devices;
365 if (hasBluetoothConnectPermission()) {
366 devices = bluetoothHeadset.getConnectedDevices();
367 } else {
368 devices = Collections.emptyList();
369 }
370 if (devices.size() > 0) {
371 bluetoothDevice = devices.get(0);
372 if (bluetoothHeadset.isAudioConnected(bluetoothDevice)) {
373 Log.d(Config.LOGTAG, "SCO connected with " + bluetoothDevice.getName());
374 scoConnected = true;
375 } else {
376 Log.d(Config.LOGTAG, "SCO is not connected with " + bluetoothDevice.getName());
377 }
378 }
379 if (scoConnected) {
380 // We thought BT had timed out, but it's actually on; updating state.
381 bluetoothState = State.SCO_CONNECTED;
382 scoConnectionAttempts = 0;
383 } else {
384 // Give up and "cancel" our request by calling stopBluetoothSco().
385 Log.w(Config.LOGTAG, "BT failed to connect after timeout");
386 stopScoAudio();
387 }
388 updateAudioDeviceState();
389 Log.d(Config.LOGTAG, "bluetoothTimeout done: BT state=" + bluetoothState);
390 }
391
392 /** Checks whether audio uses Bluetooth SCO. */
393 private boolean isScoOn() {
394 return audioManager.isBluetoothScoOn();
395 }
396
397 /** Converts BluetoothAdapter states into local string representations. */
398 private String stateToString(int state) {
399 switch (state) {
400 case BluetoothAdapter.STATE_DISCONNECTED:
401 return "DISCONNECTED";
402 case BluetoothAdapter.STATE_CONNECTED:
403 return "CONNECTED";
404 case BluetoothAdapter.STATE_CONNECTING:
405 return "CONNECTING";
406 case BluetoothAdapter.STATE_DISCONNECTING:
407 return "DISCONNECTING";
408 case BluetoothAdapter.STATE_OFF:
409 return "OFF";
410 case BluetoothAdapter.STATE_ON:
411 return "ON";
412 case BluetoothAdapter.STATE_TURNING_OFF:
413 // Indicates the local Bluetooth adapter is turning off. Local clients should
414 // immediately
415 // attempt graceful disconnection of any remote links.
416 return "TURNING_OFF";
417 case BluetoothAdapter.STATE_TURNING_ON:
418 // Indicates the local Bluetooth adapter is turning on. However local clients should
419 // wait
420 // for STATE_ON before attempting to use the adapter.
421 return "TURNING_ON";
422 default:
423 return "INVALID";
424 }
425 }
426
427 // Bluetooth connection state.
428 public enum State {
429 // Bluetooth is not available; no adapter or Bluetooth is off.
430 UNINITIALIZED,
431 // Bluetooth error happened when trying to start Bluetooth.
432 ERROR,
433 // Bluetooth proxy object for the Headset profile exists, but no connected headset devices,
434 // SCO is not started or disconnected.
435 HEADSET_UNAVAILABLE,
436 // Bluetooth proxy object for the Headset profile connected, connected Bluetooth headset
437 // present, but SCO is not started or disconnected.
438 HEADSET_AVAILABLE,
439 // Bluetooth audio SCO connection with remote device is closing.
440 SCO_DISCONNECTING,
441 // Bluetooth audio SCO connection with remote device is initiated.
442 SCO_CONNECTING,
443 // Bluetooth audio SCO connection with remote device is established.
444 SCO_CONNECTED
445 }
446
447 /**
448 * Implementation of an interface that notifies BluetoothProfile IPC clients when they have been
449 * connected to or disconnected from the service.
450 */
451 private class BluetoothServiceListener implements BluetoothProfile.ServiceListener {
452 @Override
453 // Called to notify the client when the proxy object has been connected to the service.
454 // Once we have the profile proxy object, we can use it to monitor the state of the
455 // connection and perform other operations that are relevant to the headset profile.
456 public void onServiceConnected(int profile, BluetoothProfile proxy) {
457 if (profile != BluetoothProfile.HEADSET || bluetoothState == State.UNINITIALIZED) {
458 return;
459 }
460 Log.d(
461 Config.LOGTAG,
462 "BluetoothServiceListener.onServiceConnected: BT state=" + bluetoothState);
463 // Android only supports one connected Bluetooth Headset at a time.
464 bluetoothHeadset = (BluetoothHeadset) proxy;
465 updateAudioDeviceState();
466 Log.d(Config.LOGTAG, "onServiceConnected done: BT state=" + bluetoothState);
467 }
468
469 @Override
470 /** Notifies the client when the proxy object has been disconnected from the service. */
471 public void onServiceDisconnected(int profile) {
472 if (profile != BluetoothProfile.HEADSET || bluetoothState == State.UNINITIALIZED) {
473 return;
474 }
475 Log.d(
476 Config.LOGTAG,
477 "BluetoothServiceListener.onServiceDisconnected: BT state=" + bluetoothState);
478 stopScoAudio();
479 bluetoothHeadset = null;
480 bluetoothDevice = null;
481 bluetoothState = State.HEADSET_UNAVAILABLE;
482 updateAudioDeviceState();
483 Log.d(Config.LOGTAG, "onServiceDisconnected done: BT state=" + bluetoothState);
484 }
485 }
486
487 // Intent broadcast receiver which handles changes in Bluetooth device availability.
488 // Detects headset changes and Bluetooth SCO state changes.
489 private class BluetoothHeadsetBroadcastReceiver extends BroadcastReceiver {
490 @Override
491 public void onReceive(Context context, Intent intent) {
492 if (bluetoothState == State.UNINITIALIZED) {
493 return;
494 }
495 final String action = intent.getAction();
496 // Change in connection state of the Headset profile. Note that the
497 // change does not tell us anything about whether we're streaming
498 // audio to BT over SCO. Typically received when user turns on a BT
499 // headset while audio is active using another audio device.
500 if (action.equals(BluetoothHeadset.ACTION_CONNECTION_STATE_CHANGED)) {
501 final int state =
502 intent.getIntExtra(
503 BluetoothHeadset.EXTRA_STATE, BluetoothHeadset.STATE_DISCONNECTED);
504 Log.d(
505 Config.LOGTAG,
506 "BluetoothHeadsetBroadcastReceiver.onReceive: "
507 + "a=ACTION_CONNECTION_STATE_CHANGED, "
508 + "s="
509 + stateToString(state)
510 + ", "
511 + "sb="
512 + isInitialStickyBroadcast()
513 + ", "
514 + "BT state: "
515 + bluetoothState);
516 if (state == BluetoothHeadset.STATE_CONNECTED) {
517 scoConnectionAttempts = 0;
518 updateAudioDeviceState();
519 } else if (state == BluetoothHeadset.STATE_CONNECTING) {
520 // No action needed.
521 } else if (state == BluetoothHeadset.STATE_DISCONNECTING) {
522 // No action needed.
523 } else if (state == BluetoothHeadset.STATE_DISCONNECTED) {
524 // Bluetooth is probably powered off during the call.
525 stopScoAudio();
526 updateAudioDeviceState();
527 }
528 // Change in the audio (SCO) connection state of the Headset profile.
529 // Typically received after call to startScoAudio() has finalized.
530 } else if (action.equals(BluetoothHeadset.ACTION_AUDIO_STATE_CHANGED)) {
531 final int state =
532 intent.getIntExtra(
533 BluetoothHeadset.EXTRA_STATE,
534 BluetoothHeadset.STATE_AUDIO_DISCONNECTED);
535 Log.d(
536 Config.LOGTAG,
537 "BluetoothHeadsetBroadcastReceiver.onReceive: "
538 + "a=ACTION_AUDIO_STATE_CHANGED, "
539 + "s="
540 + stateToString(state)
541 + ", "
542 + "sb="
543 + isInitialStickyBroadcast()
544 + ", "
545 + "BT state: "
546 + bluetoothState);
547 if (state == BluetoothHeadset.STATE_AUDIO_CONNECTED) {
548 cancelTimer();
549 if (bluetoothState == State.SCO_CONNECTING) {
550 Log.d(Config.LOGTAG, "+++ Bluetooth audio SCO is now connected");
551 bluetoothState = State.SCO_CONNECTED;
552 scoConnectionAttempts = 0;
553 updateAudioDeviceState();
554 } else {
555 Log.w(
556 Config.LOGTAG,
557 "Unexpected state BluetoothHeadset.STATE_AUDIO_CONNECTED");
558 }
559 } else if (state == BluetoothHeadset.STATE_AUDIO_CONNECTING) {
560 Log.d(Config.LOGTAG, "+++ Bluetooth audio SCO is now connecting...");
561 } else if (state == BluetoothHeadset.STATE_AUDIO_DISCONNECTED) {
562 Log.d(Config.LOGTAG, "+++ Bluetooth audio SCO is now disconnected");
563 if (isInitialStickyBroadcast()) {
564 Log.d(
565 Config.LOGTAG,
566 "Ignore STATE_AUDIO_DISCONNECTED initial sticky broadcast.");
567 return;
568 }
569 updateAudioDeviceState();
570 }
571 }
572 Log.d(Config.LOGTAG, "onReceive done: BT state=" + bluetoothState);
573 }
574 }
575}