Skip to content

Wear playlist transfer — PR 1+2: contrato compartido + Room de playlists locales - #8

Open
PonceGL wants to merge 49 commits into
dev-personalfrom
feature/wear-playlist-transfer
Open

Wear playlist transfer — PR 1+2: contrato compartido + Room de playlists locales#8
PonceGL wants to merge 49 commits into
dev-personalfrom
feature/wear-playlist-transfer

Conversation

@PonceGL

@PonceGL PonceGL commented Aug 8, 2026

Copy link
Copy Markdown
Owner

Primeros dos PRs del plan de transferencia de playlists al reloj, combinados en este PR tras un ajuste de alcance a mitad de trabajo (ver historial de comentarios). Sin lógica de negocio de transferencia todavía — solo contrato compartido e infraestructura de datos local en el reloj.

PR 1 — Contrato compartido + infra de test

  • WearPlaylistSync (nuevo DTO en :shared): snapshot de playlist (id, nombre, orden de canciones) que el teléfono envía al reloj antes que los audios.
  • WearDataPaths.PLAYLIST_SYNC: ruta Data Layer nueva para ese mensaje.
  • WearTransferProgress: añade STATUS_TRANSCODING, STATUS_AWAITING_WATCH_ACK y errorCode (default null, compatible con payloads antiguos).
  • Infraestructura de test para :shared y :wear — ninguno tenía source set de test. JUnit 5 + Truth en ambos; MockK + Turbine + Room-testing en :wear.

PR 2 — Room de playlists locales en el reloj + migración 5→6

  • LocalPlaylistEntity + LocalPlaylistSongCrossRef: identidad, membresía y orden de una playlist sincronizada. Sin FK hacia local_songs a propósito — la playlist sincroniza su orden completo antes de que termine de llegar el audio de cada canción.
  • LocalPlaylistDao: upsertPlaylist transaccional (reemplaza membresía completa, no la fusiona), consultas ordenadas por posición.
  • WearMusicDatabase: v5→v6, con índices en (playlistId, position) y songId.
  • Bug preexistente corregido de paso: las migraciones 1→5 estaban declaradas pero nunca registradas en Room.databaseBuilder(...) — cualquier reloj actualizando desde un esquema antiguo habría crasheado. Tocaba la misma línea para registrar la mía, así que registré las 5.
  • Tests instrumentados: LocalPlaylistDaoTest (upsert reemplaza no fusiona, orden por posición, aislamiento entre playlists) y WearMusicDatabaseMigrationTest (v5→v6 sobre un archivo SQLite construido a mano, ya que :wear no exporta esquema Room).

Riesgo conocido, sin verificar

WearMusicDatabaseMigrationTest construye el CREATE TABLE local_songs de v5 a mano porque :wear tiene exportSchema = false. Si esa DDL no coincide exactamente (tipos, nulabilidad) con lo que Room generaría de LocalSongEntity, la validación de esquema post-migración de Room podría fallar aunque la migración en sí sea correcta. No puedo descartarlo sin ejecutar en dispositivo — es el primer punto a verificar en la sesión de hardware.

Verificado

  • :shared:testDebugUnitTest → 7/7 en verde.
  • :wear:compileDebugKotlin, :wear:compileDebugAndroidTestKotlin, :wear:assembleDebug → compilan y ensamblan limpio (KSP/Hilt incluido).
  • :app:compileDebugKotlin → sin regresiones.
  • :wear:testDebugUnitTest → sin tests unitarios propios todavía (los tests de Room son instrumentados, no hay Robolectric en el repo); esperado.
  • Los tests instrumentados no se han ejecutado — no hay dispositivo ni emulador conectado en esta sesión. Se ejecutan en la sesión de verificación en hardware.

Ejecutado en local (JBR de Android Studio como JDK 21); ningún workflow de CI corre tests unitarios hoy.

PonceGL added 7 commits August 8, 2026 10:27
The module had no test source set at all. Mirrors :app's JUnit 5 +
Truth setup so the DTOs gain round-trip tests before other modules
start depending on their shape.
WearPlaylistSync carries a phone playlist's membership and order to
the watch, sent once up front so the watch can show and start playing
the list before every song has finished transferring. Adds the
/playlist_sync Data Layer path it travels on.
…ress

The playlist batch coordinator needs to report a transcode phase
before streaming starts, and to hand callers a machine-readable
failure reason instead of a free-text string. Both fields are
additive with safe defaults, so older payloads still decode.
The module had no test source set at all. JUnit 5 + MockK + Turbine +
Truth for unit tests, mirroring :app's stack; Room in-memory testing
+ AndroidJUnitRunner for instrumentation, needed for the upcoming
local-playlist DAO. No legacy JUnit 4 tests are planned here, so the
vintage engine isn't pulled in.

