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

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_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