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.
10 KiB
Design: Canonical Bluetooth Device Identity
Technical Approach
Restore stable BT MAC identity by acquiring BLUETOOTH_CONNECT at point of intent, and make the id pipeline resilient when the MAC is still the Android placeholder. Four coordinated edits, all following existing patterns: (1) Kotlin deviceToMap() gains a placeholder guard + composite fallback and a new requestBluetoothConnect MethodChannel call mirroring requestPostNotificationsPermission; (2) EstadoEcualizador caches per-device platform names in-memory and stops auto-spawning list entries for the placeholder sentinel; (3) the two pantalla_ajustes.dart call sites feed the real platform name into nombreVisible(); (4) a guarded one-time migration purges the exact placeholder key from the three SP maps. iOS is untouched. Preserves the bt_a2dp: id shape and the eq_presets_matriz_v1 colon-delimiter invariant.
Load-bearing correction:
multi-device-eq/design.mdADR-1 (L17) claimed "BT MAC fromAudioManager.getDevices()requires no extra permission." That is FALSE on API 31+ and is the root of Bug 1. On unpermitted installsgetAddress()returns the placeholder02:00:00:00:00:00(not null/blank), so every BT device collapsed ontobt_a2dp:02:00:00:00:00:00.
Architecture Decisions
ADR-1: When to request BLUETOOTH_CONNECT
| Option | Tradeoff | Decision |
|---|---|---|
On opening device-management UI (settings), via new requestBluetoothConnect MethodChannel call |
Point-of-intent = higher grant rate, Play-safe, mirrors POST_NOTIFICATIONS | Chosen |
| At app launch | Sensitive prompt out of context, lower grant, Play scrutiny | Rejected |
Rationale: Reuses the proven pluriwave/audio_devices MethodChannel already handling getActiveDevice. Deny degrades gracefully to composite fallback; re-prompted on next settings open.
ADR-2: Re-emit device list after grant
Choice: After a grant result, Kotlin re-runs getActiveAudioDevice() and pushes it through audioDevicesSink.
Alternatives: Do nothing (leave pre-grant placeholder cached). Rationale: A device connected BEFORE grant carries the placeholder id; without re-enumeration the real MAC never reaches Dart until a reconnect. Re-emission is required for correctness.
ADR-3: Placeholder guard + composite fallback id
Choice: In the TYPE_BLUETOOTH_A2DP branch, treat both blank and the literal 02:00:00:00:00:00 as absent. When absent, build a deterministic fallback "bt_a2dp:name:$safeProductName" where safeProductName sanitizes : → - (and blank → unknown). MAC path unchanged when present.
Alternatives: Distinct sentinel prefix (breaks matrix delimiter parsing); pass placeholder through (current bug). Rationale: Keeps the single leading bt_a2dp: segment so eq_presets_matriz_v1 split-on-first-: stays valid (multi-device-eq ADR-3 L35: station UUIDs are RFC 4122, no colons). Sanitizing productName guarantees no additional colons corrupt the matrix key.
ADR-4: Per-device name cache — in-memory only
Choice: Map<String, String> _nombresPlataforma in EstadoEcualizador, populated on every _onDispositivoCambiado and seed; NOT persisted.
Alternatives: Persist to a new SP key. Rationale: Devices re-report their name on every enumeration, so the cache self-heals each session. Persisting adds a key + migration surface AND collides with an existing latent gap (guardarConfiguracion never writes nombresDispositivos back). In-memory is simpler and sufficient.
ADR-5: Placeholder migration location & guard
Choice: Run once inside ServicioEcualizador.cargar() (or a dedicated migrarClavesPlaceholder() called there), guarded by a new bool flag key eq_placeholder_purge_done_v1. Purge the exact literal bt_a2dp:02:00:00:00:00:00 from eq_nombres_dispositivos_v1, eq_preset_por_dispositivo_v1, and (any key ending :bt_a2dp:02:00:00:00:00:00) from eq_presets_matriz_v1. Set flag true. Idempotent — flag short-circuits re-runs.
Alternatives: Migration in EstadoEcualizador.cargarPersistido. Rationale: Service owns SP; keeps state layer clean. Exact-literal match satisfies the "delete only placeholder" risk mitigation.
ADR-6: Transient duplicate-entry guard
Choice: In _onDispositivoCambiado, always update _nombresPlataforma; skip auto-creating a _presetsDispositivo entry when dispositivo.id starts with the composite-fallback marker bt_a2dp:name: (unknown-MAC device). Stable ids (real MAC, builtin_speaker, wired_headset, usb) still auto-create.
Alternatives: Dedup by canonical id / suppress non-BT transient types. Rationale: The reported "duplicate on rename" is driven by placeholder collision (Bug 1); once MAC is canonical the duplicate disappears. Suppressing legitimate builtin/wired entries would regress their EQ. Broader transient-churn dedup is deferred (Open Question).
Data Flow
Settings UI opens ──► servicioDispositivoAudio.solicitarPermisoBluetooth()
│ │ MethodChannel 'requestBluetoothConnect'
▼ ▼
(Dart) MainActivity.requestBluetoothConnect()
│ grant result
▼
getActiveAudioDevice() ──► deviceToMap() [MAC or composite]
│ audioDevicesSink.success(map)
▼
EstadoEcualizador._onDispositivoCambiado(dispositivo)
├─ _nombresPlataforma[id] = dispositivo.nombre (always)
└─ if id not placeholder-composite → create/persist preset entry
│
▼
_FilaDispositivo / dialog ─► eq.nombreVisible(id, eq.nombrePlataforma(id))
File Changes
| File | Action | Description |
|---|---|---|
android/app/src/main/AndroidManifest.xml |
Modify | Add <uses-permission android:name="android.permission.BLUETOOTH_CONNECT"/> |
android/app/src/main/kotlin/.../MainActivity.kt |
Modify | Placeholder guard + composite fallback in deviceToMap() (L741); requestBluetoothConnect MethodChannel case on audioDevicesChannel (L638) mirroring requestPostNotificationsPermission (L306); new request code; re-emit active device in onRequestPermissionsResult (L610) on grant |
lib/servicios/servicio_dispositivo_audio.dart |
Modify | Add Future<bool> solicitarPermisoBluetooth() to abstract + real impl (invokeMethod requestBluetoothConnect) |
lib/estado/estado_ecualizador.dart |
Modify | _nombresPlataforma map + nombrePlataforma(id) getter; populate in _onDispositivoCambiado/seed; guard auto-create against composite sentinel (L216) |
lib/pantallas/pantalla_ajustes.dart |
Modify | L769 + L846: pass eq.nombrePlataforma(deviceId) instead of ''; trigger solicitarPermisoBluetooth() when advanced-EQ section builds/toggles on |
lib/servicios/servicio_ecualizador.dart |
Modify | _keyPlaceholderPurgaHecha; migrarClavesPlaceholder() called from cargar(); purge literal placeholder from 3 maps |
lib/l10n/app_*.arb (13) |
Modify | Permission-rationale + migration-notice keys |
Interfaces / Contracts
// ServicioDispositivoAudio (abstract + real): returns true if granted/not-needed
Future<bool> solicitarPermisoBluetooth();
// EstadoEcualizador
String nombrePlataforma(String deviceId); // last-seen platform name or ''
// MainActivity, audioDevicesChannel handler
"requestBluetoothConnect" -> result.success(requestBluetoothConnect())
// mirrors requestPostNotificationsPermission: SDK<31 → true; granted → true;
// else requestPermissions(BLUETOOTH_CONNECT, code) → true
Placeholder constant (shared intent, define once per side): 02:00:00:00:00:00.
Composite fallback id shape: bt_a2dp:name:<sanitized productName> (colons in name → -).
Testing Strategy
Existing fakes: FakeServicioDispositivoAudio (has emitirDispositivo), FakeServicioDispositivoAudioThrows, FakeServicioEcualizador (in-memory ConfiguracionEcualizador) — all in test/helpers/fakes.dart.
| Layer | What to Test | Approach |
|---|---|---|
| Unit (state) | Placeholder-composite id does NOT create a device-list entry; real MAC does; _nombresPlataforma populated on event; nombreVisible returns platform name when no custom |
test/estado/estado_ecualizador_test.dart — emit via FakeServicioDispositivoAudio |
| Unit (service) | migrarClavesPlaceholder drops only bt_a2dp:02:00:00:00:00:00, keeps stable-MAC entries, is idempotent (flag), matrix suffix variant purged |
test/servicios/servicio_ecualizador_test.dart — seed SP via SharedPreferences.setMockInitialValues |
| Unit (device svc) | solicitarPermisoBluetooth invokes requestBluetoothConnect and returns bool |
test/servicios/servicio_dispositivo_audio_real_test.dart — mock MethodChannel handler |
| Widget | Device row shows platform name (not raw id) when platform name known; permission call fires on section build | test/pantallas/pantalla_ajustes_test.dart |
New fake behavior: add a permisoBluetoothConcedido flag + call counter to FakeServicioDispositivoAudio, and a helper to emit a placeholder-composite device. Kotlin permission path is not unit-tested (no instrumented tests in repo); covered by the Dart contract test on the channel.
Migration / Rollout
One-time guarded purge in ServicioEcualizador.cargar(), flag eq_placeholder_purge_done_v1. Deletes only the exact placeholder-keyed entries (unrecoverable regardless). No persistence-key version bump → downgrade clean. Additive otherwise. Surfaces a one-time "rename your Bluetooth devices again" notice only when a placeholder entry was actually removed.
Open Questions
- Broader transient-churn dedup (builtin/wired appearing mid-handshake) is deferred; acceptable now that MAC is canonical. Revisit if duplicates persist post-fix.
- Rationale-dialog UX (show explanatory sheet before the OS prompt) vs. firing the OS prompt directly — proposal implies a rationale string exists; confirm whether a pre-prompt sheet is in scope for tasks.