MainCoroutineExtension mirrors :app's copy — dispatchers aren't
injected anywhere in this repo yet, so tests swap the main dispatcher
globally instead.
LocalPlaylistEntity + LocalPlaylistSongCrossRef store a phone
playlist's identity, membership and order. No foreign key from the
cross-ref to local_songs on purpose: a playlist syncs its full order
before every song's audio has finished transferring, so a cross-ref
routinely points at a songId that doesn't have a local row yet.

Not wired into WearMusicDatabase yet — that's the next commit, since
it also needs a schema bump and a migration.
…igration chain

Adds MIGRATION_5_6 (creates local_playlists and local_playlist_songs,
plus their indices) and registers local playlist tables/DAO on
WearMusicDatabase.

Also fixes a preexisting bug while touching the same builder call:
MIGRATION_1_2 through MIGRATION_4_5 were declared but never passed to
.addMigrations(), so any watch still on an old schema would have
crashed on the next open instead of migrating. Wiring my own
migration through the same line means wiring all of them costs
nothing extra and actually fixes the app for those devices.
…>v6 migration

LocalPlaylistDaoTest covers upsert-replaces-not-merges, per-playlist
song ordering, and that a re-sync of one playlist leaves others alone.

WearMusicDatabaseMigrationTest hand-builds a v5 SQLite file (:wear
has exportSchema = false, so MigrationTestHelper's schema fixtures
aren't available here) and verifies MIGRATION_5_6 both creates the new
tables and leaves existing local_songs rows untouched.

Both need androidTestImplementation(kotlinx-coroutines-test) for
runTest, which the module's unit test config didn't cover.

Not yet run on a device or emulator — none is connected this session.
Verified so far: compiles clean (:wear:compileDebugAndroidTestKotlin),
including KSP/Hilt. Actual execution is deferred to the dedicated
hardware-verification pass.
@PonceGL PonceGL changed the title Wear playlist transfer — PR 1: contrato compartido + infra de test Wear playlist transfer — PR 1+2: contrato compartido + Room de playlists locales Aug 8, 2026
PonceGL added 21 commits August 9, 2026 09:59
Nothing in this codebase injects CoroutineDispatchers today — repo-wide
convention is Dispatchers.IO/.Main referenced directly, which fails
AND-CONC-03 and forces tests to depend on the real dispatcher. Rather
than migrate existing code (out of scope, high blast radius), these
qualifiers are for new code only: WatchAudioTranscoder is the first
consumer, needing an injected Main dispatcher so its Transformer setup
(which requires a Looper thread) is swappable in tests.
…h handoff

WatchAudioTranscoder decides whether a song needs re-encoding before
it goes to the watch, and performs that encode with media3-transformer
(already a declared dependency, previously unused anywhere in the
codebase).

Lossless/high-bitrate sources are transcoded to AAC-LC 128kbps —
watches decode AAC in hardware, and 128kbps keeps the Bluetooth
transfer time down until a Wi-Fi transport exists (a later phase).
Already-lossy sources at or under 256kbps pass through untouched:
re-encoding an already-small MP3 only costs CPU and quality for no
transfer-time benefit.

requiresTranscoding is a pure function, split out from the actual
encode specifically so it stays cheap to unit test — the encode path
itself needs a real hardware encoder and a Looper thread, so it can
only be verified on a device.
Pure size/time heuristics for the send-to-watch confirmation sheet,
computed only over songs that aren't already on the watch. Sizes a
song by its transcoded target bitrate when it'll be re-encoded, or by
its own bitrate when it'll pass through untouched.

The Bluetooth throughput assumption is deliberately conservative and
needs re-measuring against a real phone+watch pair once device testing
resumes — an estimate that undershoots erodes trust in the sheet more
than one that's a bit pessimistic.
…timator

WatchAudioTranscoderTest covers requiresTranscoding's full branch
matrix (lossless, passthrough bitrate boundary, unknown mime/bitrate,
case sensitivity) — 7/7 passing.

WatchPlaylistTransferEstimatorTest covers passthrough vs transcoded
sizing, that only pending songs are summed, and the zero/near-zero
edge cases — 5/5 passing.

Verified: :app:testDebugUnitTest, full suite, 426 tests, only the same
5 pre-existing failures unrelated to this branch (confirmed by running
the identical suite against a clean dev-personal worktree: 414 tests,
same 5 failures, same names).
…transcoded file

Adds a WatchAudioOverride hook to startTransferToWatch/performTransfer:
when present, it substitutes the song's own file resolution and
eligibility check entirely, streaming the given file with its own
mimeType/bitrate reported in the transfer metadata instead of the
original song's. The override file was just written locally by
WatchAudioTranscoder, so it's unconditionally eligible regardless of
where the source song actually lives (local file, cloud proxy, ...).

No behavior change for existing callers — audioOverride defaults to
null.
The rest of the wear/ package resolves these via
Wearable.getXClient(application) internally, which is fine for
production but can't be faked in a JVM unit test — mocking
Wearable's static factory methods needs MockK's inline-mocking agent,
and its dynamic self-attach hangs indefinitely in this environment's
sandboxing (confirmed: the worker process sat at 0% CPU for 5+
minutes). CapabilityClient/MessageClient are non-final abstract
classes, so injecting them lets a test construct a coordinator with
mocked instances directly, with no agent involved.

Only used by PlaylistWatchTransferCoordinator so far; the rest of the
package is unchanged.
PhoneWatchBatchTransferState is the aggregate (song counts, current
song, overall status) driven by PlaylistWatchTransferCoordinator;
PhoneWatchTransferState (existing) keeps tracking the active song's
byte-level progress under its own requestId. Same StateFlow-per-map
shape and terminal-cleanup pattern as the existing per-song transfers,
just keyed by batchId instead of requestId.
Sends a whole playlist to the watch: syncs membership/order first (so
the watch can show and start playing it before every song has
arrived), then transcodes and transfers pending songs one at a time —
never in parallel, to avoid saturating the single Bluetooth channel.
Reuses the existing single-song pipeline end to end via
PhoneDirectWatchTransferCoordinator's WatchAudioOverride hook.

A song missing from the library or that never reaches a terminal
state within the timeout is counted as failed rather than silently
skipped, so completed+failed always accounts for every song in the
batch.

Deviates from the usual CoroutineScope(SupervisorJob() + Dispatchers.X)
pattern seen elsewhere in this package: takes the injected @AppScope
scope instead of constructing its own, so the scope has an owner
(GEN-CONC-01). The song-transfer-await timeout is a settable instance
property, not a companion var, so tests can shrink it on their own
instance without mutating shared state other tests could see.
An active or just-finished batch takes priority over any concurrent
lone single-song transfer (e.g. from the song info sheet) in the
notification — it's the longer-running, more significant operation,
and showing both at once would make a single notification unreadable.
Content text reads "N of M songs" rather than a byte count, matching
the confirmation-sheet-to-notification UX: the user cares about song
progress, not bytes, for a playlist send.

The service now stays foreground as long as either transfers or
batchTransfers is non-empty, not just transfers.
…ne transfer state store

PlaylistWatchTransferCoordinatorTest (9 cases): empty playlist,
no reachable watch, playlist order preserved, dedupe of songs already
on every reachable watch, one song failing doesn't abort the batch,
mid-batch cancellation, timeout on a song that never confirms,
fan-out to multiple reachable nodes counted once per song, a missing
song counted as failed rather than dropped.

PhoneWatchTransferStateStoreTest (20 cases): covers both the batch
state this PR adds and the pre-existing per-song transfer state,
which had no test coverage at all before this. Doesn't assert on the
store's terminal-state cleanup — it runs on an internal, non-injectable
Dispatchers.Default scope after a real-time delay, so testing it here
would mean either a real sleep (GEN-TEST-04) or refactoring the
store's scope handling, out of scope for this change.

capabilityClient/messageClient are constructor-injected into the
coordinator specifically so they can be faked directly in the test
without mocking Wearable's static factory methods, which needs an
agent that hangs in this environment.

Verified: :app:testDebugUnitTest, full suite, 455 tests. Only the same
5 pre-existing failures unrelated to this branch (confirmed against a
clean dev-personal worktree earlier in this feature). The 29 new tests
in this PR (9 + 20) are all green.
estimateWatchTransfer, isPlaylistFullyOnWatch, sendPlaylistToWatch,
cancelPlaylistTransfer, and activePlaylistBatchTransfer — thin
delegation to PlaylistWatchTransferCoordinator/PhoneWatchTransferStateStore/
WearPhoneTransferSender, mirroring the exact pattern
SongInfoBottomSheetViewModel already uses for the single-song case.

Adds 4 constructor dependencies to an already-1200-line ViewModel.
Flagged, not fixed here — splitting it is a separate, unrelated
refactor.
…aylist screen

New action in the playlist options sheet — labeled "Send to Watch" or
"Update on Watch" depending on whether any of its songs are already
there. Tapping it refreshes watch availability and opens a
confirmation dialog showing pending-song count and the size/time
estimate (WatchPlaylistTransferEstimator, already built) before
anything is sent.

A non-blocking progress banner appears at the top of the songs list
once a batch is running for this playlist, with a cancel action — the
user can navigate away or leave the app while it continues; the
foreground notification (already built) is what tracks it from there.
A playlist batch takes priority over a concurrent lone single-song
transfer in the top bar badge and compact-navigation pill — same
priority rule as the transfer notification (WatchTransferForegroundService)
and the playlist screen's own banner: it's the longer-running, more
significant operation, and showing both at once would be unreadable.

WatchPlaylistBatchProgressDialog mirrors the existing single-song
WatchTransferProgressDialog's look (loading ring + percent, wavy
progress bar, cancel button) rather than reusing PlaylistDetailScreen's
banner — LibraryScreen already establishes badge-tap-opens-dialog as
its own convention for this, and a lone playlist name/song-count
doesn't need the full list context a banner implies.
Covers estimateWatchTransfer, isPlaylistFullyOnWatch (empty list,
partial, and fully-on-watch cases), sendPlaylistToWatch,
cancelPlaylistTransfer, activePlaylistBatchTransfer, and
refreshWatchAvailability. The rest of PlaylistViewModel's existing
surface (CRUD, sorting, AI generation, M3U import/export) is untouched
and out of scope — no PlaylistViewModelTest existed before this.

