diff --git a/app/src/main/java/eu/darken/capod/common/bluetooth/BluetoothManager2.kt b/app/src/main/java/eu/darken/capod/common/bluetooth/BluetoothManager2.kt index 97751b02..2c7c0c2e 100644 --- a/app/src/main/java/eu/darken/capod/common/bluetooth/BluetoothManager2.kt +++ b/app/src/main/java/eu/darken/capod/common/bluetooth/BluetoothManager2.kt @@ -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" } diff --git a/app/src/main/java/eu/darken/capod/main/ui/devicesettings/DeviceSettingsScreen.kt b/app/src/main/java/eu/darken/capod/main/ui/devicesettings/DeviceSettingsScreen.kt index 22034b16..d0d83cea 100644 --- a/app/src/main/java/eu/darken/capod/main/ui/devicesettings/DeviceSettingsScreen.kt +++ b/app/src/main/java/eu/darken/capod/main/ui/devicesettings/DeviceSettingsScreen.kt @@ -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, + ) + } } } } diff --git a/app/src/main/java/eu/darken/capod/main/ui/devicesettings/DeviceSettingsViewModel.kt b/app/src/main/java/eu/darken/capod/main/ui/devicesettings/DeviceSettingsViewModel.kt index 945081cd..f708cc50 100644 --- a/app/src/main/java/eu/darken/capod/main/ui/devicesettings/DeviceSettingsViewModel.kt +++ b/app/src/main/java/eu/darken/capod/main/ui/devicesettings/DeviceSettingsViewModel.kt @@ -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() @@ -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()) { diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 96558db7..ab2964a4 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -496,6 +496,7 @@ Device name Rename Only ASCII characters are supported + Android didn\'t let us rename the device here. To update the name in Bluetooth settings, rename it there or re-pair the device. Could not apply setting: %1$s Connected Devices Other devices currently connected to these AirPods