Files
pluriwave/openspec/specs/bt-device-identity/spec.md
T
FreeTLab 159334f997
Build & Deploy PluriWave / Análisis de código (push) Successful in 35s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 1m44s
docs(openspec): archive bt-device-identity and promote its spec
Move the change folder to changes/archive/2026-07-11-bt-device-identity
with the verified artifact set (verdict: pass with warnings, 0 critical,
102/102 targeted tests) and create the bt-device-identity capability
spec under openspec/specs/. Phase 7 on-device QA remains the pending
human gate before release.
2026-07-11 01:15:06 +02:00

186 lines
9.3 KiB
Markdown

# 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:<sanitized productName>"`, 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_<locale>.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