Files
pluriwave/openspec/changes/archive/2026-07-11-bt-device-identity/design.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

110 lines
10 KiB
Markdown

# 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.md` ADR-1 (L17) claimed "BT MAC from `AudioManager.getDevices()` requires no extra permission." That is FALSE on API 31+ and is the root of Bug 1. On unpermitted installs `getAddress()` returns the placeholder `02:00:00:00:00:00` (not null/blank), so every BT device collapsed onto `bt_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
```dart
// ServicioDispositivoAudio (abstract + real): returns true if granted/not-needed
Future<bool> solicitarPermisoBluetooth();
// EstadoEcualizador
String nombrePlataforma(String deviceId); // last-seen platform name or ''
```
```kotlin
// 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.