activePlaylistBatchTransfer is a stateIn(WhileSubscribed) flow —
reading .value directly never triggers the upstream collection, so
that test uses Turbine's test{} for a real subscriber instead.

Verified: :app:testDebugUnitTest, full suite, 462 tests. Only the same
5 pre-existing failures unrelated to this branch. The 7 new tests in
this PR are green.
WearDataListenerService now routes the PLAYLIST_SYNC message path to
WearTransferRepository.onPlaylistSyncReceived, which upserts the
playlist entity and its song cross-refs (order preserved via
position) into LocalPlaylistDao in one transaction.

Re-syncing an existing playlistId (e.g. after editing it on the
phone) replaces membership/order rather than merging with stale
cross-refs, and preserves the original createdAt while bumping
updatedAt — the DAO's upsertPlaylist already had this transactional
behavior from PR2, this just starts calling it.

The manifest's MESSAGE_RECEIVED intent filter gets a matching <data>
entry for /playlist_sync, mirroring the existing entries for the
other message paths.

Unlike the two pre-existing branches in the same when-block
(TRANSFER_METADATA, FAVORITES_SYNC_STATE), this new branch's catch
re-throws CancellationException instead of swallowing it — left the
other two alone since fixing them is out of scope here.
Backs the upcoming local-playlists screens. Resolves each playlist
song's availability reactively by joining its cross-ref order against
LocalSongDao.getAllSongs(), so a song that finishes transferring
while the detail screen is open flips from pending to playable
without the user backing out and re-entering.

