diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index 3acaa76..00285e1 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -14,6 +14,7 @@
+
? = null
@@ -614,6 +616,14 @@ class MainActivity : AudioServiceActivity() {
) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
if (requestCode == notificationPermissionRequestCode) return
+ if (requestCode == bluetoothConnectPermissionRequestCode) {
+ if (grantResults.firstOrNull() == PackageManager.PERMISSION_GRANTED) {
+ val device = getActiveAudioDevice()
+ Log.d(tag, "audio_devices.requestBluetoothConnect granted -> $device")
+ audioDevicesSink?.success(device)
+ }
+ return
+ }
if (requestCode != visualizerPermissionRequestCode) return
if (grantResults.firstOrNull() == PackageManager.PERMISSION_GRANTED) {
@@ -641,6 +651,10 @@ class MainActivity : AudioServiceActivity() {
Log.d(tag, "audio_devices.getActiveDevice -> $device")
result.success(device)
}
+ "requestBluetoothConnect" -> {
+ Log.d(tag, "audio_devices.requestBluetoothConnect")
+ result.success(requestBluetoothConnect())
+ }
else -> result.notImplemented()
}
}
@@ -660,6 +674,20 @@ class MainActivity : AudioServiceActivity() {
)
}
+ private fun requestBluetoothConnect(): Boolean {
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) return true
+ if (checkSelfPermission(Manifest.permission.BLUETOOTH_CONNECT) ==
+ PackageManager.PERMISSION_GRANTED
+ ) {
+ return true
+ }
+ requestPermissions(
+ arrayOf(Manifest.permission.BLUETOOTH_CONNECT),
+ bluetoothConnectPermissionRequestCode
+ )
+ return true
+ }
+
private fun registerAudioDeviceCallback() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) return
val audioManager = getSystemService(Context.AUDIO_SERVICE) as AudioManager
@@ -693,11 +721,15 @@ class MainActivity : AudioServiceActivity() {
* Returns a map describing the current active audio output device.
*
* Device ID format (matches spec):
- * - "builtin_speaker" — TYPE_BUILTIN_SPEAKER (2)
- * - "wired_headset" — TYPE_WIRED_HEADSET (3) or TYPE_WIRED_HEADPHONES (4)
- * - "bt_a2dp:" — TYPE_BLUETOOTH_A2DP (8); MAC from AudioDeviceInfo.address
- * - "usb_headset:" — TYPE_USB_HEADSET (14)
- * - "builtin_speaker" — fallback when API < 23
+ * - "builtin_speaker" — TYPE_BUILTIN_SPEAKER (2)
+ * - "wired_headset" — TYPE_WIRED_HEADSET (3) or TYPE_WIRED_HEADPHONES (4)
+ * - "bt_a2dp:" — TYPE_BLUETOOTH_A2DP (8); MAC from AudioDeviceInfo.address
+ * - "bt_a2dp:name:" — TYPE_BLUETOOTH_A2DP (8) fallback when the MAC is absent
+ * or still the OS placeholder ("02:00:00:00:00:00", seen
+ * without BLUETOOTH_CONNECT); productName colons are
+ * sanitized to '-' to preserve the matrix-key delimiter
+ * - "usb_headset:" — TYPE_USB_HEADSET (14)
+ * - "builtin_speaker" — fallback when API < 23
*
* Type int values sent to Dart match the AudioDeviceInfo.TYPE_* constants.
*/
@@ -739,9 +771,19 @@ class MainActivity : AudioServiceActivity() {
AudioDeviceInfo.TYPE_WIRED_HEADPHONES ->
mapOf("id" to "wired_headset", "type" to 3, "name" to (device.productName?.toString() ?: "Wired Headphones"))
AudioDeviceInfo.TYPE_BLUETOOTH_A2DP -> {
- val mac = device.address?.takeIf { it.isNotBlank() } ?: "00:00:00:00:00:00"
+ // The OS reports a placeholder MAC ("02:00:00:00:00:00") when
+ // BLUETOOTH_CONNECT has not been granted; treat that (and any
+ // null/blank address) as absent instead of using it as an id.
+ val mac = device.address?.takeIf { it.isNotBlank() && it != bluetoothMacPlaceholder }
+ val id = if (mac != null) {
+ "bt_a2dp:$mac"
+ } else {
+ val safeProductName = (device.productName?.toString()?.takeIf { it.isNotBlank() } ?: "unknown")
+ .replace(":", "-")
+ "bt_a2dp:name:$safeProductName"
+ }
mapOf(
- "id" to "bt_a2dp:$mac",
+ "id" to id,
"type" to 8,
"name" to (device.productName?.toString() ?: "Bluetooth"),
)