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.
22 KiB
Tasks: Canonical Bluetooth Device Identity
Review Workload Forecast
| Field | Value |
|---|---|
| Estimated changed lines | 420–560 (Kotlin ~90, Dart prod ~140, Dart tests ~230, manifest ~1, l10n ~40 across 13 files) |
| 400-line budget risk | High |
| Chained PRs recommended | Yes |
| Suggested split | PR 1: Kotlin identity + permission plumbing (Phase 1–2) → PR 2: Dart state/display/migration (Phase 3–5) → PR 3: l10n + manual QA sign-off (Phase 6–7) |
| Delivery strategy | ask-on-risk |
| Chain strategy | pending |
Decision needed before apply: Yes Chained PRs recommended: Yes Chain strategy: pending 400-line budget risk: High
Batch progress: 2/3 complete. Batch 1 (Phase 1, Kotlin plumbing) and Batch 2 (Phases 2-5, Dart state/display/migration) are both done — see sdd/bt-device-identity/apply-progress for full merged evidence. Phase 6 (l10n) resolved N/A for this change (no in-app UI copy shipped — see Phase 6 section below). Remaining: Batch 3 = Phase 7 (manual/on-device QA) only.
Suggested Work Units
| Unit | Goal | Likely PR | Notes |
|---|---|---|---|
| 1 | Manifest + Kotlin placeholder guard/composite fallback + requestBluetoothConnect channel + re-emit on grant |
PR 1 | Base: feature/bt-device-identity; no unit harness (Kotlin) — code-inspection gated |
| 2 | Dart contract (solicitarPermisoBluetooth), platform-name cache, duplicate-entry guard, display fix, migration purge |
PR 2 | Base: PR 1 branch; depends on Unit 1 id-shape contract (composite fallback string) |
| 3 | l10n strings (13 locales) + manual/on-device QA pass | PR 3 | Base: PR 2 branch; depends on Unit 2 UI trigger points existing |
Phase 1: Kotlin — Permission Plumbing (PR 1 scope, code-inspection + manual QA — no unit harness)
Kotlin has no instrumented/unit test harness in this repo (confirmed: only
test/Dart tree exists). Every Kotlin task below is validated by self/peer code inspection against the exact method signature already used byrequestPostNotificationsPermission(MainActivity.kt L306-318), plus the Phase 7 manual QA pass. Do NOT attempt to add a Kotlin test file — there is no gradle test source set wired for this.
Batch progress: 1/3 complete (Phase 1, tasks 1.1-1.8, all [x]). flutter analyze re-run after these Kotlin/manifest-only edits: 0 issues (Dart untouched). Next: Batch 2 (Phase 2-5, Dart state/migration).
- 1.1 [manual/code-inspection] Modify
android/app/src/main/AndroidManifest.xml— add<uses-permission android:name="android.permission.BLUETOOTH_CONNECT"/>alongside the existing permission block (afterACCESS_FINE_LOCATION, L16) — DONE: inserted at L17, verified viarg BLUETOOTH_CONNECTon the manifest. - 1.2 [manual/code-inspection] Modify
MainActivity.kt— addprivate val bluetoothConnectPermissionRequestCode = 4823constant (next free code afternotificationPermissionRequestCode = 4822, L39) — DONE: added at L40, plus aprivate val bluetoothMacPlaceholder = "02:00:00:00:00:00"constant at L41 (grouped with the other class-level channel/request-code constants) to back tasks 1.5/1.6 and satisfy the "placeholder constant handled" static check. - 1.3 [manual/code-inspection] Modify
MainActivity.kt— addprivate fun requestBluetoothConnect(): BooleanmirroringrequestPostNotificationsPermission()(L306-318) verbatim in structure:SDK_INT < S→true; alreadyManifest.permission.BLUETOOTH_CONNECTgranted →true; elserequestPermissions(arrayOf(Manifest.permission.BLUETOOTH_CONNECT), bluetoothConnectPermissionRequestCode)→true— DONE. Placement note (implementation choice, not a design deviation): placed inside the "Audio Devices Channel" section (right aftersetupAudioDevicesChannel(), beforeregisterAudioDeviceCallback()) rather than next to the alarm-permission helpers, since it is invoked exclusively from theaudioDevicesChannelhandler and conceptually belongs with that section; design did not pin an exact line for the new function, only its body structure. - 1.4 [manual/code-inspection] Modify
MainActivity.ktsetupAudioDevicesChannel()(L637-646) — add"requestBluetoothConnect" -> { Log.d(tag, "audio_devices.requestBluetoothConnect"); result.success(requestBluetoothConnect()) }case on the existingaudioDevicesChannelMethodChannel (same channel asgetActiveDevice, not a new channel) — DONE, case added verbatim as specified. - 1.5 [manual/code-inspection] Modify
MainActivity.ktdeviceToMap()(L730-764),TYPE_BLUETOOTH_A2DPbranch (L741-748) — replace the current all-zeros fallback (device.address?.takeIf { it.isNotBlank() } ?: "00:00:00:00:00:00", L742) with an explicit placeholder guard: treatdevice.addressas absent when it isnull, blank, OR equal to the literal"02:00:00:00:00:00"(the real OS placeholder — NOT the00:00:...string currently hardcoded, which was never a real Android placeholder and must be removed as dead/incorrect fallback logic) — DONE: old fallback fully replaced (confirmed zero remaining00:00:00:00:00:00matches viarg), guard now usesit.isNotBlank() && it != bluetoothMacPlaceholder. - 1.6 [manual/code-inspection] Same branch — when the MAC is absent per 1.5, build composite fallback id
"bt_a2dp:name:$safeProductName"wheresafeProductName = (device.productName?.toString()?.takeIf { it.isNotBlank() } ?: "unknown").replace(":", "-")(ADR-3: single leadingbt_a2dp:segment preserved, colons in productName sanitized soeq_presets_matriz_v1split-on-first-:stays valid); when MAC is present, keep existing"bt_a2dp:$mac"shape unchanged — DONE verbatim; MAC-present path untouched ("bt_a2dp:$mac"). - 1.7 [manual/code-inspection] Modify
MainActivity.ktonRequestPermissionsResult()(L610-628) — add a branch forrequestCode == bluetoothConnectPermissionRequestCode: ongrantResults.firstOrNull() == PERMISSION_GRANTED, callgetActiveAudioDevice()and push throughaudioDevicesSink?.success(device)(ADR-2 — re-emit so a device connected before grant gets its real MAC without requiring reconnect); on denial, no-op (composite fallback already active, no crash path needed) — DONE: new early-return branch inserted between thenotificationPermissionRequestCodeandvisualizerPermissionRequestCodeguards, matching the existing guard-clause style; denial path is a barereturn(no-op), matching spec (no crash path needed). - 1.8 [code-inspection REFACTOR] Re-read full diff of
MainActivity.ktagainstrequestPostNotificationsPermission/visualizerPermissionRequestCodepatterns — confirm no request-code collision (4821/4822/4823 distinct), confirmdeviceToMap()doc comment (L692-703) still accurately describes thebt_a2dp:shape after the composite-fallback addition, update comment if stale — DONE: 4821/4822/4823 confirmed distinct by re-read; doc comment abovegetActiveAudioDevice()(which documents the id shapedeviceToMap()produces) updated with a new"bt_a2dp:name:<productName>"bullet explaining the placeholder/absent-MAC fallback and colon sanitization.
Phase 2: Dart — Permission Contract (PR 1/2 boundary — Dart side of Unit 1↔2 handoff)
Batch progress: 2/3 complete (Phase 2, tasks 2.1-2.5, all [x]).
- 2.1 RED: extend
test/servicios/servicio_dispositivo_audio_real_test.dart— assertsolicitarPermisoBluetooth()invokesMethodChannel('pluriwave/audio_devices').invokeMethod('requestBluetoothConnect')and returns the bool result (mockMethodChannelper existing test's setup pattern) — DONE: 3 cases (granted/denied/null-default), confirmed compile-fail RED viaflutter testbefore GREEN. - 2.2 GREEN: modify
lib/servicios/servicio_dispositivo_audio.dart— add abstractFuture<bool> solicitarPermisoBluetooth();toServicioDispositivoAudio; implement inServicioDispositivoAudioRealasawait _methodChannel.invokeMethod<bool>('requestBluetoothConnect') ?? false— DONE verbatim. - 2.3 GREEN: add
permisoBluetoothConcedidobool field +solicitarPermisoBluetoothCallsint counter toFakeServicioDispositivoAudiointest/helpers/fakes.dart; implementsolicitarPermisoBluetooth()override returning the field and incrementing the counter — DONE, defaultpermisoBluetoothConcedido: true. - 2.4 GREEN: add the same
solicitarPermisoBluetooth()override (returningtrue, no-op counter) toFakeServicioDispositivoAudioThrowsintest/helpers/fakes.dart— DONE. Also fixed a 4th, previously-undocumented implementer discovered during grounding:NullServicioDispositivoAudiointest/servicios/servicio_dispositivo_audio_toggle_test.dart(Dart requires every concrete subclass to implement a new abstract method before ANYTHING compiles, so this was a mandatory atomic addition, not scope creep). - 2.5 REFACTOR: confirmed
test/servicios/servicio_dispositivo_audio_test.dartIS the abstract-contract test file (group('ServicioDispositivoAudio (abstract contract)', ...)); added an interface-completeness assertion there — DONE.
Phase 3: Dart — Platform-Name Cache and Duplicate-Entry Guard (PR 2 scope)
Batch progress: 2/3 complete (Phase 3, tasks 3.1-3.10, all [x]). Integrated cleanly with the pre-existing esBase (builtin_speaker bootstrap-skip) guard from eq-device-disconnect-revert — Phase D regression group (D.1-D.5) confirmed still green.
- 3.1 RED: extend
test/estado/estado_ecualizador_test.dart— scenario "platform name is cached from a device-change event": emit a BT device viaFakeServicioDispositivoAudio.emitirDispositivowith real-MAC id +nombre: 'AirPods Pro', asserteq.nombrePlataforma('bt_a2dp:AA:BB:CC:DD:EE:FF') == 'AirPods Pro'— DONE, confirmed compile-fail RED (referenced not-yet-existingnombrePlataforma). - 3.2 GREEN: modify
lib/estado/estado_ecualizador.dart— addfinal Map<String, String> _nombresPlataforma = {};(in-memory only, ADR-4) andString nombrePlataforma(String deviceId) => _nombresPlataforma[deviceId] ?? '';getter — DONE verbatim. - 3.3 GREEN: in
_onDispositivoCambiado, unconditionally set_nombresPlataforma[dispositivo.id] = dispositivo.nombre;as the first statement inside theif (!_eqMultiDeviceEnabled) return;guard — DONE. - 3.4 RED: extend
test/estado/estado_ecualizador_test.dart— scenario "composite-placeholder sentinel does not create device-list entry": emit'bt_a2dp:name:AirPods-Pro', assertpresetsDispositivogains no new key whilenombrePlataformastill resolves — DONE. - 3.5 GREEN: in
_onDispositivoCambiado, guard the auto-create block with an additional check — skip whendispositivo.id.startsWith('bt_a2dp:name:')(extracted to a named constant_prefijoPlaceholderCompuesto, ADR-6) — DONE, combined via!esBase && !esPlaceholderCompuesto && !_presetsDispositivo.containsKey(...)(theesBaseguard is the pre-existingeq-device-disconnect-revertcheck, left untouched). - 3.6 RED: extend
test/estado/estado_ecualizador_test.dart— scenario "multiple denied-permission devices do not collide": two composite-shape devices in sequence, both cache correctly, neither creates apresetsDispositivoentry — DONE. - 3.7 RED (regression-lock, not new behavior): "repeated event for known id is a no-op on preset creation" — DONE, explicitly labeled as an approval/regression-lock test in the test name per this task's own note.
- 3.8 RED (regression-lock): "transient non-BT id during pairing handshake does not corrupt BT entry" — DONE.
- 3.9 GREEN/REFACTOR: ran full
estado_ecualizador_test.dartsuite — 48/48 pass, including the full Phase D group (D.1-D.5) and all pre-existing Phase 5/5.3-5.9/CRITICAL-1/CRITICAL-2 groups — DONE, zero regressions. - 3.10 [post-verify, closes CRITICAL-1] Added composed regression test
test/estado/estado_ecualizador_test.dart— "3.10 rename persists after re-pair — composed regression (closes bt-device-identity CRITICAL-1)": connect BT device (real-MAC id) →renombrarDispositivo(...)→ simulate disconnect (emitbuiltin_speaker) → re-pair (emit the SAME MAC id again) → assert no duplicatepresetsDispositivoentry, custom rename still wins vianombreVisible(...), and the device's preset entry survives untouched. Added persdd/bt-device-identity/verify-reportCRITICAL-1 finding: spec.md L95-100 ("Requirement: Rename overlay survives re-pair under canonical id") had no covering test — the constituent behaviors were each individually tested (reconnect-dedup by pre-existing Phase D test D.4, rename-priority by test 4.4) but never composed into a single sequence. Result: PASS against the existing, unmodified implementation — this closes a test-coverage gap only, zero production-code changes.
Phase 4: Dart — Display Fix and Permission Trigger (PR 2 scope)
Batch progress: 2/3 complete (Phase 4, tasks 4.1-4.8, all [x]).
- 4.1 RED: extend
test/pantallas/pantalla_ajustes_test.dart— scenario "platform name displays with no custom rename" — DONE, confirmed genuine pre-fix assertion failure (expected 'AirPods Pro', found 0 widgets). - 4.2 GREEN: modify
lib/pantallas/pantalla_ajustes.dart—_FilaDispositivo.build():eq.nombreVisible(deviceId, '')→eq.nombreVisible(deviceId, eq.nombrePlataforma(deviceId))— DONE. - 4.3 GREEN: modify
lib/pantallas/pantalla_ajustes.dart—_DialogoEdicionDispositivoState.initState():eq.nombreVisible(widget.deviceId, '')→eq.nombreVisible(widget.deviceId, eq.nombrePlataforma(widget.deviceId))— DONE. - 4.4 RED (triangulation companion, not independently RED — see note): "custom rename overrides platform name" — DONE, but honesty note: this scenario passes both before AND after the 4.2 fix, because
nombreVisible's custom-name branch short-circuits before ever inspectingplatformName. It is valuable integration-layer triangulation (proves custom-rename priority holds at the widget layer with a populated platform-name cache in play), not a fail-before/pass-after RED. Recorded here rather than silently mischaracterized. - 4.5 RED (approval/regression-lock, matches task's own "locks unchanged legacy behavior" framing): "no platform name yet falls back to raw id" — DONE, passes before and after by design (proves the fallback chain's final link is untouched).
- 4.6 RED: "permission call fires on device-management open" — DONE, confirmed genuine pre-fix assertion failure (
expected 1 call, found 0). Extended in-test (not a new task) with a second assertion after toggling back OFF, provingsolicitarPermisoBluetoothis gated onhabilitado == trueand not called unconditionally (triangulation). - 4.7 GREEN: modified
_SeccionEcualizadorAvanzado— bothonTap/onChangedhandlers now route through a new private_alternarMultiDevice(eq, habilitado)method:unawaited(eq.cambiarMultiDeviceEnabled(habilitado)), thenif (habilitado) unawaited(eq.solicitarPermisoBluetooth())— DONE.unawaited()(fromdart:async, newly imported) used per project'sunawaited_futures: truelint convention (matchesmain.dart/app.dartprecedent) since these are bare statements, not arrow-body callback expressions like the original single-line form. Trigger-point deviation from design's literal "on opening device-management UI" stands as previously flagged (toggle-turn-ON tap, not a StatefulWidget on-build hook) — still not converting_SeccionEcualizadorAvanzadoto StatefulWidget, per orchestrator sign-off. - 4.8 REFACTOR: verified —
ServicioDispositivoAudiois NOT registered as a top-levelProvideranywhere inapp.dart'sMultiProvider(confirmed by readingapp.dartand greppingestado_radio.dart:ServicioDispositivoAudioReal()is constructed once inline and stored asEstadoRadio._dispositivoAudio, a private field with no public getter). Resolved per the task's own fallback instruction: addedFuture<bool> solicitarPermisoBluetooth()passthrough onEstadoEcualizador(delegates to its existing private_dispositivoAudiofield) instead of adding new provider wiring. This also avoids breaking every existing widget test that builds_SeccionEcualizadorAvanzadowithout aServicioDispositivoAudioprovider in the tree (e.g. test 7.1-C) — a literalcontext.read<ServicioDispositivoAudio>()call site would have thrownProviderNotFoundExceptionthere.
Phase 5: Dart — One-Time Migration Purge (PR 2 scope)
Batch progress: 2/3 complete (Phase 5, tasks 5.1-5.10, all [x]).
- 5.1 RED: extend
test/servicios/servicio_ecualizador_test.dart— "migration removes only exact placeholder entries" — DONE, confirmed compile-fail RED (migrarClavesPlaceholderundefined) before GREEN. - 5.2 RED: "matrix keys purge only the placeholder segment" — DONE.
- 5.3 RED: "near-miss keys are preserved" (
bt_a2dp:02:00:00:00:00:01) — DONE. - 5.4 RED: "migration runs once" — DONE, strengthened beyond the literal task wording: re-seeds the placeholder key directly (bypassing the flag) between the two
migrarClavesPlaceholder()calls so the assertion proves the SECOND call is a true no-op via the flag short-circuit, not merely "nothing left to purge." - 5.5 RED: "no placeholder entries when BLUETOOTH_CONNECT was never requested" — DONE.
- 5.6 RED:
eq_nombres_dispositivos_v1purge scenario — DONE. - 5.7 GREEN: added
_keyPlaceholderPurgaHecha = 'eq_placeholder_purge_done_v1'and_placeholderMacLiteral = 'bt_a2dp:02:00:00:00:00:00'constants tolib/servicios/servicio_ecualizador.dart— DONE verbatim. - 5.8 GREEN: implemented
Future<void> migrarClavesPlaceholder() asyncexactly per the (a)/(b)/(c)/(d) spec in this task — DONE. Matrix-key extraction usesclave.indexOf(':')(first colon only, per multi-device-eq ADR-3 RFC4122-no-colons rationale already recorded in the design). - 5.9 GREEN:
cargar()now callsawait migrarClavesPlaceholder();as its first line — DONE. - 5.10 REFACTOR: ran full
servicio_ecualizador_test.dartsuite — 20/20 pass, including all pre-existing Phase 4/nombresDispositivos round-trip groups — DONE, zero regressions.
Phase 6: Localization (PR 3 scope) — N/A for this change
Only add new l10n keys if UI copy is actually shown for permission rationale or migration notice (spec: "IF UI copy is shown"). If Phase 4/5 tasks above ship with no new user-visible string (e.g., the permission request is silent/OS-dialog-only and no in-app migration banner is added), this phase becomes a no-op and MUST be explicitly marked skipped-by-design in the apply report, not silently dropped.
SCOPE DECISION (orchestrator, recorded here per instruction): N/A for this change. No in-app rationale sheet or migration-notice UI was implemented anywhere in Phases 2-5 — the permission request in _alternarMultiDevice (Task 4.7) fires the OS BLUETOOTH_CONNECT dialog directly with zero in-app copy beforehand, and the migration purge (Phase 5) is entirely silent (no banner/snackbar). The spec makes l10n conditional on "IF UI copy is shown"; none does, so Phase 6 does not apply. No UI copy was found to be unavoidable during implementation — nothing was stopped or flagged mid-task for this reason.
- 6.1 [decision gate] N/A — decided above: OS dialog alone, no in-app copy shipped. Tasks 6.2-6.6 skipped by design, not silently dropped.
- 6.2 GREEN (if in scope) — N/A, no in-app copy shipped.
- 6.3 GREEN (if in scope) — N/A, no in-app copy shipped.
- 6.4 RED — N/A, no new l10n keys were added.
- 6.5 GREEN — N/A, no rationale string exists to wire in.
- 6.6 REFACTOR — N/A,
flutter gen-l10nwas not re-run since no.arbfiles changed in this batch.
Phase 7: Manual/On-Device QA (all PRs — final gate before merge)
No instrumented/emulator test harness exists in this repo. Every item below requires a real or emulated Android 12+ device and is signed off by a human, not CI. Do NOT mark any of these done from code-reading alone.
- 7.1 [manual/on-device] Fresh install, never open device-management screen → confirm NO BT permission dialog appears at any point during normal app use (spec: "permission not requested at app launch")
- 7.2 [manual/on-device] Fresh install, open Settings → enable "Enable per-device EQ" toggle → confirm the system
BLUETOOTH_CONNECTpermission dialog appears before any BT device shows up in the known-devices list - 7.3 [manual/on-device] Grant the permission → pair/connect a real BT A2DP device → confirm the device row displays the device's own Bluetooth name (not a raw id), and inspect logs to confirm the underlying id is
bt_a2dp:<real MAC>, not the placeholder - 7.4 [manual/on-device] Deny the permission (or test on a build where it's denied) → connect a BT A2DP device → confirm the app does not crash, the device row still appears with a readable name (composite fallback), and no
bt_a2dp:02:00:00:00:00:00string appears anywhere in the UI or logs - 7.5 [manual/on-device] With permission granted, rename a connected BT device via the edit dialog → disconnect and re-pair the SAME physical device → confirm the custom rename is still shown and NO duplicate row appears in the device list
- 7.6 [manual/on-device] Connect two DIFFERENT real BT devices in sequence (permission granted) → confirm each gets its own distinct row with its own name/MAC, no collision
- 7.7 [manual/on-device] On an install that has pre-existing
bt_a2dp:02:00:00:00:00:00-keyed entries (simulate by seeding SharedPreferences via adb/debug tooling, or use a build from before this change that already has the corrupted key) → upgrade to this change → confirm the placeholder-keyed entries are gone after first load, the migration/rename-again notice (if shipped per Phase 6) is shown once, and any OTHER stable-MAC entries the user had are untouched - 7.8 [manual/on-device] Re-launch the app after 7.7's migration already ran once → confirm no second migration notice appears and no further data is altered (idempotency, matches Task 5.4's automated coverage but verified end-to-end)
- 7.9 [manual/on-device] If Phase 6 ships in-app rationale copy, switch the device's app language to at least 2 non-English locales (e.g.
es,ja) and repeat 7.2 → confirm the rationale text renders in the selected locale before the OS dialog - 7.10 [sign-off] Record pass/fail for 7.1-7.9 in the apply-progress artifact before this change is considered ready for
sdd-verify