playlistIdsReceiving surfaces which playlists currently have an
in-flight song transfer, for a receiving indicator on the list
screen. playAll/playFrom skip songs still pending transfer.
LocalPlaylistsScreen lists playlists synced from the phone with a
receiving indicator; LocalPlaylistDetailScreen shows a playlist's
songs in sync order, marking pending ones as disabled with a
'waiting to transfer' label instead of hiding them, so the list's
shape matches the phone immediately even before every song has
arrived. Both use androidx.wear.compose.foundation.lazy's items()
overload with an explicit key (playlist.playlistId / item.songId)
instead of the module's usual count-based items(n){} — needed here
because playlists reorder by updatedAt on every sync and songs flip
availability while the screen is open, both of which lose state and
break animations without a stable key.

DownloadsScreen gets a new 'Playlists' entry navigating into the new
screens. Reachable via Downloads → Playlists → a playlist → its
songs.
Persists at most one in-flight playlist batch transfer intent
(batchId, playlistId, playlistName, songIds, requestedAtMillis) to
the app's shared DataStore<Preferences>, matching the existing
*PreferencesRepository convention.

Deliberately doesn't persist the rest of PhoneWatchTransferStateStore
(per-song byte progress, reachable nodes, ...) — that's UI-only
state, cheap to rebuild, and churns too fast to persist sensibly.
Only the intent needs to survive a process restart; the coordinator
already re-derives everything else when it runs a batch.

