# 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 _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 `` | | `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 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 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:` (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.