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.
110 lines
10 KiB
Markdown
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.
|