clearInFlightBatch(batchId) only removes the stored intent if its
batchId still matches — if a newer batch already overwrote it (e.g.
the user sent another playlist before the first one's cleanup ran),
clearing unconditionally would drop that newer intent instead.
PlaylistWatchTransferCoordinator now persists its batch intent when
a transfer starts and clears it on every terminal outcome (completed,
failed with no reachable watch, cancelled) — so a batch surviving to
the next app start is exactly the ones that were cut off by the
process dying mid-transfer, not a theoretical case for a transfer
that can run tens of minutes over Bluetooth.

resumePersistedBatchIfNeeded() re-runs any such orphaned batch: it
refreshes the watch-library snapshot first (empty right after a cold
start) and waits briefly for it to resolve, so the existing dedup
against what's already on the watch is accurate on the first pass
instead of relying solely on the watch's own duplicate rejection.
Re-running from scratch is safe either way — the watch rejects a
transfer for a song it already has.

Wired into PixelPlayApplication.onCreate(), alongside the app's other
one-shot startup work. Best-effort: a cold start not directly
triggered by the user may be too restricted to start the foreground
service this resumes into, so failures here are logged and skipped
rather than crashing app startup — the persisted intent stays put
for the next launch that can.
Found on real hardware: the watchdog firing only flipped bookkeeping
state (activeTransfers -> FAILED, pendingMetadata cleared) while the
coroutine actually reading the ChannelClient InputStream kept running,
completely unaware anything had happened. Two ways that went wrong in
one 6-song transfer over Bluetooth with a connected BT headset:

- A song reported 'timed out' but the read loop kept going anyway and
  finished successfully seconds later — a false alarm, but the
  watchdog re-armed on every remaining read, so the same requestId
  could fire repeatedly (observed 2, 3, and 4 times on one transfer).
- Worse: firing cleared pendingMetadata out from under the still-live
  loop, so when it finally tried to resolve metadata to finish writing
  the file, it found nothing — 'Transfer metadata missing', the file
  got deleted, and the song was lost for real despite every byte
  having arrived over the wire.

Root cause: no link between the watchdog and the actual I/O. Now
armTransferWatchdog closes the live InputStream (tracked per
requestId in openAudioStreams) when it fires, which unblocks the
loop's read() with an IOException and routes it through
onAudioChannelOpened's own catch block for one consistent cleanup
path — instead of the watchdog declaring failure independently.
watchdogTimedOutRequestIds lets that catch block report 'Transfer
timed out' instead of a generic stream-closed message.

Not unit-testable as-is: the watchdog is a real delay() with no
injected clock, and onAudioChannelOpened is private. Verified by code
review against the exact failure sequence from hardware; needs
re-verification on-device (same scenario: BT headphones connected,
transfer running) before calling it confirmed fixed.
Found on real hardware: once a song's transfer failed, its playlist
kept showing the 'Receiving…' badge forever. playlistIdsReceiving
treated mere presence in WearTransferRepository.activeTransfers as
'in progress' — but a failed entry deliberately stays in that map
(DownloadsScreen lists it under 'Transfer issues'), it's just no
longer active. Now only STATUS_TRANSFERRING counts.
PonceGL added 21 commits August 9, 2026 11:54
The plan's own risk table already called for this ('reintento con
backoff') but it was never implemented. Real hardware testing showed
why it matters: 2 of 6 songs in one batch failed outright, both
consistent with a Bluetooth stall from the watch's radio being shared
with a connected BT headset — a real, non-theoretical condition, not
a broken link.

transferSongToAllNodesWithRetry re-attempts once, after a short fixed
backoff, before the coordinator gives up on a song. Re-transcodes on
the retry rather than caching the first attempt's output — simpler,
and cheap enough on a modern phone's hardware encoder to not be worth
the extra bookkeeping.
…ility

fix(wear): confiabilidad de la transferencia de playlists — hallazgos de la primera prueba en hardware
A playlist synced to the watch used to show a song's raw internal id
in the pending-songs list — the only thing it knew about a song it
hadn't received yet. WearPlaylistSync now carries songTitles, a
parallel list to songIds (kept separate rather than restructuring
songIds itself, so an older watch or phone build still decodes the
payload cleanly via default values / ignoreUnknownKeys).

The phone resolves titles once, up front, alongside the existing
per-song library lookup used for transcoding — no extra querying.
The watch stores each cross-ref's pendingTitle (Room v6->v7,
MIGRATION_6_7 adds the column, defaulting existing rows to '').
WearLocalPlaylistSongItem.displayTitle picks the real title once the
song has actually arrived, falling back to the phone-provided pending
title, and only the raw id as a last resort for a sync from a phone
build old enough to not send one.
The banner PlaylistDetailScreen shows while a playlist batch is
sending to the watch looked bolted onto the screen rather than part
of it: 20dp horizontal padding against the 16dp every other row in
that same options list (PlaylistActionItem, right below it) actually
uses, a plain LinearProgressIndicator, and a full-width text-only
cancel button laid out awkwardly next to it.

