Files
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

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

// 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.