# Spec: Canonical Bluetooth Device Identity ## New Capability: bt-device-identity ### Purpose Ensures each physical Bluetooth A2DP device produces a stable, unique `deviceId` and displays its own Bluetooth name by default, by acquiring `BLUETOOTH_CONNECT` at point-of-intent, guarding against the OS placeholder MAC, caching per-device platform names, and migrating away corrupted placeholder-keyed persistence entries. ### Requirements #### Requirement: BLUETOOTH_CONNECT requested at point-of-intent On API 31+, the system MUST request `BLUETOOTH_CONNECT` when the device-management UI (Settings → Advanced Equalization) is opened, not at app launch. `AndroidManifest.xml` MUST declare `BLUETOOTH_CONNECT`. ##### Scenario: permission requested on UI open — manual/on-device QA - GIVEN the app has never requested `BLUETOOTH_CONNECT` - WHEN the user opens the device-management screen on API 31+ - THEN the system permission dialog MUST appear before any BT device is enumerated for identity purposes ##### Scenario: permission not requested at app launch — manual/on-device QA - GIVEN a fresh install - WHEN the app starts and the user never opens device-management - THEN no BT permission dialog MUST appear --- #### Requirement: Placeholder MAC guarded with composite fallback `deviceToMap()` MUST treat the OS placeholder `"02:00:00:00:00:00"` as absent, not as a usable MAC. When the MAC is absent or the placeholder, the id MUST fall back to a deterministic composite id keeping the single leading `bt_a2dp:` segment, with the product name colon-sanitized (implemented shape: `"bt_a2dp:name:"`, per design ADR-3 — the fixed `name` marker keeps matrix-key split-on-first-colon safety); the placeholder string itself MUST NOT become a `deviceId`. ##### Scenario: permission granted yields real MAC id — Dart-testable via fake device stream - GIVEN `BLUETOOTH_CONNECT` is granted - WHEN a BT A2DP device with MAC `AA:BB:CC:DD:EE:FF` connects - THEN `deviceId` MUST be `"bt_a2dp:AA:BB:CC:DD:EE:FF"` ##### Scenario: permission granted yields real MAC id — manual/on-device QA (Kotlin `deviceToMap`) - GIVEN `BLUETOOTH_CONNECT` is granted on a real device - WHEN `deviceToMap()` runs for a connected BT A2DP device - THEN `device.address` MUST be a real MAC, not the placeholder - AND the resulting id MUST NOT equal `"bt_a2dp:02:00:00:00:00:00"` ##### Scenario: permission denied yields composite fallback, no crash — Dart-testable via fake device stream - GIVEN `BLUETOOTH_CONNECT` is denied or `device.address` reports the placeholder - WHEN a BT A2DP device with `productName` "AirPods Pro" connects - THEN `deviceId` MUST be a deterministic composite id (implemented shape: `"bt_a2dp:name:AirPods Pro"`), MUST NOT be `"bt_a2dp:02:00:00:00:00:00"`, and app state MUST update without throwing ##### Scenario: multiple denied-permission devices do not collide — Dart-testable via fake device stream - GIVEN `BLUETOOTH_CONNECT` is denied - WHEN two distinct BT A2DP devices with different `productName` values connect in sequence - THEN each MUST produce a distinct composite `deviceId` - AND `presetsDispositivo` MUST NOT collapse them into one entry ##### Scenario: placeholder never surfaces as a stored id — Dart-testable - GIVEN any device change event is processed after the fix - WHEN persistence keys are inspected - THEN no key MUST equal the literal string `"bt_a2dp:02:00:00:00:00:00"` --- #### Requirement: Per-device platform-name cache feeds display `EstadoEcualizador` MUST cache the last-seen platform (`productName`) name per `deviceId`. `nombreVisible(deviceId, platformName)` call sites in `pantalla_ajustes.dart` MUST pass the cached platform name, not an empty string. ##### Scenario: platform name displays with no custom rename — Dart-testable - GIVEN a device with id `"bt_a2dp:AA:BB:CC:DD:EE:FF"` and platform name "AirPods Pro" has connected, with no custom rename stored - WHEN the device row is rendered - THEN the displayed name MUST be "AirPods Pro", not the raw `deviceId` ##### Scenario: custom rename overrides platform name — Dart-testable - GIVEN the same device has both a cached platform name "AirPods Pro" and a custom rename "My Headphones" - WHEN the device row is rendered - THEN the displayed name MUST be "My Headphones" ##### Scenario: no platform name yet falls back to raw id — Dart-testable - GIVEN a `deviceId` has no cached platform name and no custom rename - WHEN the device row is rendered - THEN the displayed name MUST be the raw `deviceId` (unchanged legacy behavior) --- #### Requirement: Rename overlay survives re-pair under canonical id Once `BLUETOOTH_CONNECT` is granted, a rename stored under a device's canonical MAC-based `deviceId` MUST persist across disconnect/reconnect (re-pair) of the same physical device. ##### Scenario: rename persists after re-pair — Dart-testable via fake device stream - GIVEN a custom rename "My Headphones" is stored for `"bt_a2dp:AA:BB:CC:DD:EE:FF"` - WHEN that device disconnects and reconnects, reporting the same MAC - THEN the device row MUST still display "My Headphones" - AND no second/duplicate entry MUST appear in `presetsDispositivo` ##### Scenario: rename persists after re-pair — manual/on-device QA - GIVEN a real paired BT device is renamed in-app - WHEN the user disconnects and re-pairs the same physical device - THEN the rename MUST still be shown and no duplicate device row MUST appear --- #### Requirement: `_onDispositivoCambiado` does not create duplicate entries for transient reports Device-change events reporting an id already present in `presetsDispositivo` MUST NOT create a second entry or overwrite the existing preset with a fresh copy. ##### Scenario: repeated event for known id is a no-op on preset creation — Dart-testable - GIVEN `presetsDispositivo` already contains an entry for `deviceId` - WHEN `_onDispositivoCambiado` fires again for the same `deviceId` - THEN `presetsDispositivo[deviceId]` MUST remain unchanged - AND no new key MUST be added to `presetsDispositivo` ##### Scenario: transient non-BT id during pairing handshake does not corrupt BT entry — Dart-testable via fake device stream - GIVEN a BT device is mid-reconnect and the OS transiently reports `"builtin_speaker"` before A2DP profile attaches - WHEN both the transient and final BT events are processed - THEN the BT device's own entry MUST be keyed only by its BT `deviceId` - AND MUST NOT be merged with or overwritten by the transient `"builtin_speaker"` entry --- #### Requirement: One-time guarded migration purges only exact placeholder-keyed entries On first load after this change, the system MUST remove entries whose key is exactly `"bt_a2dp:02:00:00:00:00:00"` from `eq_nombres_dispositivos_v1`, `eq_presets_matriz_v1` (matching the `deviceId` segment), and `eq_preset_por_dispositivo_v1`. All other entries MUST be preserved unchanged. The migration MUST run at most once (idempotent, flagged). ##### Scenario: migration removes only exact placeholder entries — Dart-testable - GIVEN `eq_preset_por_dispositivo_v1` contains both `"bt_a2dp:02:00:00:00:00:00"` and `"bt_a2dp:AA:BB:CC:DD:EE:FF"` - WHEN the migration runs - THEN the placeholder-keyed entry MUST be removed - AND the stable-MAC entry MUST remain byte-for-byte identical ##### Scenario: matrix keys purge only the placeholder segment — Dart-testable - GIVEN `eq_presets_matriz_v1` contains `"station1:bt_a2dp:02:00:00:00:00:00"` and `"station1:bt_a2dp:AA:BB:CC:DD:EE:FF"` - WHEN the migration runs - THEN only the entry with the placeholder `deviceId` segment MUST be removed - AND the other matrix entry MUST be preserved unchanged ##### Scenario: near-miss keys are preserved — Dart-testable - GIVEN a key `"bt_a2dp:02:00:00:00:00:01"` exists (differs by one digit from the placeholder) - WHEN the migration runs - THEN this entry MUST NOT be removed ##### Scenario: migration runs once — Dart-testable - GIVEN the migration has already run once (flag set) - WHEN the app loads again with the same data - THEN the migration MUST NOT execute a second time - AND no additional entries MUST be removed ##### Scenario: no placeholder entries when BLUETOOTH_CONNECT was never requested — Dart-testable - GIVEN no BT permission was ever granted and no placeholder-keyed entries exist - WHEN the migration runs - THEN it MUST be a no-op and MUST NOT alter any persisted keys --- ### Localization #### Requirement: Permission rationale and migration notice strings exist in all locales If UI copy is shown for the `BLUETOOTH_CONNECT` rationale or the migration notice, new l10n keys MUST be added to `lib/l10n/app_en.arb` as the template and translated in all 13 target locales: `ar`, `bn`, `de`, `en`, `es`, `fr`, `hi`, `id`, `it`, `ja`, `pt`, `ru`, `zh`. ##### Scenario: new keys present in every locale — Dart-testable (arb parity check) - GIVEN new keys `btConnectRationale` and `eqDeviceMigrationNotice` (or equivalent) are added to `app_en.arb` - WHEN each of the 13 `app_.arb` files is parsed - THEN each file MUST contain both keys with non-empty translated values ##### Scenario: rationale copy renders before the OS dialog — manual/on-device QA - GIVEN the device-management UI is opened for the first time - WHEN the in-app rationale (if any) is shown - THEN it MUST render in the user's selected app locale before the OS permission dialog appears