Impl/wear playlist transfer#2530
Draft
PonceGL wants to merge 30 commits into
Draft
Conversation
The wear module had no test source sets or dependencies configured at all, unlike app. Mirrors app/build.gradle.kts: JUnit5 + MockK + Turbine + Truth for local unit tests, and androidx.room.testing + AndroidJUnit4 for instrumented DAO/migration tests, needed for the upcoming playlist transfer feature.
Adds the shared Data Layer contracts needed for sending a whole playlist to the watch: WearDataPaths.PLAYLIST_SYNC(_ACK), the WearPlaylistSync payload, a STATUS_TRANSCODING transfer state, and a freeStorageBytes field on WearLibraryState so the phone can warn before a transfer that won't fit. Also adds unit test infrastructure to the shared module (it had none) and round-trip serialization tests for the new/changed models.
WATCH_LIBRARY_STATE messages from the watch were silently dropped on the phone (fell into the "unknown message path" branch), and PhoneWatchTransferStateStore.updateWatchSongIds was dead code with no caller. Neither was actually wired end to end. Wires WearCommandReceiver to handle WATCH_LIBRARY_STATE and forward both songIds and the new freeStorageBytes into the store, and has the watch populate freeStorageBytes via StatFs on its songs directory when publishing library state.
Adds WatchAudioTranscoder: re-encodes lossless/high-bitrate sources to AAC-LC ~256kbps via Media3 Transformer before they go to the watch (hardware-decoded there, unlike FLAC), and passes already-compliant lossy sources (<=256kbps) through untouched. Sample rate is left to Transformer/DefaultEncoderFactory, which already clamps to the closest rate the chosen encoder supports. Not yet wired into the actual transfer flow — that lands with the batch coordinator in the next phase.
Adds PlaylistWatchTransferCoordinator: syncs a playlist's membership/ order to the watch first, then sends songs not already there one at a time (never in parallel), transcoding each via WatchAudioTranscoder when needed. Cancelling a batch stops the remaining queue but leaves already-completed songs on the watch. PhoneWatchTransferStateStore gains a PhoneWatchBatchTransferState aggregate (StateFlow<Map<batchId, ...>>) so the UI can show one progress row per playlist transfer instead of one per song. PhoneDirectWatchTransferCoordinator gets a minimal additive hook (overrideAudioFile) so it can stream a transcoded temp file instead of the original — the single-song transfer path is unchanged when this param is omitted.
Pure size/time heuristic for the send-to-watch confirmation sheet, summed only over songs not already on the watch. Reuses WatchAudioTranscoder.requiresTranscoding to know whether a song counts at its own bitrate (passthrough) or the target AAC bitrate (transcoded).
Exposes reachable-watch availability, per-node free storage, a size/time estimate helper, and send/cancel entry points into PlaylistWatchTransferCoordinator so PlaylistDetailScreen can drive the whole-playlist send-to-watch flow without touching the coordinator directly.
Adds "Send to watch" / "Update on watch" to the playlist options bottom sheet (label flips once every song is already on a reachable watch), refreshing watch availability on entry and exposing watchSongIds from PlaylistViewModel so the label/state stay reactive. Confirmation sheet and batch progress dialog land in follow-up commits.
SendPlaylistToWatchSheet shows song count, estimated size/time from WatchPlaylistTransferEstimator, and blocks confirm when there's no reachable watch or the estimate exceeds the watch's free storage. WatchPlaylistBatchProgressDialog mirrors the existing single-song transfer dialog but drives off PhoneWatchBatchTransferState (aggregate progress, current song, cancel), auto-hiding once the batch clears.
…ation WatchTransferForegroundService now observes batchTransfers alongside the existing per-song transfers map and, whenever a playlist batch is active, shows "Sending playlist X (12/40)" with aggregate progress instead of listing individual songs. Falls back to the prior per-song notification when there's no active batch.
wear/build.gradle.kts sets testInstrumentationRunner to androidx.test.runner.AndroidJUnitRunner, but nothing on the androidTest classpath actually provided that class (androidx.test.ext:junit only pulls in androidx.test:monitor) — any connectedAndroidTest run in this module crashed with ClassNotFoundException before a single test could execute. The app module works only because espresso/compose-ui-test happen to pull androidx.test:runner transitively.
LocalPlaylistEntity + LocalPlaylistSongCrossRef store a playlist snapshot and song order on the watch; song availability is resolved by joining against LocalSongDao at read time rather than duplicated here, since a playlist can exist before (or without) all of its songs having finished transferring. LocalPlaylistDao.upsertPlaylist replaces the full cross-ref set per sync so re-sending an updated playlist always reflects the latest order instead of merging with stale rows. Also wires MIGRATION_1_2..MIGRATION_5_6 into the Room builder in WearModule — they were defined but never passed to addMigrations(), so any upgrade of an existing watch install would have crashed with "migration not found" the moment this database was opened. Verified against a real Wear OS emulator (connectedDebugAndroidTest).
WearDataListenerService now handles WearDataPaths.PLAYLIST_SYNC, deserializing WearPlaylistSync and delegating to the new WearTransferRepository.onPlaylistSyncReceived, which upserts the playlist snapshot via LocalPlaylistDao. Song availability is still resolved separately by joining against LocalSongDao, so the playlist becomes visible immediately even before any of its songs finish transferring. Preserves the original createdAt across re-syncs (e.g. "update on watch" after adding a song) while always bumping updatedAt and replacing the song order outright, matching the snapshot-not-merge model already used by LocalPlaylistDao.upsertPlaylist. Verified: :wear:testDebugUnitTest (new WearTransferRepositoryPlaylistSyncTest, mocks LocalPlaylistDao so no Room/instrumentation needed) and a manual install + launch on the Wear_OS_XL_Round emulator with no crash.
New LocalPlaylistsScreen (list) and LocalPlaylistDetailScreen (song order), backed by WearLocalPlaylistViewModel, which reactively joins LocalPlaylistDao's synced song order against LocalSongDao.getAllSongs. Songs still in flight show as a dimmed, non-tappable "Waiting to transfer" row with a cloud icon; a song that finishes downloading while the screen is open flips to playable without navigating away and back. "Play all" and per-song taps build the ExoPlayer queue from only the already-transferred songs, skipping pending ones outright rather than leaving gaps. Entry point is a "Playlists" chip on DownloadsScreen (new onPlaylistsClick param), not the existing BROWSE/LIBRARY_LIST/SONG_LIST routes — those are wired to the phone's live remote library, not this local Room snapshot. Verified: new WearLocalPlaylistViewModelTest (availability join, playAll/playFrom queue building) plus manual verification on the Wear_OS_XL_Round emulator with seeded playlist/song rows — confirmed both screens render correctly (title, song count, Play all, available vs. pending rows) with no crash. Touch input via `adb shell input tap` doesn't register on this emulator image (environment issue, not code — swipe gestures work fine), so navigation was exercised by temporarily swapping the nav graph's start destination rather than tapping through from Downloads; that temporary change is not part of this commit.
…ilure WearLocalPlayerRepository.playerListener had no onPlayerError override, so a song that failed to decode (more likely now that heterogeneous transcoded content arrives in batches) would just hang playback. Skip to the next queued song automatically and expose a transient message via a new localPlaybackError StateFlow, shown as a short banner in PlayerScreen.
WearDataListenerService already handled WearDataPaths.PLAYLIST_SYNC in code (Fase 6), but the intent-filter never listed it, so Play Services silently dropped every playlist-sync message before it reached the app — individual songs still transferred fine over their own declared paths, which is why this only showed up as "songs arrive but no playlist groups them." Also renames the local-playlists label to "My Playlists" to disambiguate it from the pre-existing (and unrelated) remote "Playlists" browse chip.
… transfer completion The phone was marking a song "present on watch" purely because it finished writing its own send stream, never waiting for the watch to actually validate and finalize the file. If the connection dropped right after, the phone's belief could drift from reality with no automatic correction (the only reconciliation path, refreshWatchLibraryState, only fires from specific UI screens). Now the phone records a local-only STATUS_AWAITING_WATCH_ACK instead of optimistically completing, and the watch reports its real, validated outcome back over the existing /transfer_progress path (reusing and generalizing notifyPhoneTransferFailure into notifyPhoneTransferResult, now also used for the success case). WearCommandReceiver previously had no case for that path at all and silently dropped it — this also revives the existing-but-dead duplicate-rejection flow as a side effect.
…e batch PlaylistWatchTransferCoordinator.transferSongToNode awaited a terminal transfer status forever with no backstop. If the watch died or dropped off before ever sending an ack (its own 120s idle watchdog never even got to fire), the whole batch would hang indefinitely on that one song instead of failing it and moving on. Adds a 5-minute timeout (generous relative to the watch's 120s watchdog, to tolerate slow transcoding + slow Bluetooth) as a fallback; on expiry the song is marked failed with a distinct "timed out" error code and the batch continues with the next song, same as the existing cancel behavior.
Previously a full disk only surfaced as whatever generic IOException the write happened to throw, with no distinct signal for the phone. Now the receive loop checks remaining free space against the bytes still needed every watchdog tick and fails early with ERROR_CODE_INSUFFICIENT_STORAGE before the OS throws, so the phone can show a specific "not enough space" message instead of a generic failure. Also tags the existing idle-timeout watchdog failure with ERROR_CODE_CONNECTION_LOST so the phone can distinguish it from other failure reasons too.
…eens LocalPlaylistsScreen and LocalPlaylistDetailScreen had no indication a transfer was in progress — the only feedback lived on DownloadsScreen and SongListScreen. Reuses the same WearTransferRepository.activeTransfers StateFlow and spinner+percentage pattern already established there instead of inventing new state: a playlist row shows "Receiving..." while any of its songs are actively transferring, and a pending song row shows live percentage instead of a static "waiting" label once its transfer starts.
…o the user The batch and single-song progress dialogs had no way to show the new AWAITING_WATCH_ACK phase (progress would sit at 100% with no explanation before the watch's ack landed), and a batch that finished with failed songs gave no indication of what went wrong or that anything failed at all. Adds a "Confirming on watch..." status, a per-batch failure summary that distinguishes storage-full vs connection-lost vs generic reasons (using the error codes from the earlier ack/timeout/storage work), and two advisory lines in the send-to-watch sheet about keeping the watch nearby and that backgrounding the app is safe.
…sfers Prevents CPU deep-sleep from silently stalling a long-running transfer once the screen turns off, which could otherwise outlast our own idle-timeout watchdogs and mark a healthy-but-slow transfer as failed.
…eground service while receiving Wear OS is more aggressive than phone Android about suspending background processes on screen-off/ambient, and receiving is purely reactive (driven by WearableListenerService callbacks). Without this, a long playlist transfer left running overnight risks the process being paused mid-transfer with nothing to resume it. Started from WearDataListenerService at the two entry points where a receive begins (transfer metadata, audio channel open); holds a PARTIAL_WAKE_LOCK for the same reason as the phone side. The reactive stop-if-empty check only fires once the service has genuinely started foreground at least once — onCreate() begins collecting activeTransfers before onStartCommand() has run, so the flow's current (still-empty) value can arrive first and otherwise trigger stopSelf() before startForeground(), which crashes the process with ForegroundServiceDidNotStartInTimeException (reproduced and confirmed fixed on a physical Galaxy Watch5 Pro).
…oreground service Missed alongside the previous commit — used by WearTransferForegroundService's notification title/placeholder text.
…istic transfer estimate Halves transcoded file size (and transfer time) for lossless/high- bitrate sources. 128kbps AAC-LC is the de facto standard for "high quality portable" audio, an easy trade-off for gym/running use — not aimed at the audiophile-at-home listening context. Also fixes PhoneDirectWatchTransferCoordinator's own hardcoded TRANSCODED_BITRATE_BPS, a separate copy of the same value that would have silently drifted from WatchAudioTranscoder.TARGET_BITRATE_BPS the moment only one of the two got changed; now references the single source of truth directly. WatchPlaylistTransferEstimator's assumed throughput was also replaced with a figure measured on a real phone+watch pair (~40KB/s over the Wearable Data Layer ChannelClient) instead of the previous ~200KB/s guess, which made the confirmation sheet's time estimate wildly optimistic during physical-device testing.
…not only once all have A playlist sent in multiple partial sessions (the common case for large playlists on a slow link) previously kept showing "Send to Watch" — implying a first-time send — until every single song had arrived, even though most of it was already there. Found during physical-device testing.
…weak hardware On-device profiling (2-core Wear SoC) traced audible playback stutter to the watch's own continuous Compose animations (now-playing eq icon, play-button rotation) pegging the main thread, which starved ExoPlayer's decode/render threads long enough to cause AudioFlinger underruns. - Add WearDeviceTier: gates non-essential continuous UI work by core count. - PlayingEqIcon: static shape on constrained tier, cheap low-freq tick on capable tier, instead of two continuous per-frame Animatable tweens. - CenterPlayButton rotation and the live position ticker: only skip on constrained tier while local (on-watch) playback is active, since remote (phone-controlled) playback does no local decode work and has CPU to spare. - WearPlaybackService: larger AudioTrack buffer (1-3s) via a custom DefaultAudioTrackBufferSizeProvider, so transient CPU stalls (ours or a future concurrent app, e.g. workout tracking) don't reach the ear. - WearLocalPlayerRepository: gate the Room-flow-triggered updateState() by isInteractiveNow, consistent with startPositionUpdates().
…nuous scale currentSongProgress was fed by two different 0f..1f fractions on the same field (transcode, then transfer-byte progress), causing a visible jump-then-reset in the batch progress UI each time a song moved from one phase to the other. Weight transcode into 0-30% and transfer into 30-100% instead, and let markBatchSongStarted take an explicit initial progress so re-targeting activeRequestId to the per-node transfer request no longer snaps progress back to 0.
…ibraryScreen LibraryScreen's "sending to watch" chip only knew about individual song transfers, never playlist batches. Since a batch's songs still flow through the same per-song transfer pipeline, the chip would show during a batch too, but reopening it showed the single-song dialog — whose Cancel only stopped the one song currently in flight, not the whole batch. PlaylistViewModel.activePlaylistBatchTransfer also only tracked "the last batchId this ViewModel instance kicked off", so a batch started from PlaylistDetailScreen was invisible to LibraryScreen's separate instance. Changed it to query the shared PhoneWatchTransferStateStore directly for any non-terminal batch, so it's correct regardless of which screen started it.
…t batch Tapping "Send to Watch"/"Update on Watch" always reopened the confirmation sheet, even while a batch transfer (this playlist's or another's) was already running — confirming it would start a second batch contending for the single Bluetooth channel. Now the menu action shows the existing batch's progress dialog instead when one is active, and the sheet's onConfirm re-checks before sending in case a batch started while it was open.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
No description provided.