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