From aef4e02c1fdd4ae62b9a275217d4e5fe47d3845d Mon Sep 17 00:00:00 2001 From: freetlab Date: Sat, 11 Jul 2026 00:02:38 +0200 Subject: [PATCH] fix(devices): use real Bluetooth MAC as device identity on Android 12+ Without BLUETOOTH_CONNECT, Android 12+ returns the fixed placeholder 02:00:00:00:00:00 for every Bluetooth device's address, so all BT devices collapsed onto the same equalizer identity and renames appeared to duplicate devices after re-pairing. Declare the permission, add a requestBluetoothConnect channel method mirroring the existing notification-permission flow, guard the placeholder in deviceToMap() with a colon-sanitized name-based fallback id (replacing the dead 00:00:00:00:00:00 branch), and re-emit the active device after the grant so already-connected devices pick up their real MAC without a reconnect. Work unit 1/2 of bt-device-identity (Kotlin plumbing). --- android/app/src/main/AndroidManifest.xml | 1 + .../es/freetimelab/pluriwave/MainActivity.kt | 56 ++++++++++++++++--- 2 files changed, 50 insertions(+), 7 deletions(-) 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"), )