feat(aap): Also update system Bluetooth alias when renaming

After the AAP rename succeeds, try to update Android's per-device bond alias via the hidden BluetoothDevice.setAlias(String) method, so the new name also shows up in the system Bluetooth settings on this phone.

Known failure mode on Android 12+: setAlias is gated behind a Companion Device Manager (CDM) association at the service layer, and raises 'does not have a CDM association with the Bluetooth Device' for third-party apps that don't hold one. In that case, a dedicated snackbar explains that the system rename didn't go through and suggests renaming manually in system settings or re-pairing.

The AAP-level rename is always attempted first and is the load-bearing part; the system alias is a best-effort extra.
This commit is contained in:
darken
2026-04-08 15:36:18 +02:00
committed by Matthias Urhahn
parent 41245d928c
commit cb915d6263
4 changed files with 58 additions and 1 deletions
@@ -307,6 +307,37 @@ class BluetoothManager2 @Inject constructor(
private var _isNudgeAvailable: Boolean = true
val isNudgeAvailable: Boolean get() = _isNudgeAvailable
/**
* Set the Android-local alias for a bonded [device]. Updates what Android's system Bluetooth
* settings display without touching the AirPods firmware itself. Uses reflection on the hidden
* `setAlias(String)` method because the public API 30+ variant requires `BLUETOOTH_PRIVILEGED`,
* which third-party apps cannot hold.
*
* Returns `true` on success, `false` if the call threw, was rejected, or returned `false`.
*
* Known failure mode on Android 12+: `SecurityException: does not have a CDM association with
* the Bluetooth Device`. The hidden method was moved behind a Companion Device Manager (CDM)
* permission check at the service layer — only apps that have explicitly requested and been
* granted a CDM association for this specific device can rename it. CAPod does not currently
* pursue a CDM association (that's a user-visible pairing flow), so setAlias is effectively
* unavailable on modern Android and callers should be prepared to surface a user-facing
* fallback when it returns false. See DeviceSettingsViewModel.setDeviceName.
*/
@android.annotation.SuppressLint("MissingPermission")
fun setDeviceAlias(device: BluetoothDevice2, alias: String): Boolean {
return try {
val method = BluetoothDevice::class.java.getDeclaredMethod("setAlias", String::class.java)
.apply { isAccessible = true }
val result = method.invoke(device.internal, alias) as? Boolean ?: false
log(TAG) { "setDeviceAlias(${device.address}, $alias) -> $result" }
result
} catch (e: Exception) {
val cause = (e as? java.lang.reflect.InvocationTargetException)?.cause ?: e
log(TAG, WARN) { "setDeviceAlias(${device.address}, $alias) failed: $cause" }
false
}
}
suspend fun nudgeConnection(device: BluetoothDevice2): Boolean = getBluetoothProfile().map { bluetoothProfile ->
try {
log(TAG) { "Nudging Android connection to $device" }
@@ -109,6 +109,12 @@ fun DeviceSettingsScreenHost(
context.getString(R.string.device_settings_send_failed, event.message ?: ""),
)
}
DeviceSettingsViewModel.Event.SystemRenameUnavailable -> {
snackbarHostState.showSnackbar(
context.getString(R.string.device_settings_rename_system_unavailable),
duration = androidx.compose.material3.SnackbarDuration.Long,
)
}
}
}
}
@@ -58,6 +58,7 @@ class DeviceSettingsViewModel @Inject constructor(
sealed interface Event {
data object OpenBluetoothSettings : Event
data class SendFailed(val command: AapCommand, val message: String?) : Event
data object SystemRenameUnavailable : Event
}
val events = SingleEventFlow<Event>()
@@ -191,7 +192,25 @@ class DeviceSettingsViewModel @Inject constructor(
fun setSleepDetection(enabled: Boolean) = sendProGated(AapCommand.SetSleepDetection(enabled))
fun setDeviceName(name: String) = send(AapCommand.SetDeviceName(name))
fun setDeviceName(name: String) = launch {
val address = targetAddress.value ?: return@launch
sendInternal(AapCommand.SetDeviceName(name))
// Also try to update the Android-local bond alias so the new name shows in Android's
// Bluetooth settings too. The AAP rename only changes what the AirPods themselves report;
// Android's system display reads from the bond database and needs a separate update.
val bonded = try {
bluetoothManager.bondedDevices().first().firstOrNull { it.address == address }
} catch (e: Exception) {
log(TAG, WARN) { "bondedDevices() failed while renaming: ${e.message}" }
null
}
val aliasOk = bonded?.let { bluetoothManager.setDeviceAlias(it, name) } ?: false
if (!aliasOk) {
log(TAG, WARN) { "System bond alias rename failed for $address — user must rename in system settings or re-pair" }
events.emit(Event.SystemRenameUnavailable)
}
}
fun navToStemConfig() = launch {
if (upgradeRepo.isPro()) {
+1
View File
@@ -496,6 +496,7 @@
<string name="device_settings_rename_hint">Device name</string>
<string name="device_settings_rename_confirm">Rename</string>
<string name="device_settings_rename_invalid_ascii">Only ASCII characters are supported</string>
<string name="device_settings_rename_system_unavailable">Android didn\'t let us rename the device here. To update the name in Bluetooth settings, rename it there or re-pair the device.</string>
<string name="device_settings_send_failed">Could not apply setting: %1$s</string>
<string name="device_settings_category_connections_label">Connected Devices</string>
<string name="device_settings_connected_devices_description">Other devices currently connected to these AirPods</string>