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.
9.3 KiB
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_CONNECTis granted - WHEN a BT A2DP device with MAC
AA:BB:CC:DD:EE:FFconnects - THEN
deviceIdMUST be"bt_a2dp:AA:BB:CC:DD:EE:FF"
Scenario: permission granted yields real MAC id — manual/on-device QA (Kotlin deviceToMap)
- GIVEN
BLUETOOTH_CONNECTis granted on a real device - WHEN
deviceToMap()runs for a connected BT A2DP device - THEN
device.addressMUST 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_CONNECTis denied ordevice.addressreports the placeholder - WHEN a BT A2DP device with
productName"AirPods Pro" connects - THEN
deviceIdMUST 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_CONNECTis denied - WHEN two distinct BT A2DP devices with different
productNamevalues connect in sequence - THEN each MUST produce a distinct composite
deviceId - AND
presetsDispositivoMUST 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
deviceIdhas 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
presetsDispositivoalready contains an entry fordeviceId - WHEN
_onDispositivoCambiadofires again for the samedeviceId - 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_v1contains 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_v1contains"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
deviceIdsegment 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
btConnectRationaleandeqDeviceMigrationNotice(or equivalent) are added toapp_en.arb - WHEN each of the 13
app_<locale>.arbfiles 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