Skip to content

Commit cd8794a

Browse files
authored
VBLOCKS-2802 Bluetooth permissions (#162)
* Remove bluetooth permissions * Update tests * Add supresslint * Update to use PermissionCheckStrategy, update unit tests * Add bluetooth permission in test manifest * TestUtil add missing headsetManager * Update README.md
1 parent d43c7b3 commit cd8794a

File tree

12 files changed

+112
-114
lines changed

12 files changed

+112
-114
lines changed

Diff for: CHANGELOG.md

+3-7
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,13 @@
11
# Changelog
2-
### 1.1.10 (In Progress)
2+
### 1.1.10 (In progress)
33

44
Enhancements
55

66
- Updated gradle version to 8.4
77
- Updated gradle plugin to 8.3.1
8-
9-
10-
### 1.1.10 (March 21, 2024)
11-
12-
Enhancements
13-
148
- BluetoothHeadsetConnectionListener now can be added to AudioSwitch to notify when bluetooth device has connected or failed to connect.
9+
- BLUETOOTH_CONNECT and/or BLUETOOTH permission have been removed and are optional now. If not provided bluetooth device
10+
will not appear in the list of available devices and no callbacks will be received for BluetoothHeadsetConnectionListener.
1511

1612
### 1.1.9 (July 13, 2023)
1713

Diff for: README.md

+1
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,7 @@ audioSwitch.deactivate()
108108
## Bluetooth Support
109109

110110
Multiple connected bluetooth headsets are supported.
111+
- Bluetooth support requires BLUETOOTH_CONNECT or BLUETOOTH permission. These permission have to be added to the application using AudioSwitch, they do not come with the library.
111112
- The library will accurately display the up to date active bluetooth headset within the `AudioSwitch` `availableAudioDevices` and `selectedAudioDevice` functions.
112113
- Other connected headsets are not stored by the library at this moment.
113114
- In the event of a failure to connecting audio to a bluetooth headset, the library will revert the selected audio device (this is usually the Earpiece on a phone).

Diff for: audioswitch/src/androidTest/AndroidManifest.xml

+4-1
Original file line numberDiff line numberDiff line change
@@ -6,4 +6,7 @@
66
<uses-permission android:name="android.permission.BLUETOOTH_SCAN"
77
android:usesPermissionFlags="neverForLocation" />
88
<uses-permission android:name="android.permission.BLUETOOTH_ADVERTISE" />
9-
</manifest>
9+
<uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30"/>
10+
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
11+
12+
</manifest>

Diff for: audioswitch/src/androidTest/java/com.twilio.audioswitch/TestUtil.kt

+1-1
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ internal fun setupFakeAudioSwitch(
6464
preferredDevicesList,
6565
audioDeviceManager,
6666
wiredHeadsetReceiver,
67-
headsetManager,
67+
bluetoothHeadsetManager = headsetManager,
6868
),
6969
headsetManager!!,
7070
wiredHeadsetReceiver,

Diff for: audioswitch/src/main/AndroidManifest.xml

-2
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,5 @@
11
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
22

3-
<uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30"/>
4-
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
53
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
64

75
</manifest>

Diff for: audioswitch/src/main/java/com/twilio/audioswitch/AudioSwitch.kt

+41-3
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
package com.twilio.audioswitch
22

3+
import android.Manifest
4+
import android.annotation.SuppressLint
35
import android.bluetooth.BluetoothAdapter
46
import android.content.Context
7+
import android.content.pm.PackageManager.PERMISSION_GRANTED
58
import android.media.AudioManager
69
import android.media.AudioManager.OnAudioFocusChangeListener
710
import androidx.annotation.VisibleForTesting
@@ -13,21 +16,23 @@ import com.twilio.audioswitch.AudioSwitch.State.ACTIVATED
1316
import com.twilio.audioswitch.AudioSwitch.State.STARTED
1417
import com.twilio.audioswitch.AudioSwitch.State.STOPPED
1518
import com.twilio.audioswitch.android.Logger
19+
import com.twilio.audioswitch.android.PermissionsCheckStrategy
1620
import com.twilio.audioswitch.android.ProductionLogger
1721
import com.twilio.audioswitch.bluetooth.BluetoothHeadsetConnectionListener
1822
import com.twilio.audioswitch.bluetooth.BluetoothHeadsetManager
1923
import com.twilio.audioswitch.wired.WiredDeviceConnectionListener
2024
import com.twilio.audioswitch.wired.WiredHeadsetReceiver
2125

2226
private const val TAG = "AudioSwitch"
27+
private const val PERMISSION_ERROR_MESSAGE = "Bluetooth unsupported, permissions not granted"
2328

2429
/**
2530
* This class enables developers to enumerate available audio devices and select which device audio
2631
* should be routed to. It is strongly recommended that instances of this class are created and
2732
* accessed from a single application thread. Accessing an instance from multiple threads may cause
2833
* synchronization problems.
2934
*
30-
* @property bluetoothHeadsetConnectionListener Listener to notify if Bluetooth device state has
35+
* @property bluetoothHeadsetConnectionListener Requires bluetooth permission. Listener to notify if Bluetooth device state has
3136
* changed (connect, disconnect, audio connect, audio disconnect) or failed to connect. Null by default.
3237
* @property loggingEnabled A property to configure AudioSwitch logging behavior. AudioSwitch logging is disabled by
3338
* default.
@@ -47,6 +52,7 @@ class AudioSwitch {
4752
private var bluetoothHeadsetManager: BluetoothHeadsetManager? = null
4853
private val preferredDeviceList: List<Class<out AudioDevice>>
4954
private var bluetoothHeadsetConnectionListener: BluetoothHeadsetConnectionListener? = null
55+
private val permissionsRequestStrategy: PermissionsCheckStrategy
5056

5157
internal var state: State = STOPPED
5258
internal enum class State {
@@ -138,7 +144,8 @@ class AudioSwitch {
138144
audioFocusChangeListener = audioFocusChangeListener,
139145
),
140146
wiredHeadsetReceiver: WiredHeadsetReceiver = WiredHeadsetReceiver(context, logger),
141-
headsetManager: BluetoothHeadsetManager? = BluetoothHeadsetManager.newInstance(
147+
permissionsCheckStrategy: PermissionsCheckStrategy = DefaultPermissionsCheckStrategy(context),
148+
bluetoothHeadsetManager: BluetoothHeadsetManager? = BluetoothHeadsetManager.newInstance(
142149
context,
143150
logger,
144151
BluetoothAdapter.getDefaultAdapter(),
@@ -149,8 +156,14 @@ class AudioSwitch {
149156
this.bluetoothHeadsetConnectionListener = bluetoothHeadsetConnectionListener
150157
this.audioDeviceManager = audioDeviceManager
151158
this.wiredHeadsetReceiver = wiredHeadsetReceiver
152-
this.bluetoothHeadsetManager = headsetManager
153159
this.preferredDeviceList = getPreferredDeviceList(preferredDeviceList)
160+
this.permissionsRequestStrategy = permissionsCheckStrategy
161+
this.bluetoothHeadsetManager = if (hasPermissions()) {
162+
bluetoothHeadsetManager
163+
} else {
164+
logger.w(TAG, PERMISSION_ERROR_MESSAGE)
165+
null
166+
}
154167
logger.d(TAG, "AudioSwitch($VERSION)")
155168
logger.d(TAG, "Preferred device list = ${this.preferredDeviceList.map { it.simpleName }}")
156169
}
@@ -387,6 +400,31 @@ class AudioSwitch {
387400
audioDeviceChangeListener = null
388401
}
389402

403+
internal fun hasPermissions() = permissionsRequestStrategy.hasPermissions()
404+
405+
internal class DefaultPermissionsCheckStrategy(private val context: Context) : PermissionsCheckStrategy {
406+
407+
@SuppressLint("NewApi")
408+
override fun hasPermissions(): Boolean {
409+
return if (context.applicationInfo.targetSdkVersion <= android.os.Build.VERSION_CODES.R ||
410+
android.os.Build.VERSION.SDK_INT <= android.os.Build.VERSION_CODES.R
411+
) {
412+
PERMISSION_GRANTED == context.checkPermission(
413+
Manifest.permission.BLUETOOTH,
414+
android.os.Process.myPid(),
415+
android.os.Process.myUid(),
416+
)
417+
} else {
418+
// for android 12/S or newer
419+
PERMISSION_GRANTED == context.checkPermission(
420+
Manifest.permission.BLUETOOTH_CONNECT,
421+
android.os.Process.myPid(),
422+
android.os.Process.myUid(),
423+
)
424+
}
425+
}
426+
}
427+
390428
companion object {
391429
/**
392430
* The version of the AudioSwitch library.

Diff for: audioswitch/src/main/java/com/twilio/audioswitch/android/BluetoothDeviceWrapperImpl.kt

+2
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
package com.twilio.audioswitch.android
22

3+
import android.annotation.SuppressLint
34
import android.bluetooth.BluetoothDevice
45

56
internal const val DEFAULT_DEVICE_NAME = "Bluetooth"
67

8+
@SuppressLint("MissingPermission")
79
internal data class BluetoothDeviceWrapperImpl(
810
val device: BluetoothDevice,
911
override val name: String = device.name ?: DEFAULT_DEVICE_NAME,

Diff for: audioswitch/src/main/java/com/twilio/audioswitch/bluetooth/BluetoothHeadsetManager.kt

+36-84
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
package com.twilio.audioswitch.bluetooth
22

3-
import android.Manifest
43
import android.annotation.SuppressLint
54
import android.bluetooth.BluetoothAdapter
65
import android.bluetooth.BluetoothClass
@@ -16,7 +15,6 @@ import android.content.BroadcastReceiver
1615
import android.content.Context
1716
import android.content.Intent
1817
import android.content.IntentFilter
19-
import android.content.pm.PackageManager.PERMISSION_GRANTED
2018
import android.media.AudioManager
2119
import android.media.AudioManager.ACTION_SCO_AUDIO_STATE_UPDATED
2220
import android.media.AudioManager.SCO_AUDIO_STATE_CONNECTED
@@ -31,7 +29,6 @@ import com.twilio.audioswitch.android.BluetoothDeviceWrapper
3129
import com.twilio.audioswitch.android.BluetoothIntentProcessor
3230
import com.twilio.audioswitch.android.BluetoothIntentProcessorImpl
3331
import com.twilio.audioswitch.android.Logger
34-
import com.twilio.audioswitch.android.PermissionsCheckStrategy
3532
import com.twilio.audioswitch.android.SystemClockWrapper
3633
import com.twilio.audioswitch.bluetooth.BluetoothHeadsetManager.HeadsetState.AudioActivated
3734
import com.twilio.audioswitch.bluetooth.BluetoothHeadsetManager.HeadsetState.AudioActivating
@@ -40,7 +37,6 @@ import com.twilio.audioswitch.bluetooth.BluetoothHeadsetManager.HeadsetState.Con
4037
import com.twilio.audioswitch.bluetooth.BluetoothHeadsetManager.HeadsetState.Disconnected
4138

4239
private const val TAG = "BluetoothHeadsetManager"
43-
private const val PERMISSION_ERROR_MESSAGE = "Bluetooth unsupported, permissions not granted"
4440

4541
internal class BluetoothHeadsetManager
4642
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
@@ -54,7 +50,6 @@ internal constructor(
5450
systemClockWrapper: SystemClockWrapper = SystemClockWrapper(),
5551
private val bluetoothIntentProcessor: BluetoothIntentProcessor = BluetoothIntentProcessorImpl(),
5652
private var headsetProxy: BluetoothHeadset? = null,
57-
private val permissionsRequestStrategy: PermissionsCheckStrategy = DefaultPermissionsCheckStrategy(context),
5853
private var hasRegisteredReceivers: Boolean = false,
5954
) : BluetoothProfile.ServiceListener, BroadcastReceiver() {
6055

@@ -100,6 +95,7 @@ internal constructor(
10095
}
10196
}
10297

98+
@SuppressLint("MissingPermission")
10399
override fun onServiceConnected(profile: Int, bluetoothProfile: BluetoothProfile) {
104100
headsetProxy = bluetoothProfile as BluetoothHeadset
105101
bluetoothProfile.connectedDevices.forEach { device ->
@@ -200,56 +196,44 @@ internal constructor(
200196
}
201197

202198
fun start(headsetListener: BluetoothHeadsetConnectionListener) {
203-
if (hasPermissions()) {
204-
this.headsetListener = headsetListener
205-
206-
bluetoothAdapter.getProfileProxy(
207-
context,
199+
this.headsetListener = headsetListener
200+
201+
bluetoothAdapter.getProfileProxy(
202+
context,
203+
this,
204+
BluetoothProfile.HEADSET,
205+
)
206+
if (!hasRegisteredReceivers) {
207+
context.registerReceiver(
208208
this,
209-
BluetoothProfile.HEADSET,
209+
IntentFilter(ACTION_CONNECTION_STATE_CHANGED),
210210
)
211-
if (!hasRegisteredReceivers) {
212-
context.registerReceiver(
213-
this,
214-
IntentFilter(ACTION_CONNECTION_STATE_CHANGED),
215-
)
216-
context.registerReceiver(
217-
this,
218-
IntentFilter(ACTION_AUDIO_STATE_CHANGED),
219-
)
220-
context.registerReceiver(
221-
this,
222-
IntentFilter(ACTION_SCO_AUDIO_STATE_UPDATED),
223-
)
224-
hasRegisteredReceivers = true
225-
}
226-
} else {
227-
logger.w(TAG, PERMISSION_ERROR_MESSAGE)
211+
context.registerReceiver(
212+
this,
213+
IntentFilter(ACTION_AUDIO_STATE_CHANGED),
214+
)
215+
context.registerReceiver(
216+
this,
217+
IntentFilter(ACTION_SCO_AUDIO_STATE_UPDATED),
218+
)
219+
hasRegisteredReceivers = true
228220
}
229221
}
230222

231223
fun stop() {
232-
if (hasPermissions()) {
233-
headsetListener = null
234-
bluetoothAdapter.closeProfileProxy(BluetoothProfile.HEADSET, headsetProxy)
235-
if (hasRegisteredReceivers) {
236-
context.unregisterReceiver(this)
237-
hasRegisteredReceivers = false
238-
}
239-
} else {
240-
logger.w(TAG, PERMISSION_ERROR_MESSAGE)
224+
headsetListener = null
225+
bluetoothAdapter.closeProfileProxy(BluetoothProfile.HEADSET, headsetProxy)
226+
if (hasRegisteredReceivers) {
227+
context.unregisterReceiver(this)
228+
hasRegisteredReceivers = false
241229
}
242230
}
243231

244232
fun activate() {
245-
if (hasPermissions()) {
246-
if (headsetState == Connected || headsetState == AudioActivationError) {
247-
enableBluetoothScoJob.executeBluetoothScoJob()
248-
} else {
249-
logger.w(TAG, "Cannot activate when in the ${headsetState::class.simpleName} state")
250-
}
233+
if (headsetState == Connected || headsetState == AudioActivationError) {
234+
enableBluetoothScoJob.executeBluetoothScoJob()
251235
} else {
252-
logger.w(TAG, PERMISSION_ERROR_MESSAGE)
236+
logger.w(TAG, "Cannot activate when in the ${headsetState::class.simpleName} state")
253237
}
254238
}
255239

@@ -262,26 +246,16 @@ internal constructor(
262246
}
263247

264248
fun hasActivationError(): Boolean {
265-
return if (hasPermissions()) {
266-
headsetState == AudioActivationError
267-
} else {
268-
logger.w(TAG, PERMISSION_ERROR_MESSAGE)
269-
false
270-
}
249+
return headsetState == AudioActivationError
271250
}
272251

273252
// TODO Remove bluetoothHeadsetName param
274253
fun getHeadset(bluetoothHeadsetName: String?): AudioDevice.BluetoothHeadset? {
275-
return if (hasPermissions()) {
276-
if (headsetState != Disconnected) {
277-
val headsetName = bluetoothHeadsetName ?: getHeadsetName()
278-
headsetName?.let { AudioDevice.BluetoothHeadset(it) }
279-
?: AudioDevice.BluetoothHeadset()
280-
} else {
281-
null
282-
}
254+
return if (headsetState != Disconnected) {
255+
val headsetName = bluetoothHeadsetName ?: getHeadsetName()
256+
headsetName?.let { AudioDevice.BluetoothHeadset(it) }
257+
?: AudioDevice.BluetoothHeadset()
283258
} else {
284-
logger.w(TAG, PERMISSION_ERROR_MESSAGE)
285259
null
286260
}
287261
}
@@ -309,6 +283,7 @@ internal constructor(
309283

310284
private fun hasActiveHeadsetChanged() = headsetState == AudioActivated && hasConnectedDevice() && !hasActiveHeadset()
311285

286+
@SuppressLint("MissingPermission")
312287
private fun getHeadsetName(): String? =
313288
headsetProxy?.let { proxy ->
314289
proxy.connectedDevices?.let { devices ->
@@ -331,13 +306,15 @@ internal constructor(
331306
}
332307
}
333308

309+
@SuppressLint("MissingPermission")
334310
private fun hasActiveHeadset() =
335311
headsetProxy?.let { proxy ->
336312
proxy.connectedDevices?.let { devices ->
337313
devices.any { proxy.isAudioConnected(it) }
338314
}
339315
} ?: false
340316

317+
@SuppressLint("MissingPermission")
341318
private fun hasConnectedDevice() =
342319
headsetProxy?.let { proxy ->
343320
proxy.connectedDevices?.let { devices ->
@@ -359,8 +336,6 @@ internal constructor(
359336
deviceClass == BluetoothClass.Device.Major.UNCATEGORIZED
360337
} ?: false
361338

362-
internal fun hasPermissions() = permissionsRequestStrategy.hasPermissions()
363-
364339
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
365340
internal sealed class HeadsetState {
366341
object Disconnected : HeadsetState()
@@ -408,27 +383,4 @@ internal constructor(
408383
headsetState = AudioActivationError
409384
}
410385
}
411-
412-
internal class DefaultPermissionsCheckStrategy(private val context: Context) :
413-
PermissionsCheckStrategy {
414-
@SuppressLint("NewApi")
415-
override fun hasPermissions(): Boolean {
416-
return if (context.applicationInfo.targetSdkVersion <= android.os.Build.VERSION_CODES.R ||
417-
android.os.Build.VERSION.SDK_INT <= android.os.Build.VERSION_CODES.R
418-
) {
419-
PERMISSION_GRANTED == context.checkPermission(
420-
Manifest.permission.BLUETOOTH,
421-
android.os.Process.myPid(),
422-
android.os.Process.myUid(),
423-
)
424-
} else {
425-
// for android 12/S or newer
426-
PERMISSION_GRANTED == context.checkPermission(
427-
Manifest.permission.BLUETOOTH_CONNECT,
428-
android.os.Process.myPid(),
429-
android.os.Process.myUid(),
430-
)
431-
}
432-
}
433-
}
434386
}

0 commit comments

Comments
 (0)