diff --git a/src/main/AndroidManifest.xml b/src/main/AndroidManifest.xml index 2a48da7a0..8cb375870 100644 --- a/src/main/AndroidManifest.xml +++ b/src/main/AndroidManifest.xml @@ -2,6 +2,7 @@ + diff --git a/src/main/java/eu/siacs/conversations/services/AppRTCBluetoothManager.java b/src/main/java/eu/siacs/conversations/services/AppRTCBluetoothManager.java index 862cdf0c7..484072605 100644 --- a/src/main/java/eu/siacs/conversations/services/AppRTCBluetoothManager.java +++ b/src/main/java/eu/siacs/conversations/services/AppRTCBluetoothManager.java @@ -9,6 +9,7 @@ */ package eu.siacs.conversations.services; +import android.Manifest; import android.annotation.SuppressLint; import android.bluetooth.BluetoothAdapter; import android.bluetooth.BluetoothDevice; @@ -20,25 +21,25 @@ import android.content.Intent; import android.content.IntentFilter; import android.content.pm.PackageManager; import android.media.AudioManager; +import android.os.Build; import android.os.Handler; import android.os.Looper; -import android.os.Process; import android.util.Log; import androidx.annotation.Nullable; +import androidx.core.app.ActivityCompat; + +import com.google.common.collect.ImmutableList; import org.webrtc.ThreadUtils; +import java.util.Collections; import java.util.List; -import java.util.Set; import eu.siacs.conversations.Config; import eu.siacs.conversations.utils.AppRTCUtils; -/** - * AppRTCProximitySensor manages functions related to Bluetoth devices in the - * AppRTC demo. - */ +/** AppRTCProximitySensor manages functions related to Bluetoth devices in the AppRTC demo. */ public class AppRTCBluetoothManager { // Timeout interval for starting or stopping audio to a Bluetooth SCO device. private static final int BLUETOOTH_SCO_TIMEOUT_MS = 4000; @@ -46,28 +47,26 @@ public class AppRTCBluetoothManager { private static final int MAX_SCO_CONNECTION_ATTEMPTS = 2; private final Context apprtcContext; private final AppRTCAudioManager apprtcAudioManager; - @Nullable - private final AudioManager audioManager; + @Nullable private final AudioManager audioManager; private final Handler handler; private final BluetoothProfile.ServiceListener bluetoothServiceListener; private final BroadcastReceiver bluetoothHeadsetReceiver; int scoConnectionAttempts; private State bluetoothState; - @Nullable - private BluetoothAdapter bluetoothAdapter; - @Nullable - private BluetoothHeadset bluetoothHeadset; - @Nullable - private BluetoothDevice bluetoothDevice; + @Nullable private BluetoothAdapter bluetoothAdapter; + @Nullable private BluetoothHeadset bluetoothHeadset; + @Nullable private BluetoothDevice bluetoothDevice; // Runs when the Bluetooth timeout expires. We use that timeout after calling // startScoAudio() or stopScoAudio() because we're not guaranteed to get a // callback after those calls. - private final Runnable bluetoothTimeoutRunnable = new Runnable() { - @Override - public void run() { - bluetoothTimeout(); - } - }; + private final Runnable bluetoothTimeoutRunnable = + new Runnable() { + @Override + public void run() { + bluetoothTimeout(); + } + }; + protected AppRTCBluetoothManager(Context context, AppRTCAudioManager audioManager) { Log.d(Config.LOGTAG, "ctor"); ThreadUtils.checkIsOnMainThread(); @@ -80,42 +79,29 @@ public class AppRTCBluetoothManager { handler = new Handler(Looper.getMainLooper()); } - /** - * Construction. - */ + /** Construction. */ static AppRTCBluetoothManager create(Context context, AppRTCAudioManager audioManager) { Log.d(Config.LOGTAG, "create" + AppRTCUtils.getThreadInfo()); return new AppRTCBluetoothManager(context, audioManager); } - /** - * Returns the internal state. - */ + /** Returns the internal state. */ public State getState() { ThreadUtils.checkIsOnMainThread(); return bluetoothState; } /** - * Activates components required to detect Bluetooth devices and to enable - * BT SCO (audio is routed via BT SCO) for the headset profile. The end - * state will be HEADSET_UNAVAILABLE but a state machine has started which - * will start a state change sequence where the final outcome depends on - * if/when the BT headset is enabled. - * Example of state change sequence when start() is called while BT device - * is connected and enabled: - * UNINITIALIZED --> HEADSET_UNAVAILABLE --> HEADSET_AVAILABLE --> - * SCO_CONNECTING --> SCO_CONNECTED <==> audio is now routed via BT SCO. - * Note that the AppRTCAudioManager is also involved in driving this state - * change. + * Activates components required to detect Bluetooth devices and to enable BT SCO (audio is + * routed via BT SCO) for the headset profile. The end state will be HEADSET_UNAVAILABLE but a + * state machine has started which will start a state change sequence where the final outcome + * depends on if/when the BT headset is enabled. Example of state change sequence when start() + * is called while BT device is connected and enabled: UNINITIALIZED --> HEADSET_UNAVAILABLE --> + * HEADSET_AVAILABLE --> SCO_CONNECTING --> SCO_CONNECTED <==> audio is now routed via BT SCO. + * Note that the AppRTCAudioManager is also involved in driving this state change. */ public void start() { ThreadUtils.checkIsOnMainThread(); - Log.d(Config.LOGTAG, "start"); - if (!hasPermission(apprtcContext, android.Manifest.permission.BLUETOOTH)) { - Log.w(Config.LOGTAG, "Process (pid=" + Process.myPid() + ") lacks BLUETOOTH permission"); - return; - } if (bluetoothState != State.UNINITIALIZED) { Log.w(Config.LOGTAG, "Invalid BT state"); return; @@ -130,11 +116,10 @@ public class AppRTCBluetoothManager { return; } // Ensure that the device supports use of BT SCO audio for off call use cases. - if (!audioManager.isBluetoothScoAvailableOffCall()) { + if (this.audioManager == null || !audioManager.isBluetoothScoAvailableOffCall()) { Log.e(Config.LOGTAG, "Bluetooth SCO audio is not available off call"); return; } - logBluetoothAdapterInfo(bluetoothAdapter); // Establish a connection to the HEADSET profile (includes both Bluetooth Headset and // Hands-Free) proxy object and install a listener. if (!getBluetoothProfileProxy( @@ -149,16 +134,20 @@ public class AppRTCBluetoothManager { // Register receiver for change in audio connection state of the Headset profile. bluetoothHeadsetFilter.addAction(BluetoothHeadset.ACTION_AUDIO_STATE_CHANGED); registerReceiver(bluetoothHeadsetReceiver, bluetoothHeadsetFilter); - Log.d(Config.LOGTAG, "HEADSET profile state: " - + stateToString(bluetoothAdapter.getProfileConnectionState(BluetoothProfile.HEADSET))); + if (hasBluetoothConnectPermission()) { + Log.d( + Config.LOGTAG, + "HEADSET profile state: " + + stateToString( + bluetoothAdapter.getProfileConnectionState( + BluetoothProfile.HEADSET))); + } Log.d(Config.LOGTAG, "Bluetooth proxy for headset profile has started"); bluetoothState = State.HEADSET_UNAVAILABLE; Log.d(Config.LOGTAG, "start done: BT state=" + bluetoothState); } - /** - * Stops and closes all components related to Bluetooth audio. - */ + /** Stops and closes all components related to Bluetooth audio. */ public void stop() { ThreadUtils.checkIsOnMainThread(); Log.d(Config.LOGTAG, "stop: BT state=" + bluetoothState); @@ -184,23 +173,29 @@ public class AppRTCBluetoothManager { } /** - * Starts Bluetooth SCO connection with remote device. - * Note that the phone application always has the priority on the usage of the SCO connection - * for telephony. If this method is called while the phone is in call it will be ignored. - * Similarly, if a call is received or sent while an application is using the SCO connection, - * the connection will be lost for the application and NOT returned automatically when the call - * ends. Also note that: up to and including API version JELLY_BEAN_MR1, this method initiates a - * virtual voice call to the Bluetooth headset. After API version JELLY_BEAN_MR2 only a raw SCO - * audio connection is established. + * Starts Bluetooth SCO connection with remote device. Note that the phone application always + * has the priority on the usage of the SCO connection for telephony. If this method is called + * while the phone is in call it will be ignored. Similarly, if a call is received or sent while + * an application is using the SCO connection, the connection will be lost for the application + * and NOT returned automatically when the call ends. Also note that: up to and including API + * version JELLY_BEAN_MR1, this method initiates a virtual voice call to the Bluetooth headset. + * After API version JELLY_BEAN_MR2 only a raw SCO audio connection is established. * TODO(henrika): should we add support for virtual voice call to BT headset also for JBMR2 and * higher. It might be required to initiates a virtual voice call since many devices do not * accept SCO audio without a "call". */ public boolean startScoAudio() { ThreadUtils.checkIsOnMainThread(); - Log.d(Config.LOGTAG, "startSco: BT state=" + bluetoothState + ", " - + "attempts: " + scoConnectionAttempts + ", " - + "SCO is on: " + isScoOn()); + Log.d( + Config.LOGTAG, + "startSco: BT state=" + + bluetoothState + + ", " + + "attempts: " + + scoConnectionAttempts + + ", " + + "SCO is on: " + + isScoOn()); if (scoConnectionAttempts >= MAX_SCO_CONNECTION_ATTEMPTS) { Log.e(Config.LOGTAG, "BT SCO connection fails - no more attempts"); return false; @@ -213,24 +208,29 @@ public class AppRTCBluetoothManager { Log.d(Config.LOGTAG, "Starting Bluetooth SCO and waits for ACTION_AUDIO_STATE_CHANGED..."); // The SCO connection establishment can take several seconds, hence we cannot rely on the // connection to be available when the method returns but instead register to receive the - // intent ACTION_SCO_AUDIO_STATE_UPDATED and wait for the state to be SCO_AUDIO_STATE_CONNECTED. + // intent ACTION_SCO_AUDIO_STATE_UPDATED and wait for the state to be + // SCO_AUDIO_STATE_CONNECTED. bluetoothState = State.SCO_CONNECTING; audioManager.startBluetoothSco(); audioManager.setBluetoothScoOn(true); scoConnectionAttempts++; startTimer(); - Log.d(Config.LOGTAG, "startScoAudio done: BT state=" + bluetoothState + ", " - + "SCO is on: " + isScoOn()); + Log.d( + Config.LOGTAG, + "startScoAudio done: BT state=" + + bluetoothState + + ", " + + "SCO is on: " + + isScoOn()); return true; } - /** - * Stops Bluetooth SCO connection with remote device. - */ + /** Stops Bluetooth SCO connection with remote device. */ public void stopScoAudio() { ThreadUtils.checkIsOnMainThread(); - Log.d(Config.LOGTAG, "stopScoAudio: BT state=" + bluetoothState + ", " - + "SCO is on: " + isScoOn()); + Log.d( + Config.LOGTAG, + "stopScoAudio: BT state=" + bluetoothState + ", " + "SCO is on: " + isScoOn()); if (bluetoothState != State.SCO_CONNECTING && bluetoothState != State.SCO_CONNECTED) { return; } @@ -238,17 +238,18 @@ public class AppRTCBluetoothManager { audioManager.stopBluetoothSco(); audioManager.setBluetoothScoOn(false); bluetoothState = State.SCO_DISCONNECTING; - Log.d(Config.LOGTAG, "stopScoAudio done: BT state=" + bluetoothState + ", " - + "SCO is on: " + isScoOn()); + Log.d( + Config.LOGTAG, + "stopScoAudio done: BT state=" + bluetoothState + ", " + "SCO is on: " + isScoOn()); } /** - * Use the BluetoothHeadset proxy object (controls the Bluetooth Headset - * Service via IPC) to update the list of connected devices for the HEADSET - * profile. The internal state will change to HEADSET_UNAVAILABLE or to - * HEADSET_AVAILABLE and |bluetoothDevice| will be mapped to the connected - * device if available. + * Use the BluetoothHeadset proxy object (controls the Bluetooth Headset Service via IPC) to + * update the list of connected devices for the HEADSET profile. The internal state will change + * to HEADSET_UNAVAILABLE or to HEADSET_AVAILABLE and |bluetoothDevice| will be mapped to the + * connected device if available. */ + @SuppressLint("MissingPermission") public void updateDevice() { if (bluetoothState == State.UNINITIALIZED || bluetoothHeadset == null) { return; @@ -257,7 +258,12 @@ public class AppRTCBluetoothManager { // Get connected devices for the headset profile. Returns the set of // devices which are in state STATE_CONNECTED. The BluetoothDevice class // is just a thin wrapper for a Bluetooth hardware address. - List devices = bluetoothHeadset.getConnectedDevices(); + final List devices; + if (hasBluetoothConnectPermission()) { + devices = bluetoothHeadset.getConnectedDevices(); + } else { + devices = ImmutableList.of(); + } if (devices.isEmpty()) { bluetoothDevice = null; bluetoothState = State.HEADSET_UNAVAILABLE; @@ -266,17 +272,21 @@ public class AppRTCBluetoothManager { // Always use first device in list. Android only supports one device. bluetoothDevice = devices.get(0); bluetoothState = State.HEADSET_AVAILABLE; - Log.d(Config.LOGTAG, "Connected bluetooth headset: " - + "name=" + bluetoothDevice.getName() + ", " - + "state=" + stateToString(bluetoothHeadset.getConnectionState(bluetoothDevice)) - + ", SCO audio=" + bluetoothHeadset.isAudioConnected(bluetoothDevice)); + Log.d( + Config.LOGTAG, + "Connected bluetooth headset: " + + "name=" + + bluetoothDevice.getName() + + ", " + + "state=" + + stateToString(bluetoothHeadset.getConnectionState(bluetoothDevice)) + + ", SCO audio=" + + bluetoothHeadset.isAudioConnected(bluetoothDevice)); } Log.d(Config.LOGTAG, "updateDevice done: BT state=" + bluetoothState); } - /** - * Stubs for test mocks. - */ + /** Stubs for test mocks. */ @Nullable protected AudioManager getAudioManager(Context context) { return (AudioManager) context.getSystemService(Context.AUDIO_SERVICE); @@ -295,52 +305,31 @@ public class AppRTCBluetoothManager { return bluetoothAdapter.getProfileProxy(context, listener, profile); } - protected boolean hasPermission(Context context, String permission) { - return apprtcContext.checkPermission(permission, Process.myPid(), Process.myUid()) - == PackageManager.PERMISSION_GRANTED; - } - - /** - * Logs the state of the local Bluetooth adapter. - */ - @SuppressLint("HardwareIds") - protected void logBluetoothAdapterInfo(BluetoothAdapter localAdapter) { - Log.d(Config.LOGTAG, "BluetoothAdapter: " - + "enabled=" + localAdapter.isEnabled() + ", " - + "state=" + stateToString(localAdapter.getState()) + ", " - + "name=" + localAdapter.getName() + ", " - + "address=" + localAdapter.getAddress()); - // Log the set of BluetoothDevice objects that are bonded (paired) to the local adapter. - Set pairedDevices = localAdapter.getBondedDevices(); - if (!pairedDevices.isEmpty()) { - Log.d(Config.LOGTAG, "paired devices:"); - for (BluetoothDevice device : pairedDevices) { - Log.d(Config.LOGTAG, " name=" + device.getName() + ", address=" + device.getAddress()); - } + protected boolean hasBluetoothConnectPermission() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + return ActivityCompat.checkSelfPermission( + apprtcContext, Manifest.permission.BLUETOOTH_CONNECT) + == PackageManager.PERMISSION_GRANTED; + } else { + return true; } } - /** - * Ensures that the audio manager updates its list of available audio devices. - */ + /** Ensures that the audio manager updates its list of available audio devices. */ private void updateAudioDeviceState() { ThreadUtils.checkIsOnMainThread(); Log.d(Config.LOGTAG, "updateAudioDeviceState"); apprtcAudioManager.updateAudioDeviceState(); } - /** - * Starts timer which times out after BLUETOOTH_SCO_TIMEOUT_MS milliseconds. - */ + /** Starts timer which times out after BLUETOOTH_SCO_TIMEOUT_MS milliseconds. */ private void startTimer() { ThreadUtils.checkIsOnMainThread(); Log.d(Config.LOGTAG, "startTimer"); handler.postDelayed(bluetoothTimeoutRunnable, BLUETOOTH_SCO_TIMEOUT_MS); } - /** - * Cancels any outstanding timer tasks. - */ + /** Cancels any outstanding timer tasks. */ private void cancelTimer() { ThreadUtils.checkIsOnMainThread(); Log.d(Config.LOGTAG, "cancelTimer"); @@ -348,23 +337,36 @@ public class AppRTCBluetoothManager { } /** - * Called when start of the BT SCO channel takes too long time. Usually - * happens when the BT device has been turned on during an ongoing call. + * Called when start of the BT SCO channel takes too long time. Usually happens when the BT + * device has been turned on during an ongoing call. */ + @SuppressLint("MissingPermission") private void bluetoothTimeout() { ThreadUtils.checkIsOnMainThread(); if (bluetoothState == State.UNINITIALIZED || bluetoothHeadset == null) { return; } - Log.d(Config.LOGTAG, "bluetoothTimeout: BT state=" + bluetoothState + ", " - + "attempts: " + scoConnectionAttempts + ", " - + "SCO is on: " + isScoOn()); + Log.d( + Config.LOGTAG, + "bluetoothTimeout: BT state=" + + bluetoothState + + ", " + + "attempts: " + + scoConnectionAttempts + + ", " + + "SCO is on: " + + isScoOn()); if (bluetoothState != State.SCO_CONNECTING) { return; } // Bluetooth SCO should be connecting; check the latest result. boolean scoConnected = false; - List devices = bluetoothHeadset.getConnectedDevices(); + final List devices; + if (hasBluetoothConnectPermission()) { + devices = bluetoothHeadset.getConnectedDevices(); + } else { + devices = Collections.emptyList(); + } if (devices.size() > 0) { bluetoothDevice = devices.get(0); if (bluetoothHeadset.isAudioConnected(bluetoothDevice)) { @@ -387,16 +389,12 @@ public class AppRTCBluetoothManager { Log.d(Config.LOGTAG, "bluetoothTimeout done: BT state=" + bluetoothState); } - /** - * Checks whether audio uses Bluetooth SCO. - */ + /** Checks whether audio uses Bluetooth SCO. */ private boolean isScoOn() { return audioManager.isBluetoothScoOn(); } - /** - * Converts BluetoothAdapter states into local string representations. - */ + /** Converts BluetoothAdapter states into local string representations. */ private String stateToString(int state) { switch (state) { case BluetoothAdapter.STATE_DISCONNECTED: @@ -412,11 +410,13 @@ public class AppRTCBluetoothManager { case BluetoothAdapter.STATE_ON: return "ON"; case BluetoothAdapter.STATE_TURNING_OFF: - // Indicates the local Bluetooth adapter is turning off. Local clients should immediately + // Indicates the local Bluetooth adapter is turning off. Local clients should + // immediately // attempt graceful disconnection of any remote links. return "TURNING_OFF"; case BluetoothAdapter.STATE_TURNING_ON: - // Indicates the local Bluetooth adapter is turning on. However local clients should wait + // Indicates the local Bluetooth adapter is turning on. However local clients should + // wait // for STATE_ON before attempting to use the adapter. return "TURNING_ON"; default: @@ -457,7 +457,9 @@ public class AppRTCBluetoothManager { if (profile != BluetoothProfile.HEADSET || bluetoothState == State.UNINITIALIZED) { return; } - Log.d(Config.LOGTAG, "BluetoothServiceListener.onServiceConnected: BT state=" + bluetoothState); + Log.d( + Config.LOGTAG, + "BluetoothServiceListener.onServiceConnected: BT state=" + bluetoothState); // Android only supports one connected Bluetooth Headset at a time. bluetoothHeadset = (BluetoothHeadset) proxy; updateAudioDeviceState(); @@ -470,7 +472,9 @@ public class AppRTCBluetoothManager { if (profile != BluetoothProfile.HEADSET || bluetoothState == State.UNINITIALIZED) { return; } - Log.d(Config.LOGTAG, "BluetoothServiceListener.onServiceDisconnected: BT state=" + bluetoothState); + Log.d( + Config.LOGTAG, + "BluetoothServiceListener.onServiceDisconnected: BT state=" + bluetoothState); stopScoAudio(); bluetoothHeadset = null; bluetoothDevice = null; @@ -495,12 +499,20 @@ public class AppRTCBluetoothManager { // headset while audio is active using another audio device. if (action.equals(BluetoothHeadset.ACTION_CONNECTION_STATE_CHANGED)) { final int state = - intent.getIntExtra(BluetoothHeadset.EXTRA_STATE, BluetoothHeadset.STATE_DISCONNECTED); - Log.d(Config.LOGTAG, "BluetoothHeadsetBroadcastReceiver.onReceive: " - + "a=ACTION_CONNECTION_STATE_CHANGED, " - + "s=" + stateToString(state) + ", " - + "sb=" + isInitialStickyBroadcast() + ", " - + "BT state: " + bluetoothState); + intent.getIntExtra( + BluetoothHeadset.EXTRA_STATE, BluetoothHeadset.STATE_DISCONNECTED); + Log.d( + Config.LOGTAG, + "BluetoothHeadsetBroadcastReceiver.onReceive: " + + "a=ACTION_CONNECTION_STATE_CHANGED, " + + "s=" + + stateToString(state) + + ", " + + "sb=" + + isInitialStickyBroadcast() + + ", " + + "BT state: " + + bluetoothState); if (state == BluetoothHeadset.STATE_CONNECTED) { scoConnectionAttempts = 0; updateAudioDeviceState(); @@ -516,13 +528,22 @@ public class AppRTCBluetoothManager { // Change in the audio (SCO) connection state of the Headset profile. // Typically received after call to startScoAudio() has finalized. } else if (action.equals(BluetoothHeadset.ACTION_AUDIO_STATE_CHANGED)) { - final int state = intent.getIntExtra( - BluetoothHeadset.EXTRA_STATE, BluetoothHeadset.STATE_AUDIO_DISCONNECTED); - Log.d(Config.LOGTAG, "BluetoothHeadsetBroadcastReceiver.onReceive: " - + "a=ACTION_AUDIO_STATE_CHANGED, " - + "s=" + stateToString(state) + ", " - + "sb=" + isInitialStickyBroadcast() + ", " - + "BT state: " + bluetoothState); + final int state = + intent.getIntExtra( + BluetoothHeadset.EXTRA_STATE, + BluetoothHeadset.STATE_AUDIO_DISCONNECTED); + Log.d( + Config.LOGTAG, + "BluetoothHeadsetBroadcastReceiver.onReceive: " + + "a=ACTION_AUDIO_STATE_CHANGED, " + + "s=" + + stateToString(state) + + ", " + + "sb=" + + isInitialStickyBroadcast() + + ", " + + "BT state: " + + bluetoothState); if (state == BluetoothHeadset.STATE_AUDIO_CONNECTED) { cancelTimer(); if (bluetoothState == State.SCO_CONNECTING) { @@ -531,14 +552,18 @@ public class AppRTCBluetoothManager { scoConnectionAttempts = 0; updateAudioDeviceState(); } else { - Log.w(Config.LOGTAG, "Unexpected state BluetoothHeadset.STATE_AUDIO_CONNECTED"); + Log.w( + Config.LOGTAG, + "Unexpected state BluetoothHeadset.STATE_AUDIO_CONNECTED"); } } else if (state == BluetoothHeadset.STATE_AUDIO_CONNECTING) { Log.d(Config.LOGTAG, "+++ Bluetooth audio SCO is now connecting..."); } else if (state == BluetoothHeadset.STATE_AUDIO_DISCONNECTED) { Log.d(Config.LOGTAG, "+++ Bluetooth audio SCO is now disconnected"); if (isInitialStickyBroadcast()) { - Log.d(Config.LOGTAG, "Ignore STATE_AUDIO_DISCONNECTED initial sticky broadcast."); + Log.d( + Config.LOGTAG, + "Ignore STATE_AUDIO_DISCONNECTED initial sticky broadcast."); return; } updateAudioDeviceState(); @@ -547,4 +572,4 @@ public class AppRTCBluetoothManager { Log.d(Config.LOGTAG, "onReceive done: BT state=" + bluetoothState); } } -} \ No newline at end of file +}