Now it matches PlaylistActionItem's own treatment — the same 40dp
icon badge, 16dp padding, 18dp corner radius — and uses
LinearWavyProgressIndicator, matching WatchPlaylistBatchProgressDialog
(LibraryScreen's own send-to-watch progress modal) instead of a plain
bar. The cancel action is now an icon-only IconButton instead of a
separate labeled TextButton competing for space in the same row.

Also adds the currently-transferring song's name to both this banner
and WatchPlaylistBatchProgressDialog — until now the phone's own
system notification showed more detail than the in-app UI did.
Starting a song from deep inside Downloads -> Playlists -> a playlist,
or from Downloads' own song menu, left the user several swipes-back
away from the transport controls they just asked for. Both screens
now take an onPlaybackStarted callback, fired right after the local
(or phone) playback call; WearNavigation wires it to navigate to
PLAYER with the intermediate stack popped, so a swipe-back from
Player doesn't retrace the whole browse path either.
…ture

25 strings introduced across PR1-7 and the two commits above only
existed in the default (English) resources — values-es never got
updated alongside them. Backfills all of them, matching each file's
existing key order and tone.
feat: pulido de UI/UX de la transferencia de playlists al reloj
…ch's RAM

Two zero-risk fixes identified from on-device evidence during the watch-transfer
hardware test cycle (choppy local playback, both with and without a concurrent
transfer):

- WearPlaybackService built its ExoPlayer with the default MediaSourceFactory,
  which registers ~15 extractor types (Matroska, FLV, AVI, MPEG-TS…). ART
  verifies each extractor class the first time DefaultExtractorsFactory touches
  it while sniffing the container — on the main thread. Measured on-device this
  cost 120-300ms per unused class, ~2s total, stacked right on top of playback
  start (visible as `Choreographer: Skipped N frames` right after pressing
  play). Every file this service plays comes from WatchAudioTranscoder on the
  phone, which always writes plain (non-fragmented) MP4/AAC-LC, so the
  extractor is now scoped to just that.

- ExoPlayer's default DefaultLoadControl buffer window isn't sized for a Wear
  OS SoC. Measured on-device: sustained PLAYING/BUFFERING oscillation every
  0.5-1s during local playback. Mirrors the phone's
  DualPlayerEngine.buildAdaptiveLoadControl() RAM tiering (same
  ActivityManager.isLowRamDevice signal, same reasoning, amplified — a watch
  has far less RAM/CPU headroom than even a low-end phone, and shares both
  with whatever fitness app is running alongside).

Neither fix addresses the dominant cause (no audio offload — decode/render
still competes with Compose on the main thread) or the confirmed risk of the
whole process being recycled under system-wide memory pressure while another
app runs concurrently; both are follow-up work.
…t fallback

The dominant cause of the choppy local-playback experience confirmed on-device
this session: WearPlaybackService's ExoPlayer decodes and renders audio on the
same CPU/main-thread the Compose player screen recomposes on. Screen off/AOD
(far less Compose work) measured noticeably smoother than screen-on, pointing
squarely at CPU contention rather than the Bluetooth radio.

Requests AUDIO_OFFLOAD_MODE_ENABLED (soft — if the watch's audio HAL doesn't
support offloading AAC-LC, ExoPlayer silently falls back to normal decode on
its own, no capability probing needed here for that path).

What ExoPlayer does *not* self-heal is a HAL that accepts the offloaded track
and then stalls/resets shortly after — the exact failure mode that already
forced the phone's DualPlayerEngine to build a runtime fallback
(shouldDisableAudioOffloadOnEarlyBuffering / AudioOffloadPolicyTest in :app).
There's no comparable per-chipset denylist for Wear OS yet — no field evidence
of which watch chipsets misbehave — so this mirrors the phone's *runtime*
safety net (wearShouldFallBackFromAudioOffload, simplified for this service's
single-player model, no crossfade state to account for) instead of guessing at
one. On a detected reset it rebuilds the player with offload off and swaps it
into the existing MediaSession via setPlayer(), preserving playlist/position/
playWhenReady and keeping the phone's remote-control MediaController connected
across the swap.

Intentionally duplicated rather than extracted to :shared: DualPlayerEngine is
already flagged as a God Object (pre-existing debt, out of scope here), and
the two services differ enough (single player vs. dual-player crossfade) that
sharing would mean threading wear-only parameters through phone-only code for
a ~30-line function.

6 new tests for the pure fallback decision (WearAudioOffloadPolicyTest,
mirrors AudioOffloadPolicyTest's early-buffering cases). The player-swap
wiring itself (Player.Listener → MediaSession.setPlayer) is not unit-testable
without instrumentation — needs on-device confirmation via
`dumpsys media.audio_flinger | grep -A20 -i offload` to see whether offload
actually engages on this watch's hardware, and whether the fallback fires
correctly if it doesn't behave.
…ayback

Two things flagged by the original plan's audit of this module, acted on now:

1. `collectAsState()` instead of `collectAsStateWithLifecycle()`, across all of
   `:wear` (11 files, ~45 call sites) — every single StateFlow collection in
   the module was lifecycle-unaware before this commit. `collectAsState` keeps
   collecting with the screen off/backgrounded; the watch's own screen-off
   rule ("the UI should cost zero") is incompatible with that, and it's
   exactly what a battery-constrained device with a running app fighting for
   the same CPU cannot afford. PR15 already fixed 3 call sites in the two
   local-playlist screens; this closes the rest of the module in one pass
   since the fix is identical and mechanical everywhere.

2. `rememberLivePositionMs` (PlayerScreen) ticked every 250ms purely off
   `state.isPlaying`, with no regard for whether the screen was actually
   interactive — despite `WearLifecycleState`'s own doc comment explicitly
   naming this as the pattern it exists to prevent: "position-update jobs...
   should gate on isInteractive so they pause as soon as the activity moves
   to the background or the watch enters ambient mode." Confirmed on-device
   this session: local playback measured noticeably more stutter with the
   screen on (more Compose work) than in ambient/off (WearLifecycleState
   already reports ambient correctly), and ambient still wasn't perfectly
   smooth — this loop, un-gated, is a plausible contributor to that residual
   stutter. Gated it, and applied the identical fix to
   `rememberActiveLyricLineIndex`, which sits one page away in the pager
   (`beyondViewportPageCount = 1`) and can keep ticking while the user is
   looking at the main player page instead.

   `livePositionFromAnchor` always computes from elapsed real time, so
   freezing the display between ticks while not interactive loses nothing —
   the position snaps to the correct value the moment interactivity returns.

Left untouched (documented, not silently ignored): `CenterPlayButton`'s
continuous 13.8s-rotation animation on the play button is already gated on
`WearLifecycleState.isInteractive` via its LaunchedEffect, and is a
deliberate decorative choice, not a bug — redesigning it wasn't asked for and
isn't done here. Its ring-drawing Canvas already computes on the draw phase;
the meaningful cost while playing is the animation itself, which offload
(the previous PR in this stack) addresses more directly than a recomposition
fix could, by taking audio decode off this same CPU/thread entirely.

`./gradlew :wear:compileDebugKotlin :wear:testDebugUnitTest` — clean build,
no warnings, 29 tests in verde. Mechanical/low-risk except the two
produceState gating changes, which are behavior changes confirmable only on
a real watch (does the position display correctly resume on waking from
ambient / foregrounding?).
Confirmed on-device this session, not theoretical: the app's process was
recycled during a system-wide low-memory episode while a fitness-tracking app
ran alongside local playback (PID changed across ~90s in which 23 other
system processes were also killed for memory). WearPlaybackService being a
foreground MediaSessionService makes that less likely, not impossible — Wear
OS watches have very little RAM. Before this commit, that kind of process
death silently dropped the queue and position with no way back except
manually re-navigating to the playlist and hitting play again.

Adds WearPlaybackStatePersistence (DataStore<Preferences>-backed, mirrors
:app's PlaylistBatchTransferPersistence — same single-slot,
JSON-via-kotlinx.serialization shape, separate DataStore file since :wear and
:app are different processes with no shared storage). WearLocalPlayerRepository
saves (queueSongIds, currentIndex, positionMs) on the events that matter
(play/pause, track change) plus a coarse 10s tick while playing — not on every
1s UI tick, which would be pure DataStore-write battery cost for no benefit —
and clears it on a deliberate stop.

On a fresh WearPlayerViewModel (i.e. a fresh process), restorePersistedPlaybackIfAvailable()
resolves the persisted song ids against the watch's current local library
(songs may have been deleted since the snapshot), restores what's still
there paused — not auto-playing, since starting audio without a fresh user
gesture on app open would be surprising — and, if it actually restored
something, flips WearStateRepository's outputTarget to WATCH. That target
flip matters: outputTarget isn't itself persisted (always starts at PHONE),
so without it the restored queue would sit in WearLocalPlayerRepository
correctly but never surface in the unified playerState the Player screen
reads. A successful restore is exactly the signal that the user was on
watch-local playback when the process died.

isPersistedLocalPlaybackStateRestorable additionally requires the snapshot
to be no older than 6 hours and to have a valid queue/index — a
conservative, undedicated-hardware-calibrated window (no on-device data
exists to tune it precisely), picked to cover realistic crash-recovery gaps
without silently resurrecting a queue from days ago the next time the app
happens to open.

12 new tests (WearPlaybackStatePersistenceTest, mirrors
PlaylistBatchTransferPersistenceTest's DataStore-round-trip style;
WearPlaybackStateRestorabilityTest for the pure freshness/validity check).
The actual restore-on-boot wiring and the "does the notification/foreground
state look right" behavior are not unit-testable — need on-device
confirmation, ideally by reproducing a real process kill (not just a normal
app close) during active local playback.

`./gradlew :wear:compileDebugKotlin :wear:testDebugUnitTest` — clean build,
41 tests in verde.
…M del reloj (#17)

perf(wear): PR1+PR2 — extractor MP4-only y buffering ajustado a la RAM del reloj
feat(wear): PR3 — audio offload con fallback ante reset del HAL
…de vida (#19)

perf(wear): PR4 — auditoría de recomposición sin conciencia de ciclo de vida
…erte del proceso (#20)

feat(wear): PR5 — persistir y recuperar la reproducción local tras muerte del proceso
Real hardware testing (transferring a 2nd playlist while local playback
kept running on the watch) showed the watch ending up with every song's
audio on disk but no playlist row to show them under: the playlist_sync
message the phone sends before any song audio has no retry and no
delivery confirmation, so a watch that's mid Wi-Fi/ADB reconnect right
when it's sent loses it silently — confirmed by pulling wear_music.db
off the device and finding the songs in local_songs with zero rows in
local_playlist_songs for that playlist.

WearPlaylistSync now carries a requestId; the watch acks back
(WearPlaylistSyncAck, new /playlist_sync_ack path) once the sync is
durably applied. PlaylistWatchTransferCoordinator waits for that ack per
node and retries once (same shape as the existing per-song retry) before
giving up — giving up doesn't fail the batch, songs still transfer, and
the next explicit re-sync is idempotent and gets another chance.

Both DTO fields default for backward compatibility, same pattern as the
existing songTitles field.
Real hardware testing on a Samsung Galaxy Watch (release build) showed
songs stopping partway through and never resuming: Play did nothing
(playWhenReady was already true, so there was nothing to resume) while
Next still worked (it rebuilds the sink for the new item). Confirmed
that's not a process death — the UI stayed responsive and Next kept
working — so the existing audio-offload safety net from PR18
(WearAudioOffloadPolicy) doesn't cover this: it only reacts to a stall
within ~500ms of playback starting, surfaced as STATE_BUFFERING. This
one happens well after that window and the player's reported state
never changes, so there's no listener callback to hook.

Added a position-polling watchdog (WearPlaybackStallWatchdog) that ticks
once a second and compares the player's own reported position against
the previous tick: 3 consecutive ticks with zero movement while
isPlaying=true is read as a wedge and triggers the same player-rebuild
recovery PR18 already has (extracted the shared "preserve queue/
position/play-state, build a fresh ExoPlayer, swap via
MediaSession.setPlayer" logic out of fallBackFromAudioOffload so both
paths use it). Unlike the early check, this doesn't require offload to
still be enabled — the rebuild is the actual fix regardless of cause.

Verified :wear release (assembleRelease, R8 + shrinkResources) still
builds clean with the new coroutine-based watchdog.
…ease

Real hardware testing with release builds on both sides showed the
phone's "send playlist to watch" dialog always reporting "no watch
connected" and the button disabled — even right after confirming the
watch was reachable (phone->watch remote-control mode worked fine).
Waiting, toggling Bluetooth, and reinstalling didn't help, which ruled
out a capability-sync timing issue.

Root cause, confirmed via wear/build/outputs/mapping/release/resources.txt:

    array:android_wear_capabilities:... is not reachable.

isShrinkResources=true (release only) strips
res/values/wear_capabilities.xml's <string-array
name="android_wear_capabilities"> because nothing in the app's own code
ever references it by R.array id — it's discovered purely by resource
NAME convention by Play Services, to advertise this app's
"pixelplay_wear_app" capability (WearCapabilities.PIXELPLAY_WEAR_APP).
The shrinker's reachability analysis has no way to know that, so it
correctly-by-its-own-rules but wrongly-for-us marks it unused and
removes it — meaning the release watch app never advertised the
capability the phone's CapabilityClient.getCapability(FILTER_REACHABLE)
call looks for at all. Remote-control mode kept working because that
uses DataItem/message sync to connected nodes directly, unrelated to
capability advertisement.

Added res/raw/keep.xml with tools:keep, the standard AGP resource-
shrinker escape hatch for resources reached only by reflection/naming
convention rather than code reference. Verified against the rebuilt
release mapping report: the same line now reads "reachable from keep
xml file".
fix(wear): retry + ack playlist sync so it can't be silently lost
…22)

fix(wear): recover from mid-song offload stalls, not just early ones
…ease (#23)

fix(wear): keep the Wear capability resource from being shrunk in release
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant