From 62bdf6016283ceee6efb3120144c82c0d20f310e Mon Sep 17 00:00:00 2001 From: PonceGL Date: Sat, 8 Aug 2026 10:27:23 -0600 Subject: [PATCH 01/40] test(shared): add JUnit 5 unit test infrastructure 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. --- shared/build.gradle.kts | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/shared/build.gradle.kts b/shared/build.gradle.kts index 233f33822..4dcf89bc8 100644 --- a/shared/build.gradle.kts +++ b/shared/build.gradle.kts @@ -15,6 +15,11 @@ android { sourceCompatibility = JavaVersion.VERSION_21 targetCompatibility = JavaVersion.VERSION_21 } + + testOptions { + unitTests.isReturnDefaultValues = true + unitTests.all { it.useJUnitPlatform() } + } } kotlin { @@ -25,4 +30,16 @@ kotlin { dependencies { implementation(libs.kotlinx.serialization.json) + + // Testing (Unit) — pure DTO serialization round-trips, no Android framework needed. + testImplementation(libs.junit.jupiter.api) + testImplementation(libs.junit.jupiter.params) + testRuntimeOnly(libs.junit.jupiter.engine) + testRuntimeOnly(libs.junitplatformlauncher) + testImplementation(libs.truth) + testImplementation(kotlin("test")) +} + +tasks.withType { + useJUnitPlatform() } From bbdb6e75612a1c6ba2210286d4552e370085df59 Mon Sep 17 00:00:00 2001 From: PonceGL Date: Sat, 8 Aug 2026 10:27:29 -0600 Subject: [PATCH 02/40] feat(shared): add playlist sync contract 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. --- .../pixelplay/shared/WearDataPaths.kt | 3 ++ .../pixelplay/shared/WearPlaylistSync.kt | 19 ++++++++ .../pixelplay/shared/WearPlaylistSyncTest.kt | 48 +++++++++++++++++++ 3 files changed, 70 insertions(+) create mode 100644 shared/src/main/java/com/theveloper/pixelplay/shared/WearPlaylistSync.kt create mode 100644 shared/src/test/java/com/theveloper/pixelplay/shared/WearPlaylistSyncTest.kt diff --git a/shared/src/main/java/com/theveloper/pixelplay/shared/WearDataPaths.kt b/shared/src/main/java/com/theveloper/pixelplay/shared/WearDataPaths.kt index 35fa7debe..fd6c6d97f 100644 --- a/shared/src/main/java/com/theveloper/pixelplay/shared/WearDataPaths.kt +++ b/shared/src/main/java/com/theveloper/pixelplay/shared/WearDataPaths.kt @@ -64,4 +64,7 @@ object WearDataPaths { /** Message path for favorites sync progress/state (phone -> watch) */ const val FAVORITES_SYNC_STATE = "/favorites_sync_state" + + /** Message path for playlist sync (phone -> watch): creates or updates a local playlist's membership/order. */ + const val PLAYLIST_SYNC = "/playlist_sync" } diff --git a/shared/src/main/java/com/theveloper/pixelplay/shared/WearPlaylistSync.kt b/shared/src/main/java/com/theveloper/pixelplay/shared/WearPlaylistSync.kt new file mode 100644 index 000000000..b27b196ce --- /dev/null +++ b/shared/src/main/java/com/theveloper/pixelplay/shared/WearPlaylistSync.kt @@ -0,0 +1,19 @@ +package com.theveloper.pixelplay.shared + +import kotlinx.serialization.Serializable + +/** + * Snapshot of a phone playlist sent to the watch so it can be browsed and played offline. + * + * Sent once when the user taps "send to watch", and again (idempotently) when they tap + * "update" — the watch replaces its local membership/order for [playlistId] with [songIds] + * on each sync, independent of whether the audio for those songs has already arrived. This + * lets the watch show the full playlist and its intended order immediately, while individual + * songs keep streaming in afterward. + */ +@Serializable +data class WearPlaylistSync( + val playlistId: String, + val name: String, + val songIds: List, +) diff --git a/shared/src/test/java/com/theveloper/pixelplay/shared/WearPlaylistSyncTest.kt b/shared/src/test/java/com/theveloper/pixelplay/shared/WearPlaylistSyncTest.kt new file mode 100644 index 000000000..1d777bd2e --- /dev/null +++ b/shared/src/test/java/com/theveloper/pixelplay/shared/WearPlaylistSyncTest.kt @@ -0,0 +1,48 @@ +package com.theveloper.pixelplay.shared + +import com.google.common.truth.Truth.assertThat +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import org.junit.jupiter.api.Test + +class WearPlaylistSyncTest { + + private val json = Json { ignoreUnknownKeys = true } + + @Test + fun `round-trips through JSON preserving song order`() { + val original = WearPlaylistSync( + playlistId = "playlist-1", + name = "Running mix", + songIds = listOf("3", "1", "2"), + ) + + val decoded = json.decodeFromString(json.encodeToString(original)) + + assertThat(decoded).isEqualTo(original) + assertThat(decoded.songIds).containsExactly("3", "1", "2").inOrder() + } + + @Test + fun `decodes an empty song list`() { + val original = WearPlaylistSync(playlistId = "playlist-1", name = "Empty", songIds = emptyList()) + + val decoded = json.decodeFromString(json.encodeToString(original)) + + assertThat(decoded.songIds).isEmpty() + } + + @Test + fun `ignores unknown fields from a newer sender`() { + // The receiving side (watch) may run an older app version than the phone that sent this + // payload — unknown fields must not break decoding, only newly-added optional ones should. + val payloadWithExtraField = + """{"playlistId":"playlist-1","name":"Running mix","songIds":["1"],"futureField":true}""" + + val decoded = json.decodeFromString(payloadWithExtraField) + + assertThat(decoded).isEqualTo( + WearPlaylistSync(playlistId = "playlist-1", name = "Running mix", songIds = listOf("1")), + ) + } +} From bf038ab8afb8abf7b725e97c7a8848c17d86bd64 Mon Sep 17 00:00:00 2001 From: PonceGL Date: Sat, 8 Aug 2026 10:27:35 -0600 Subject: [PATCH 03/40] feat(shared): add transcoding status and error codes to transfer progress 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. --- .../pixelplay/shared/WearTransferProgress.kt | 14 ++++ .../shared/WearTransferProgressTest.kt | 64 +++++++++++++++++++ 2 files changed, 78 insertions(+) create mode 100644 shared/src/test/java/com/theveloper/pixelplay/shared/WearTransferProgressTest.kt diff --git a/shared/src/main/java/com/theveloper/pixelplay/shared/WearTransferProgress.kt b/shared/src/main/java/com/theveloper/pixelplay/shared/WearTransferProgress.kt index 266155160..b4f56ce0b 100644 --- a/shared/src/main/java/com/theveloper/pixelplay/shared/WearTransferProgress.kt +++ b/shared/src/main/java/com/theveloper/pixelplay/shared/WearTransferProgress.kt @@ -14,12 +14,26 @@ data class WearTransferProgress( val totalBytes: Long, val status: String, val error: String? = null, + /** Machine-readable failure reason, so callers can react (retry, prompt for space, ...) without parsing [error]. */ + val errorCode: String? = null, ) { companion object { + /** Phone is re-encoding the source file before it starts streaming; only meaningful for playlist batches. */ + const val STATUS_TRANSCODING = "transcoding" const val STATUS_TRANSFERRING = "transferring" const val STATUS_COMPLETED = "completed" const val STATUS_FAILED = "failed" const val STATUS_CANCELLED = "cancelled" + /** + * Phone finished sending the bytes but hasn't yet heard the watch's own write-complete ack. + * Local-only to the phone's in-memory/persisted transfer state — never serialized to the watch. + */ + const val STATUS_AWAITING_WATCH_ACK = "awaiting_watch_ack" + const val ERROR_ALREADY_ON_WATCH = "Song is already on watch" + const val ERROR_CODE_CONNECTION_LOST = "connection_lost" + const val ERROR_CODE_INSUFFICIENT_STORAGE = "insufficient_storage" + const val ERROR_CODE_TIMED_OUT = "timed_out" + const val ERROR_CODE_GENERIC = "generic" } } diff --git a/shared/src/test/java/com/theveloper/pixelplay/shared/WearTransferProgressTest.kt b/shared/src/test/java/com/theveloper/pixelplay/shared/WearTransferProgressTest.kt new file mode 100644 index 000000000..aca138865 --- /dev/null +++ b/shared/src/test/java/com/theveloper/pixelplay/shared/WearTransferProgressTest.kt @@ -0,0 +1,64 @@ +package com.theveloper.pixelplay.shared + +import com.google.common.truth.Truth.assertThat +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import org.junit.jupiter.api.Test + +class WearTransferProgressTest { + + private val json = Json { ignoreUnknownKeys = true } + + @Test + fun `round-trips through JSON with the new errorCode field`() { + val original = WearTransferProgress( + requestId = "req-1", + songId = "song-1", + bytesTransferred = 512L, + totalBytes = 1024L, + status = WearTransferProgress.STATUS_FAILED, + error = "Connection lost", + errorCode = WearTransferProgress.ERROR_CODE_CONNECTION_LOST, + ) + + val decoded = json.decodeFromString(json.encodeToString(original)) + + assertThat(decoded).isEqualTo(original) + } + + @Test + fun `decodes a payload from an older sender that predates errorCode as null`() { + // A phone running an older build won't include errorCode in the payload at all. + val legacyPayload = + """{"requestId":"req-1","songId":"song-1","bytesTransferred":0,"totalBytes":1024,"status":"transferring"}""" + + val decoded = json.decodeFromString(legacyPayload) + + assertThat(decoded.errorCode).isNull() + assertThat(decoded.error).isNull() + } + + @Test + fun `STATUS_TRANSCODING serializes as its raw string value`() { + val progress = WearTransferProgress( + requestId = "req-1", + songId = "song-1", + bytesTransferred = 0L, + totalBytes = 1024L, + status = WearTransferProgress.STATUS_TRANSCODING, + ) + + assertThat(json.encodeToString(progress)).contains("\"status\":\"transcoding\"") + } + + @Test + fun `STATUS_AWAITING_WATCH_ACK is a distinct value from every terminal status`() { + val terminalStatuses = setOf( + WearTransferProgress.STATUS_COMPLETED, + WearTransferProgress.STATUS_FAILED, + WearTransferProgress.STATUS_CANCELLED, + ) + + assertThat(terminalStatuses).doesNotContain(WearTransferProgress.STATUS_AWAITING_WATCH_ACK) + } +} From 239cb5314a77855683214212d43f16d3f093d049 Mon Sep 17 00:00:00 2001 From: PonceGL Date: Sat, 8 Aug 2026 10:27:45 -0600 Subject: [PATCH 04/40] test(wear): add unit and instrumentation test infrastructure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- wear/build.gradle.kts | 31 +++++++++++++++++++ .../pixelplay/MainCoroutineExtension.kt | 24 ++++++++++++++ 2 files changed, 55 insertions(+) create mode 100644 wear/src/test/java/com/theveloper/pixelplay/MainCoroutineExtension.kt diff --git a/wear/build.gradle.kts b/wear/build.gradle.kts index 22efd5464..34b11a5b6 100644 --- a/wear/build.gradle.kts +++ b/wear/build.gradle.kts @@ -16,6 +16,13 @@ android { targetSdk = 37 versionCode = (project.findProperty("APP_VERSION_CODE") as? String)?.toInt() ?: 1 versionName = (project.findProperty("APP_VERSION_NAME") as? String) ?: "1.0.0" + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + + testOptions { + unitTests.isReturnDefaultValues = true + unitTests.all { it.useJUnitPlatform() } } buildTypes { @@ -139,6 +146,26 @@ dependencies { implementation(libs.androidx.media3.session) implementation(libs.androidx.mediarouter) + // Testing (Unit) — no legacy JUnit 4 unit tests planned here, so no vintage engine needed + // (compare to :app, which carries pre-existing JUnit 4 tests under useJUnitPlatform()). + testImplementation(libs.junit.jupiter.api) + testImplementation(libs.junit.jupiter.params) + testRuntimeOnly(libs.junit.jupiter.engine) + testRuntimeOnly(libs.junitplatformlauncher) + testImplementation(libs.kotlinx.coroutines.test) + testImplementation(libs.mockk) + testImplementation(libs.turbine) + testImplementation(libs.truth) + testImplementation(kotlin("test")) + + // Testing (Instrumentation) — Room in-memory DAO tests run on-device via AndroidJUnitRunner. + androidTestImplementation(libs.androidx.junit) + androidTestImplementation(libs.androidx.room.testing) + androidTestImplementation(libs.androidx.test.core) + androidTestImplementation(libs.truth) + // Android-specific artifact: plain io.mockk:mockk can't mock classes on ART. + androidTestImplementation(libs.mockk.android) + constraints { // Fix vulnerabilities in transitive dependencies implementation(libs.netty.common) @@ -153,3 +180,7 @@ dependencies { implementation(libs.apache.httpclient) } } + +tasks.withType { + useJUnitPlatform() +} diff --git a/wear/src/test/java/com/theveloper/pixelplay/MainCoroutineExtension.kt b/wear/src/test/java/com/theveloper/pixelplay/MainCoroutineExtension.kt new file mode 100644 index 000000000..8e2cec5ff --- /dev/null +++ b/wear/src/test/java/com/theveloper/pixelplay/MainCoroutineExtension.kt @@ -0,0 +1,24 @@ +package com.theveloper.pixelplay + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import org.junit.jupiter.api.extension.AfterEachCallback +import org.junit.jupiter.api.extension.BeforeEachCallback +import org.junit.jupiter.api.extension.ExtensionContext + +@ExperimentalCoroutinesApi +class MainCoroutineExtension(private val testDispatcher: TestDispatcher = StandardTestDispatcher()) : + BeforeEachCallback, AfterEachCallback { + + override fun beforeEach(context: ExtensionContext) { + Dispatchers.setMain(testDispatcher) + } + + override fun afterEach(context: ExtensionContext) { + Dispatchers.resetMain() + } +} From 7df4c7a72a46b726cdd52552510a7d840da7f524 Mon Sep 17 00:00:00 2001 From: PonceGL Date: Sat, 8 Aug 2026 10:49:59 -0600 Subject: [PATCH 05/40] feat(wear): add local playlist Room entities and DAO MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../pixelplay/data/local/LocalPlaylistDao.kt | 53 +++++++++++++++++++ .../data/local/LocalPlaylistEntity.kt | 17 ++++++ .../data/local/LocalPlaylistSongCrossRef.kt | 24 +++++++++ 3 files changed, 94 insertions(+) create mode 100644 wear/src/main/java/com/theveloper/pixelplay/data/local/LocalPlaylistDao.kt create mode 100644 wear/src/main/java/com/theveloper/pixelplay/data/local/LocalPlaylistEntity.kt create mode 100644 wear/src/main/java/com/theveloper/pixelplay/data/local/LocalPlaylistSongCrossRef.kt diff --git a/wear/src/main/java/com/theveloper/pixelplay/data/local/LocalPlaylistDao.kt b/wear/src/main/java/com/theveloper/pixelplay/data/local/LocalPlaylistDao.kt new file mode 100644 index 000000000..2dcfd1781 --- /dev/null +++ b/wear/src/main/java/com/theveloper/pixelplay/data/local/LocalPlaylistDao.kt @@ -0,0 +1,53 @@ +package com.theveloper.pixelplay.data.local + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import androidx.room.Transaction +import kotlinx.coroutines.flow.Flow + +/** + * DAO for locally stored playlist snapshots on the watch. + */ +@Dao +interface LocalPlaylistDao { + + /** + * Replaces the playlist row and its full song membership/order in one transaction, so a + * re-sync (e.g. after adding a song on the phone) always reflects the latest order rather + * than merging with stale cross-refs. + */ + @Transaction + suspend fun upsertPlaylist(entity: LocalPlaylistEntity, songCrossRefs: List) { + insertPlaylist(entity) + deleteSongsForPlaylist(entity.playlistId) + insertSongCrossRefs(songCrossRefs) + } + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertPlaylist(entity: LocalPlaylistEntity) + + @Query("SELECT * FROM local_playlists WHERE playlistId = :playlistId") + suspend fun getPlaylistById(playlistId: String): LocalPlaylistEntity? + + @Query("DELETE FROM local_playlist_songs WHERE playlistId = :playlistId") + suspend fun deleteSongsForPlaylist(playlistId: String) + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertSongCrossRefs(crossRefs: List) + + @Query("SELECT * FROM local_playlists ORDER BY updatedAt DESC") + fun observePlaylists(): Flow> + + @Query("SELECT * FROM local_playlist_songs WHERE playlistId = :playlistId ORDER BY position ASC") + fun observePlaylistSongs(playlistId: String): Flow> + + /** + * All playlist/song memberships across every playlist — used to map an in-flight transfer's + * songId back to the playlist(s) it belongs to (e.g. to show "receiving" state on a playlist + * card while one of its songs is still transferring). + */ + @Query("SELECT * FROM local_playlist_songs") + fun observeAllPlaylistSongCrossRefs(): Flow> +} diff --git a/wear/src/main/java/com/theveloper/pixelplay/data/local/LocalPlaylistEntity.kt b/wear/src/main/java/com/theveloper/pixelplay/data/local/LocalPlaylistEntity.kt new file mode 100644 index 000000000..4ceb65768 --- /dev/null +++ b/wear/src/main/java/com/theveloper/pixelplay/data/local/LocalPlaylistEntity.kt @@ -0,0 +1,17 @@ +package com.theveloper.pixelplay.data.local + +import androidx.room.Entity +import androidx.room.PrimaryKey + +/** + * Room entity representing a playlist snapshot synced from the phone, so the watch can browse + * and play it offline. Membership and order live separately in [LocalPlaylistSongCrossRef] — + * this row only carries the playlist's own identity and timestamps. + */ +@Entity(tableName = "local_playlists") +data class LocalPlaylistEntity( + @PrimaryKey val playlistId: String, + val name: String, + val createdAt: Long, + val updatedAt: Long, +) diff --git a/wear/src/main/java/com/theveloper/pixelplay/data/local/LocalPlaylistSongCrossRef.kt b/wear/src/main/java/com/theveloper/pixelplay/data/local/LocalPlaylistSongCrossRef.kt new file mode 100644 index 000000000..cea29754d --- /dev/null +++ b/wear/src/main/java/com/theveloper/pixelplay/data/local/LocalPlaylistSongCrossRef.kt @@ -0,0 +1,24 @@ +package com.theveloper.pixelplay.data.local + +import androidx.room.Entity +import androidx.room.Index + +/** + * Junction row recording that [songId] belongs to [playlistId] at [position]. Deliberately has + * no foreign key to `local_songs`: a playlist syncs its full membership/order up front, before + * the audio for every song has finished transferring (see `WearPlaylistSync`), so a cross-ref + * routinely points at a songId that doesn't have a matching [LocalSongEntity] row yet. + */ +@Entity( + tableName = "local_playlist_songs", + primaryKeys = ["playlistId", "songId"], + indices = [ + Index(value = ["playlistId", "position"]), + Index(value = ["songId"]), + ], +) +data class LocalPlaylistSongCrossRef( + val playlistId: String, + val songId: String, + val position: Int, +) From 920d7bdf8eec8023d8b44edb4491ff663706b295 Mon Sep 17 00:00:00 2001 From: PonceGL Date: Sat, 8 Aug 2026 10:50:06 -0600 Subject: [PATCH 06/40] feat(wear): bump schema to v6 for local playlists and wire the full migration 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. --- .../pixelplay/data/local/WearMusicDatabase.kt | 37 ++++++++++++++++++- .../com/theveloper/pixelplay/di/WearModule.kt | 10 ++++- 2 files changed, 45 insertions(+), 2 deletions(-) diff --git a/wear/src/main/java/com/theveloper/pixelplay/data/local/WearMusicDatabase.kt b/wear/src/main/java/com/theveloper/pixelplay/data/local/WearMusicDatabase.kt index 25c99e278..5a05a3b37 100644 --- a/wear/src/main/java/com/theveloper/pixelplay/data/local/WearMusicDatabase.kt +++ b/wear/src/main/java/com/theveloper/pixelplay/data/local/WearMusicDatabase.kt @@ -9,9 +9,14 @@ import androidx.sqlite.db.SupportSQLiteDatabase * Room database for locally stored songs on the watch. * Tracks songs that have been transferred from the phone for offline playback. */ -@Database(entities = [LocalSongEntity::class], version = 5, exportSchema = false) +@Database( + entities = [LocalSongEntity::class, LocalPlaylistEntity::class, LocalPlaylistSongCrossRef::class], + version = 6, + exportSchema = false, +) abstract class WearMusicDatabase : RoomDatabase() { abstract fun localSongDao(): LocalSongDao + abstract fun localPlaylistDao(): LocalPlaylistDao companion object { val MIGRATION_1_2 = object : Migration(1, 2) { @@ -38,5 +43,35 @@ abstract class WearMusicDatabase : RoomDatabase() { db.execSQL("ALTER TABLE local_songs ADD COLUMN favoriteSyncPending INTEGER NOT NULL DEFAULT 0") } } + + val MIGRATION_5_6 = object : Migration(5, 6) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL( + "CREATE TABLE IF NOT EXISTS local_playlists (" + + "playlistId TEXT NOT NULL PRIMARY KEY, " + + "name TEXT NOT NULL, " + + "createdAt INTEGER NOT NULL, " + + "updatedAt INTEGER NOT NULL)" + ) + db.execSQL( + "CREATE TABLE IF NOT EXISTS local_playlist_songs (" + + "playlistId TEXT NOT NULL, " + + "songId TEXT NOT NULL, " + + "position INTEGER NOT NULL, " + + "PRIMARY KEY(playlistId, songId))" + ) + db.execSQL( + "CREATE INDEX IF NOT EXISTS index_local_playlist_songs_playlistId_position " + + "ON local_playlist_songs(playlistId, position)" + ) + db.execSQL( + "CREATE INDEX IF NOT EXISTS index_local_playlist_songs_songId " + + "ON local_playlist_songs(songId)" + ) + } + } + + /** Every migration this database has ever declared, in order — wire all of them, not just the newest. */ + val ALL_MIGRATIONS = arrayOf(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5, MIGRATION_5_6) } } diff --git a/wear/src/main/java/com/theveloper/pixelplay/di/WearModule.kt b/wear/src/main/java/com/theveloper/pixelplay/di/WearModule.kt index 4edfd651c..7f78b39d1 100644 --- a/wear/src/main/java/com/theveloper/pixelplay/di/WearModule.kt +++ b/wear/src/main/java/com/theveloper/pixelplay/di/WearModule.kt @@ -7,6 +7,7 @@ import com.google.android.gms.wearable.DataClient import com.google.android.gms.wearable.MessageClient import com.google.android.gms.wearable.NodeClient import com.google.android.gms.wearable.Wearable +import com.theveloper.pixelplay.data.local.LocalPlaylistDao import com.theveloper.pixelplay.data.local.LocalSongDao import com.theveloper.pixelplay.data.local.WearMusicDatabase import dagger.Module @@ -46,10 +47,17 @@ object WearModule { application, WearMusicDatabase::class.java, "wear_music.db" - ).build() + ) + .addMigrations(*WearMusicDatabase.ALL_MIGRATIONS) + .build() @Provides @Singleton fun provideLocalSongDao(database: WearMusicDatabase): LocalSongDao = database.localSongDao() + + @Provides + @Singleton + fun provideLocalPlaylistDao(database: WearMusicDatabase): LocalPlaylistDao = + database.localPlaylistDao() } From 2b06f4dee735ab4e778f52653ac8e3bae718d7e0 Mon Sep 17 00:00:00 2001 From: PonceGL Date: Sat, 8 Aug 2026 10:50:15 -0600 Subject: [PATCH 07/40] test(wear): add instrumented tests for local playlist DAO and the v5->v6 migration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- wear/build.gradle.kts | 1 + .../data/local/LocalPlaylistDaoTest.kt | 130 ++++++++++++++++++ .../local/WearMusicDatabaseMigrationTest.kt | 95 +++++++++++++ 3 files changed, 226 insertions(+) create mode 100644 wear/src/androidTest/java/com/theveloper/pixelplay/data/local/LocalPlaylistDaoTest.kt create mode 100644 wear/src/androidTest/java/com/theveloper/pixelplay/data/local/WearMusicDatabaseMigrationTest.kt diff --git a/wear/build.gradle.kts b/wear/build.gradle.kts index 34b11a5b6..12c3d0aa1 100644 --- a/wear/build.gradle.kts +++ b/wear/build.gradle.kts @@ -162,6 +162,7 @@ dependencies { androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.room.testing) androidTestImplementation(libs.androidx.test.core) + androidTestImplementation(libs.kotlinx.coroutines.test) androidTestImplementation(libs.truth) // Android-specific artifact: plain io.mockk:mockk can't mock classes on ART. androidTestImplementation(libs.mockk.android) diff --git a/wear/src/androidTest/java/com/theveloper/pixelplay/data/local/LocalPlaylistDaoTest.kt b/wear/src/androidTest/java/com/theveloper/pixelplay/data/local/LocalPlaylistDaoTest.kt new file mode 100644 index 000000000..e7b798259 --- /dev/null +++ b/wear/src/androidTest/java/com/theveloper/pixelplay/data/local/LocalPlaylistDaoTest.kt @@ -0,0 +1,130 @@ +package com.theveloper.pixelplay.data.local + +import android.content.Context +import androidx.room.Room +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import java.io.IOException + +@RunWith(AndroidJUnit4::class) +class LocalPlaylistDaoTest { + + private lateinit var dao: LocalPlaylistDao + private lateinit var db: WearMusicDatabase + + @Before + fun createDb() { + val context = ApplicationProvider.getApplicationContext() + db = Room.inMemoryDatabaseBuilder(context, WearMusicDatabase::class.java) + .allowMainThreadQueries() + .build() + dao = db.localPlaylistDao() + } + + @After + @Throws(IOException::class) + fun closeDb() { + db.close() + } + + @Test + fun upsertPlaylist_isVisibleViaObservePlaylists() = runTest { + val playlist = LocalPlaylistEntity(playlistId = "p1", name = "Running mix", createdAt = 1L, updatedAt = 1L) + val songs = listOf( + LocalPlaylistSongCrossRef(playlistId = "p1", songId = "s1", position = 0), + LocalPlaylistSongCrossRef(playlistId = "p1", songId = "s2", position = 1), + ) + + dao.upsertPlaylist(playlist, songs) + + val stored = dao.observePlaylists().first() + assertThat(stored).containsExactly(playlist) + } + + @Test + fun observePlaylistSongs_isOrderedByPosition() = runTest { + val playlist = LocalPlaylistEntity(playlistId = "p1", name = "Running mix", createdAt = 1L, updatedAt = 1L) + // Inserted out of order on purpose — the DAO's ORDER BY position must correct this, not + // the insertion order. + val songs = listOf( + LocalPlaylistSongCrossRef(playlistId = "p1", songId = "third", position = 2), + LocalPlaylistSongCrossRef(playlistId = "p1", songId = "first", position = 0), + LocalPlaylistSongCrossRef(playlistId = "p1", songId = "second", position = 1), + ) + + dao.upsertPlaylist(playlist, songs) + + val ordered = dao.observePlaylistSongs("p1").first().map { it.songId } + assertThat(ordered).containsExactly("first", "second", "third").inOrder() + } + + @Test + fun upsertPlaylist_replacesPreviousCrossRefsInsteadOfMerging() = runTest { + val playlist = LocalPlaylistEntity(playlistId = "p1", name = "Running mix", createdAt = 1L, updatedAt = 1L) + dao.upsertPlaylist( + playlist, + listOf( + LocalPlaylistSongCrossRef(playlistId = "p1", songId = "s1", position = 0), + LocalPlaylistSongCrossRef(playlistId = "p1", songId = "s2", position = 1), + ), + ) + + // Re-sync with a song removed and the remaining one's position shifted — simulates the + // phone re-sending after the user edited the playlist. + dao.upsertPlaylist( + playlist.copy(updatedAt = 2L), + listOf(LocalPlaylistSongCrossRef(playlistId = "p1", songId = "s2", position = 0)), + ) + + val songIds = dao.observePlaylistSongs("p1").first().map { it.songId } + assertThat(songIds).containsExactly("s2") + } + + @Test + fun upsertPlaylist_doesNotAffectOtherPlaylists() = runTest { + dao.upsertPlaylist( + LocalPlaylistEntity(playlistId = "p1", name = "Running mix", createdAt = 1L, updatedAt = 1L), + listOf(LocalPlaylistSongCrossRef(playlistId = "p1", songId = "s1", position = 0)), + ) + dao.upsertPlaylist( + LocalPlaylistEntity(playlistId = "p2", name = "Chill mix", createdAt = 1L, updatedAt = 1L), + listOf(LocalPlaylistSongCrossRef(playlistId = "p2", songId = "s2", position = 0)), + ) + + // Re-sync p1 only. + dao.upsertPlaylist( + LocalPlaylistEntity(playlistId = "p1", name = "Running mix", createdAt = 1L, updatedAt = 2L), + listOf(LocalPlaylistSongCrossRef(playlistId = "p1", songId = "s1-updated", position = 0)), + ) + + val p2Songs = dao.observePlaylistSongs("p2").first().map { it.songId } + assertThat(p2Songs).containsExactly("s2") + } + + @Test + fun observeAllPlaylistSongCrossRefs_spansEveryPlaylist() = runTest { + dao.upsertPlaylist( + LocalPlaylistEntity(playlistId = "p1", name = "Running mix", createdAt = 1L, updatedAt = 1L), + listOf(LocalPlaylistSongCrossRef(playlistId = "p1", songId = "s1", position = 0)), + ) + dao.upsertPlaylist( + LocalPlaylistEntity(playlistId = "p2", name = "Chill mix", createdAt = 1L, updatedAt = 1L), + listOf(LocalPlaylistSongCrossRef(playlistId = "p2", songId = "s2", position = 0)), + ) + + val allSongIds = dao.observeAllPlaylistSongCrossRefs().first().map { it.songId } + assertThat(allSongIds).containsExactly("s1", "s2") + } + + @Test + fun getPlaylistById_returnsNullForAnUnknownPlaylist() = runTest { + assertThat(dao.getPlaylistById("missing")).isNull() + } +} diff --git a/wear/src/androidTest/java/com/theveloper/pixelplay/data/local/WearMusicDatabaseMigrationTest.kt b/wear/src/androidTest/java/com/theveloper/pixelplay/data/local/WearMusicDatabaseMigrationTest.kt new file mode 100644 index 000000000..50dc78a52 --- /dev/null +++ b/wear/src/androidTest/java/com/theveloper/pixelplay/data/local/WearMusicDatabaseMigrationTest.kt @@ -0,0 +1,95 @@ +package com.theveloper.pixelplay.data.local + +import android.content.Context +import android.database.sqlite.SQLiteDatabase +import androidx.room.Room +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Test +import org.junit.runner.RunWith + +/** + * Verifies [WearMusicDatabase.MIGRATION_5_6] against a hand-built v5 database file. + * + * `:wear` doesn't export Room schema JSON (`exportSchema = false`), so [androidx.room.testing.MigrationTestHelper] + * — which needs those fixtures — isn't available here. Instead this builds a real on-disk SQLite + * file matching the v5 `local_songs` shape (see [LocalSongEntity]), then opens it through Room + * with the migration attached, the same way a real upgrading device would. + */ +@RunWith(AndroidJUnit4::class) +class WearMusicDatabaseMigrationTest { + + private val dbName = "migration-test-wear-music.db" + private val context: Context = ApplicationProvider.getApplicationContext() + + @After + fun cleanup() { + context.deleteDatabase(dbName) + } + + @Test + fun migrate5To6_createsPlaylistTablesAndPreservesExistingSongs() = runTest { + seedVersion5Database() + + val migratedDb = Room.databaseBuilder(context, WearMusicDatabase::class.java, dbName) + .addMigrations(WearMusicDatabase.MIGRATION_5_6) + .build() + + try { + val song = migratedDb.localSongDao().getSongById("song-1") + assertThat(song).isNotNull() + assertThat(song?.title).isEqualTo("Existing song") + + // The playlist tables must exist and be queryable — this throws if the migration + // didn't run (or ran with malformed SQL) rather than returning a false "empty" result. + val playlists = migratedDb.openHelper.readableDatabase.query("SELECT * FROM local_playlists") + playlists.use { assertThat(it.count).isEqualTo(0) } + + val playlistSongs = migratedDb.openHelper.readableDatabase.query("SELECT * FROM local_playlist_songs") + playlistSongs.use { assertThat(it.count).isEqualTo(0) } + } finally { + migratedDb.close() + } + } + + /** Hand-writes a v5 database file: the `local_songs` shape frozen right before this migration. */ + private fun seedVersion5Database() { + context.deleteDatabase(dbName) + val dbFile = context.getDatabasePath(dbName) + dbFile.parentFile?.mkdirs() + + val db = SQLiteDatabase.openOrCreateDatabase(dbFile, null) + db.execSQL( + "CREATE TABLE local_songs (" + + "songId TEXT NOT NULL PRIMARY KEY, " + + "title TEXT NOT NULL, " + + "artist TEXT NOT NULL, " + + "album TEXT NOT NULL, " + + "albumId INTEGER NOT NULL, " + + "duration INTEGER NOT NULL, " + + "mimeType TEXT NOT NULL, " + + "fileSize INTEGER NOT NULL, " + + "bitrate INTEGER NOT NULL, " + + "sampleRate INTEGER NOT NULL, " + + "isFavorite INTEGER NOT NULL, " + + "favoriteSyncPending INTEGER NOT NULL, " + + "paletteSeedArgb INTEGER, " + + "themePaletteJson TEXT, " + + "artworkPath TEXT, " + + "localPath TEXT NOT NULL, " + + "transferredAt INTEGER NOT NULL)" + ) + db.execSQL( + "INSERT INTO local_songs (songId, title, artist, album, albumId, duration, mimeType, " + + "fileSize, bitrate, sampleRate, isFavorite, favoriteSyncPending, paletteSeedArgb, " + + "themePaletteJson, artworkPath, localPath, transferredAt) VALUES " + + "('song-1', 'Existing song', 'Artist', 'Album', 1, 180000, 'audio/mp4', 4000000, " + + "128000, 44100, 0, 0, NULL, NULL, NULL, '/music/song-1.m4a', 1000)" + ) + db.version = 5 + db.close() + } +} From e63a382e079f3d0c639c339e4b91a1c86ee788e0 Mon Sep 17 00:00:00 2001 From: PonceGL Date: Sat, 8 Aug 2026 11:13:34 -0600 Subject: [PATCH 08/40] feat(app): add IO and Main dispatcher qualifiers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../com/theveloper/pixelplay/di/AppModule.kt | 9 +++++++++ .../com/theveloper/pixelplay/di/Qualifiers.kt | 18 ++++++++++++++++++ 2 files changed, 27 insertions(+) diff --git a/app/src/main/java/com/theveloper/pixelplay/di/AppModule.kt b/app/src/main/java/com/theveloper/pixelplay/di/AppModule.kt index 7f1d93599..bb6045b94 100644 --- a/app/src/main/java/com/theveloper/pixelplay/di/AppModule.kt +++ b/app/src/main/java/com/theveloper/pixelplay/di/AppModule.kt @@ -56,6 +56,7 @@ import kotlinx.serialization.json.Json import javax.inject.Qualifier import javax.inject.Singleton import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import okhttp3.OkHttpClient @@ -113,6 +114,14 @@ object AppModule { return CoroutineScope(SupervisorJob() + Dispatchers.IO) } + @Provides + @IoDispatcher + fun provideIoDispatcher(): CoroutineDispatcher = Dispatchers.IO + + @Provides + @MainDispatcher + fun provideMainDispatcher(): CoroutineDispatcher = Dispatchers.Main + @Singleton @Provides fun provideWorkManager(@ApplicationContext context: Context): WorkManager { diff --git a/app/src/main/java/com/theveloper/pixelplay/di/Qualifiers.kt b/app/src/main/java/com/theveloper/pixelplay/di/Qualifiers.kt index d0207b199..416e86ed6 100644 --- a/app/src/main/java/com/theveloper/pixelplay/di/Qualifiers.kt +++ b/app/src/main/java/com/theveloper/pixelplay/di/Qualifiers.kt @@ -29,3 +29,21 @@ annotation class BackupGson @Qualifier @Retention(AnnotationRetention.BINARY) annotation class AppScope + +/** + * Qualifier for the IO [kotlinx.coroutines.CoroutineDispatcher]. Injected rather than referenced + * as `Dispatchers.IO` directly so tests can substitute a `TestDispatcher` (implements + * `AND-CONC-03`). Most of this codebase predates this convention and still references + * `Dispatchers.IO` directly — only use this qualifier in new code. + */ +@Qualifier +@Retention(AnnotationRetention.BINARY) +annotation class IoDispatcher + +/** + * Qualifier for the Main [kotlinx.coroutines.CoroutineDispatcher]. Same rationale as + * [IoDispatcher] — new code only. + */ +@Qualifier +@Retention(AnnotationRetention.BINARY) +annotation class MainDispatcher From 75d6e367cc3f810fc50077ca57047fa0bcb28bca Mon Sep 17 00:00:00 2001 From: PonceGL Date: Sat, 8 Aug 2026 11:13:40 -0600 Subject: [PATCH 09/40] feat(wear): add AAC transcoding decision and encode pipeline for watch handoff MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../data/service/wear/WatchAudioTranscoder.kt | 208 ++++++++++++++++++ 1 file changed, 208 insertions(+) create mode 100644 app/src/main/java/com/theveloper/pixelplay/data/service/wear/WatchAudioTranscoder.kt diff --git a/app/src/main/java/com/theveloper/pixelplay/data/service/wear/WatchAudioTranscoder.kt b/app/src/main/java/com/theveloper/pixelplay/data/service/wear/WatchAudioTranscoder.kt new file mode 100644 index 000000000..6c1274877 --- /dev/null +++ b/app/src/main/java/com/theveloper/pixelplay/data/service/wear/WatchAudioTranscoder.kt @@ -0,0 +1,208 @@ +package com.theveloper.pixelplay.data.service.wear + +import android.app.Application +import android.net.Uri +import androidx.core.net.toUri +import androidx.media3.common.MediaItem +import androidx.media3.common.MimeTypes +import androidx.media3.common.util.UnstableApi +import androidx.media3.transformer.AudioEncoderSettings +import androidx.media3.transformer.Composition +import androidx.media3.transformer.DefaultEncoderFactory +import androidx.media3.transformer.ExportException +import androidx.media3.transformer.ExportResult +import androidx.media3.transformer.ProgressHolder +import androidx.media3.transformer.Transformer +import com.theveloper.pixelplay.data.model.Song +import com.theveloper.pixelplay.di.MainDispatcher +import kotlinx.coroutines.CancellableContinuation +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.withContext +import timber.log.Timber +import java.io.File +import java.util.Locale +import javax.inject.Inject +import javax.inject.Singleton +import kotlin.coroutines.resume + +/** + * Decides whether a song needs to be re-encoded before it is sent to the watch, and performs + * that re-encoding with [Transformer]. + * + * Lossless/high-bitrate sources are re-encoded to AAC-LC at [TARGET_BITRATE_BPS]: watches decode + * AAC in hardware but most FLAC decoding on Wear OS SoCs is software-only, and lossless files are + * also far larger to transfer and store on a watch's very limited flash. Sources that are already + * a compressed lossy format at or below the passthrough bitrate are sent through untouched (see + * [PhoneDirectWatchTransferCoordinator]) — re-encoding an already-small MP3 down to + * [TARGET_BITRATE_BPS] would only cost CPU and quality for no size benefit worth the transfer + * time saved. + */ +@UnstableApi +@Singleton +class WatchAudioTranscoder @Inject constructor( + private val application: Application, + @MainDispatcher private val mainDispatcher: CoroutineDispatcher, +) { + + sealed class TranscodeResult { + /** The source is already an acceptable lossy format; send it as-is. */ + data object Passthrough : TranscodeResult() + data class Transcoded(val outputFile: File) : TranscodeResult() + data class Failed(val error: Throwable) : TranscodeResult() + } + + /** Pure decision function, kept separate from the actual encode so it's cheap to unit test. */ + fun requiresTranscoding(song: Song): Boolean { + val mimeType = song.mimeType?.lowercase(Locale.ROOT) + val bitrate = song.bitrate + val isPassthroughEligible = mimeType != null && + PASSTHROUGH_MIME_TYPES.contains(mimeType) && + bitrate != null && + bitrate <= MAX_PASSTHROUGH_BITRATE_BPS + return !isPassthroughEligible + } + + /** + * Runs the transcode if [requiresTranscoding] says it's needed, reporting encode progress + * as a 0f..1f fraction via [onProgress]. Callers own [TranscodeResult.Transcoded.outputFile] + * and must delete it (via [cleanup]) once it has been sent or the transfer is abandoned. + */ + suspend fun transcodeIfNeeded( + song: Song, + requestId: String, + onProgress: (Float) -> Unit = {}, + ): TranscodeResult { + if (!requiresTranscoding(song)) return TranscodeResult.Passthrough + + val inputMediaItem = buildInputMediaItem(song) + ?: return TranscodeResult.Failed(IllegalStateException("No readable local audio source for songId=${song.id}")) + + val outputFile = outputFileFor(song.id, requestId) + outputFile.parentFile?.mkdirs() + + return runTransform(inputMediaItem, outputFile, onProgress) + } + + fun cleanup(result: TranscodeResult) { + if (result is TranscodeResult.Transcoded) { + runCatching { result.outputFile.delete() } + .onFailure { error -> Timber.tag(TAG).w(error, "Failed to delete transcoded temp file") } + } + } + + // Transformer must be built and started on a thread that has a Looper — the main thread is + // the one Android guarantees has one, so this can't move to an injected background dispatcher. + private suspend fun runTransform( + inputMediaItem: MediaItem, + outputFile: File, + onProgress: (Float) -> Unit, + ): TranscodeResult = withContext(mainDispatcher) { + suspendCancellableCoroutine { continuation -> + val encoderFactory = DefaultEncoderFactory.Builder(application) + .setRequestedAudioEncoderSettings( + AudioEncoderSettings.Builder().setBitrate(TARGET_BITRATE_BPS).build() + ) + .build() + + val transformer = Transformer.Builder(application) + .setAudioMimeType(MimeTypes.AUDIO_AAC) + .setEncoderFactory(encoderFactory) + .addListener(object : Transformer.Listener { + override fun onCompleted(composition: Composition, exportResult: ExportResult) { + if (continuation.isActive) { + continuation.resume(TranscodeResult.Transcoded(outputFile)) + } + } + + override fun onError( + composition: Composition, + exportResult: ExportResult, + exportException: ExportException, + ) { + // Transformer does not delete partial output on failure — see its Listener docs. + runCatching { outputFile.delete() } + if (continuation.isActive) { + continuation.resume(TranscodeResult.Failed(exportException)) + } + } + }) + .build() + + continuation.invokeOnCancellation { + transformer.cancel() + runCatching { outputFile.delete() } + } + + pollProgress(transformer, continuation, onProgress) + + transformer.start(inputMediaItem, outputFile.absolutePath) + } + } + + private fun pollProgress( + transformer: Transformer, + continuation: CancellableContinuation, + onProgress: (Float) -> Unit, + ) { + val progressHolder = ProgressHolder() + val handler = android.os.Handler(android.os.Looper.getMainLooper()) + val poll = object : Runnable { + override fun run() { + if (!continuation.isActive) return + val state = transformer.getProgress(progressHolder) + if (state == Transformer.PROGRESS_STATE_AVAILABLE) { + onProgress(progressHolder.progress / 100f) + } + if (state != Transformer.PROGRESS_STATE_NOT_STARTED) { + handler.postDelayed(this, PROGRESS_POLL_INTERVAL_MS) + } + } + } + handler.postDelayed(poll, PROGRESS_POLL_INTERVAL_MS) + } + + private fun buildInputMediaItem(song: Song): MediaItem? { + val directFile = song.path.takeIf { it.isNotBlank() }?.let(::File) + ?.takeIf { it.isFile && it.canRead() && it.length() > 0L } + if (directFile != null) { + return MediaItem.fromUri(Uri.fromFile(directFile)) + } + + val rawUri = song.contentUriString + if (rawUri.isBlank()) return null + if (rawUri.startsWith("/")) { + val rawFile = File(rawUri) + if (rawFile.isFile && rawFile.canRead() && rawFile.length() > 0L) { + return MediaItem.fromUri(Uri.fromFile(rawFile)) + } + } + + val uri = runCatching { rawUri.toUri() }.getOrNull() ?: return null + return when (uri.scheme?.lowercase(Locale.ROOT)) { + "file", "content" -> MediaItem.fromUri(uri) + else -> null + } + } + + private fun outputFileFor(songId: String, requestId: String): File { + val dir = File(application.cacheDir, "watch_transfer") + return File(dir, "${songId}_$requestId.m4a") + } + + companion object { + /** Also used by [WatchPlaylistTransferEstimator] to size-estimate songs that will be transcoded. */ + const val TARGET_BITRATE_BPS = 128_000 + private const val TAG = "WatchAudioTranscoder" + private const val MAX_PASSTHROUGH_BITRATE_BPS = 256_000 + private const val PROGRESS_POLL_INTERVAL_MS = 250L + private val PASSTHROUGH_MIME_TYPES = setOf( + "audio/mpeg", + "audio/mp4", + "audio/aac", + "audio/mp4a-latm", + "audio/ogg", + "audio/opus", + ) + } +} From f22e632302bc2980cbe88c805f4f6bac8e3529ee Mon Sep 17 00:00:00 2001 From: PonceGL Date: Sat, 8 Aug 2026 11:13:45 -0600 Subject: [PATCH 10/40] feat(wear): add playlist transfer size/time estimator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../wear/WatchPlaylistTransferEstimator.kt | 63 +++++++++++++++++++ 1 file changed, 63 insertions(+) create mode 100644 app/src/main/java/com/theveloper/pixelplay/data/service/wear/WatchPlaylistTransferEstimator.kt diff --git a/app/src/main/java/com/theveloper/pixelplay/data/service/wear/WatchPlaylistTransferEstimator.kt b/app/src/main/java/com/theveloper/pixelplay/data/service/wear/WatchPlaylistTransferEstimator.kt new file mode 100644 index 000000000..4ea23a4f9 --- /dev/null +++ b/app/src/main/java/com/theveloper/pixelplay/data/service/wear/WatchPlaylistTransferEstimator.kt @@ -0,0 +1,63 @@ +package com.theveloper.pixelplay.data.service.wear + +import androidx.media3.common.util.UnstableApi +import com.theveloper.pixelplay.data.model.Song + +/** + * Aggregate size/time estimate shown on the send-to-watch confirmation sheet, computed only over + * the songs that still need to be transferred (already-on-watch songs are skipped by the dedupe + * in [PlaylistWatchTransferCoordinator], so they don't cost bandwidth or storage). + */ +data class WatchPlaylistTransferEstimate( + val totalSongCount: Int, + val pendingSongCount: Int, + val estimatedBytes: Long, + val estimatedTransferSeconds: Long, +) + +/** + * Pure size/time heuristics for the whole-playlist watch transfer confirmation UI. Kept separate + * from [WatchAudioTranscoder] (which does the real encode) so it stays cheap to unit test. + */ +@UnstableApi +object WatchPlaylistTransferEstimator { + + /** + * Assumed throughput for the single Bluetooth channel used for the transfer (phase 1 — no + * Wi-Fi transport yet). This is the Wearable Data Layer ChannelClient rate, not raw Bluetooth + * bandwidth, and is deliberately conservative: an estimate that undershoots the real time + * erodes trust in the confirmation sheet more than one that's a bit pessimistic. Needs + * re-measuring against a real phone+watch pair once device testing resumes — see §R-04 of the + * Wear OS guide for the documented range (~50–150 KB/s) this sits below on purpose. + */ + private const val ASSUMED_TRANSFER_RATE_BYTES_PER_SEC = 40_000L + + fun estimateBytesForSong(song: Song, transcoder: WatchAudioTranscoder): Long { + val effectiveBitrateBps = if (transcoder.requiresTranscoding(song)) { + WatchAudioTranscoder.TARGET_BITRATE_BPS + } else { + song.bitrate ?: WatchAudioTranscoder.TARGET_BITRATE_BPS + } + val durationSeconds = song.duration / 1000.0 + return (durationSeconds * effectiveBitrateBps / 8.0).toLong().coerceAtLeast(0L) + } + + fun estimate( + allSongs: List, + pendingSongs: List, + transcoder: WatchAudioTranscoder, + ): WatchPlaylistTransferEstimate { + val totalBytes = pendingSongs.sumOf { estimateBytesForSong(it, transcoder) } + return WatchPlaylistTransferEstimate( + totalSongCount = allSongs.size, + pendingSongCount = pendingSongs.size, + estimatedBytes = totalBytes, + estimatedTransferSeconds = estimateTransferSeconds(totalBytes), + ) + } + + private fun estimateTransferSeconds(totalBytes: Long): Long { + if (totalBytes <= 0L) return 0L + return (totalBytes / ASSUMED_TRANSFER_RATE_BYTES_PER_SEC).coerceAtLeast(1L) + } +} From f98419bd8ecd5988abbb9f186dec535a0638a1c0 Mon Sep 17 00:00:00 2001 From: PonceGL Date: Sat, 8 Aug 2026 11:13:53 -0600 Subject: [PATCH 11/40] test(wear): add unit tests for the transcode decision and transfer estimator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- .../service/wear/WatchAudioTranscoderTest.kt | 61 ++++++++++++++ .../WatchPlaylistTransferEstimatorTest.kt | 84 +++++++++++++++++++ 2 files changed, 145 insertions(+) create mode 100644 app/src/test/java/com/theveloper/pixelplay/data/service/wear/WatchAudioTranscoderTest.kt create mode 100644 app/src/test/java/com/theveloper/pixelplay/data/service/wear/WatchPlaylistTransferEstimatorTest.kt diff --git a/app/src/test/java/com/theveloper/pixelplay/data/service/wear/WatchAudioTranscoderTest.kt b/app/src/test/java/com/theveloper/pixelplay/data/service/wear/WatchAudioTranscoderTest.kt new file mode 100644 index 000000000..5bf0e9e70 --- /dev/null +++ b/app/src/test/java/com/theveloper/pixelplay/data/service/wear/WatchAudioTranscoderTest.kt @@ -0,0 +1,61 @@ +package com.theveloper.pixelplay.data.service.wear + +import android.app.Application +import com.google.common.truth.Truth.assertThat +import com.theveloper.pixelplay.data.model.Song +import io.mockk.mockk +import kotlinx.coroutines.Dispatchers +import org.junit.jupiter.api.Test + +/** + * Covers [WatchAudioTranscoder.requiresTranscoding] only — the pure decision function. The actual + * encode path (`transcodeIfNeeded` / `runTransform`) drives a real [androidx.media3.transformer.Transformer], + * which needs a hardware encoder and a Looper thread; that's only verifiable on a device. + */ +class WatchAudioTranscoderTest { + + // requiresTranscoding never touches these — a relaxed mock and any real dispatcher are enough. + private val transcoder = WatchAudioTranscoder( + application = mockk(relaxed = true), + mainDispatcher = Dispatchers.Unconfined, + ) + + private fun song(mimeType: String?, bitrate: Int?) = + Song.emptySong().copy(mimeType = mimeType, bitrate = bitrate) + + @Test + fun `a lossless format requires transcoding regardless of bitrate`() { + assertThat(transcoder.requiresTranscoding(song("audio/flac", bitrate = 128_000))).isTrue() + assertThat(transcoder.requiresTranscoding(song("audio/flac", bitrate = null))).isTrue() + } + + @Test + fun `a lossy source at or under the passthrough bitrate is sent as-is`() { + assertThat(transcoder.requiresTranscoding(song("audio/mpeg", bitrate = 128_000))).isFalse() + } + + @Test + fun `a lossy source at exactly the passthrough bitrate boundary is sent as-is`() { + assertThat(transcoder.requiresTranscoding(song("audio/mpeg", bitrate = 256_000))).isFalse() + } + + @Test + fun `a lossy source over the passthrough bitrate is transcoded down`() { + assertThat(transcoder.requiresTranscoding(song("audio/mpeg", bitrate = 320_000))).isTrue() + } + + @Test + fun `unknown mimeType requires transcoding`() { + assertThat(transcoder.requiresTranscoding(song(mimeType = null, bitrate = 128_000))).isTrue() + } + + @Test + fun `unknown bitrate requires transcoding even for an otherwise eligible lossy mimeType`() { + assertThat(transcoder.requiresTranscoding(song("audio/mpeg", bitrate = null))).isTrue() + } + + @Test + fun `mimeType is matched case-insensitively`() { + assertThat(transcoder.requiresTranscoding(song("AUDIO/MPEG", bitrate = 128_000))).isFalse() + } +} diff --git a/app/src/test/java/com/theveloper/pixelplay/data/service/wear/WatchPlaylistTransferEstimatorTest.kt b/app/src/test/java/com/theveloper/pixelplay/data/service/wear/WatchPlaylistTransferEstimatorTest.kt new file mode 100644 index 000000000..ddcc1f633 --- /dev/null +++ b/app/src/test/java/com/theveloper/pixelplay/data/service/wear/WatchPlaylistTransferEstimatorTest.kt @@ -0,0 +1,84 @@ +package com.theveloper.pixelplay.data.service.wear + +import android.app.Application +import com.google.common.truth.Truth.assertThat +import com.theveloper.pixelplay.data.model.Song +import io.mockk.mockk +import kotlinx.coroutines.Dispatchers +import org.junit.jupiter.api.Test + +class WatchPlaylistTransferEstimatorTest { + + private val transcoder = WatchAudioTranscoder( + application = mockk(relaxed = true), + mainDispatcher = Dispatchers.Unconfined, + ) + + private fun song(id: String, mimeType: String?, bitrate: Int?, durationMs: Long = 180_000L) = + Song.emptySong().copy(id = id, mimeType = mimeType, bitrate = bitrate, duration = durationMs) + + @Test + fun `passthrough song is sized using its own bitrate`() { + val passthroughSong = song("s1", "audio/mpeg", bitrate = 128_000, durationMs = 60_000L) + + val bytes = WatchPlaylistTransferEstimator.estimateBytesForSong(passthroughSong, transcoder) + + // 60s * 128_000 bps / 8 = 960_000 bytes + assertThat(bytes).isEqualTo(960_000L) + } + + @Test + fun `transcoded song is sized using the target AAC bitrate, not its source bitrate`() { + val losslessSong = song("s1", "audio/flac", bitrate = 900_000, durationMs = 60_000L) + + val bytes = WatchPlaylistTransferEstimator.estimateBytesForSong(losslessSong, transcoder) + + // 60s * 128_000 (TARGET_BITRATE_BPS) bps / 8 = 960_000 bytes, not sized off the 900kbps source. + assertThat(bytes).isEqualTo(960_000L) + } + + @Test + fun `estimate only sums pending songs, not the whole playlist`() { + val alreadyOnWatch = song("on-watch", "audio/mpeg", bitrate = 128_000, durationMs = 60_000L) + val pending = song("pending", "audio/mpeg", bitrate = 128_000, durationMs = 60_000L) + + val estimate = WatchPlaylistTransferEstimator.estimate( + allSongs = listOf(alreadyOnWatch, pending), + pendingSongs = listOf(pending), + transcoder = transcoder, + ) + + assertThat(estimate.totalSongCount).isEqualTo(2) + assertThat(estimate.pendingSongCount).isEqualTo(1) + assertThat(estimate.estimatedBytes).isEqualTo(960_000L) + } + + @Test + fun `no pending songs means zero bytes and zero seconds`() { + val onlySong = song("s1", "audio/mpeg", bitrate = 128_000) + + val estimate = WatchPlaylistTransferEstimator.estimate( + allSongs = listOf(onlySong), + pendingSongs = emptyList(), + transcoder = transcoder, + ) + + assertThat(estimate.pendingSongCount).isEqualTo(0) + assertThat(estimate.estimatedBytes).isEqualTo(0L) + assertThat(estimate.estimatedTransferSeconds).isEqualTo(0L) + } + + @Test + fun `a small pending transfer still estimates at least one second`() { + val tinySong = song("s1", "audio/mpeg", bitrate = 128_000, durationMs = 1L) + + val estimate = WatchPlaylistTransferEstimator.estimate( + allSongs = listOf(tinySong), + pendingSongs = listOf(tinySong), + transcoder = transcoder, + ) + + assertThat(estimate.estimatedBytes).isGreaterThan(0L) + assertThat(estimate.estimatedTransferSeconds).isEqualTo(1L) + } +} From ce80a853f065be00f85c2b08829b27ccbfa75f10 Mon Sep 17 00:00:00 2001 From: PonceGL Date: Sat, 8 Aug 2026 12:44:45 -0600 Subject: [PATCH 12/40] feat(wear): allow the direct transfer coordinator to send an already-transcoded file MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../PhoneDirectWatchTransferCoordinator.kt | 49 +++++++++++++++---- 1 file changed, 39 insertions(+), 10 deletions(-) diff --git a/app/src/main/java/com/theveloper/pixelplay/data/service/wear/PhoneDirectWatchTransferCoordinator.kt b/app/src/main/java/com/theveloper/pixelplay/data/service/wear/PhoneDirectWatchTransferCoordinator.kt index 7f935a080..e745b86c4 100644 --- a/app/src/main/java/com/theveloper/pixelplay/data/service/wear/PhoneDirectWatchTransferCoordinator.kt +++ b/app/src/main/java/com/theveloper/pixelplay/data/service/wear/PhoneDirectWatchTransferCoordinator.kt @@ -91,6 +91,18 @@ class PhoneDirectWatchTransferCoordinator @Inject constructor( } } + /** + * Substitutes the audio actually streamed to the watch with an already-transcoded file (see + * [WatchAudioTranscoder]), bypassing [isSongTransferEligible] and the song's own local-file + * resolution — the override file was just written locally by the transcoder, so it's + * unconditionally eligible regardless of what the original [Song]'s source was. + */ + data class WatchAudioOverride( + val file: File, + val mimeType: String, + val bitrateBps: Int, + ) + fun startTransferToWatch( nodeId: String, requestId: String, @@ -98,6 +110,7 @@ class PhoneDirectWatchTransferCoordinator @Inject constructor( transferMode: String = WearTransferRequest.MODE_SAVE_TO_LIBRARY, startPositionMs: Long = 0L, autoPlay: Boolean = false, + audioOverride: WatchAudioOverride? = null, ) { transferStateStore.markRequested( requestId = requestId, @@ -112,6 +125,7 @@ class PhoneDirectWatchTransferCoordinator @Inject constructor( transferMode = transferMode, startPositionMs = startPositionMs, autoPlay = autoPlay, + audioOverride = audioOverride, ) } } @@ -123,6 +137,7 @@ class PhoneDirectWatchTransferCoordinator @Inject constructor( transferMode: String, startPositionMs: Long, autoPlay: Boolean, + audioOverride: WatchAudioOverride? = null, ) { var openedSongSource: OpenedSongSource? = null try { @@ -138,6 +153,7 @@ class PhoneDirectWatchTransferCoordinator @Inject constructor( } if ( + audioOverride == null && transferMode == WearTransferRequest.MODE_SAVE_TO_LIBRARY && !isSongTransferEligible(song) ) { @@ -150,19 +166,23 @@ class PhoneDirectWatchTransferCoordinator @Inject constructor( return } - val songSource = openSongSource( - song = song, - allowProxyStreaming = transferMode == WearTransferRequest.MODE_TEMPORARY_PLAYBACK, - ) + val songSource = if (audioOverride != null) { + openOverrideSongSource(audioOverride) + } else { + openSongSource( + song = song, + allowProxyStreaming = transferMode == WearTransferRequest.MODE_TEMPORARY_PLAYBACK, + ) + } if (songSource == null) { sendTransferMetadataError( nodeId = nodeId, requestId = requestId, songId = song.id, - errorMessage = if (transferMode == WearTransferRequest.MODE_TEMPORARY_PLAYBACK) { - "Cannot stream audio source to watch" - } else { - "Cannot read audio file" + errorMessage = when { + audioOverride != null -> "Cannot read transcoded audio file" + transferMode == WearTransferRequest.MODE_TEMPORARY_PLAYBACK -> "Cannot stream audio source to watch" + else -> "Cannot read audio file" }, ) return @@ -182,9 +202,9 @@ class PhoneDirectWatchTransferCoordinator @Inject constructor( album = song.album, albumId = song.albumId, duration = song.duration, - mimeType = song.mimeType ?: "audio/mpeg", + mimeType = audioOverride?.mimeType ?: (song.mimeType ?: "audio/mpeg"), fileSize = fileSize, - bitrate = song.bitrate ?: 0, + bitrate = audioOverride?.bitrateBps ?: (song.bitrate ?: 0), sampleRate = song.sampleRate ?: 0, isFavorite = song.isFavorite, paletteSeedArgb = paletteSeedArgb, @@ -325,6 +345,15 @@ class PhoneDirectWatchTransferCoordinator @Inject constructor( return openHttpSongSource(streamUrl) } + private fun openOverrideSongSource(override: WatchAudioOverride): OpenedSongSource? { + val file = override.file.takeIf { it.isFile && it.canRead() && it.length() > 0L } ?: return null + return runCatching { + OpenedSongSource(inputStream = file.inputStream(), fileSize = file.length()) + }.onFailure { error -> + Timber.tag(TAG).w(error, "Failed to open transcoded override file=%s", file.absolutePath) + }.getOrNull() + } + private fun openDirectSongSource(song: Song): OpenedSongSource? { val directFile = song.path .takeIf { it.isNotBlank() } From a6f8e19d8a7491ebf67c1afd7c50d4827ea5eee4 Mon Sep 17 00:00:00 2001 From: PonceGL Date: Sat, 8 Aug 2026 12:44:54 -0600 Subject: [PATCH 13/40] feat(app): provide CapabilityClient and MessageClient via Hilt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../com/theveloper/pixelplay/di/AppModule.kt | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/app/src/main/java/com/theveloper/pixelplay/di/AppModule.kt b/app/src/main/java/com/theveloper/pixelplay/di/AppModule.kt index bb6045b94..f0e8453f8 100644 --- a/app/src/main/java/com/theveloper/pixelplay/di/AppModule.kt +++ b/app/src/main/java/com/theveloper/pixelplay/di/AppModule.kt @@ -1,5 +1,6 @@ package com.theveloper.pixelplay.di +import android.app.Application import android.content.Context import androidx.annotation.OptIn import androidx.datastore.core.DataStore @@ -15,6 +16,9 @@ import androidx.work.WorkManager import coil.ImageLoader import coil.disk.DiskCache import coil.memory.MemoryCache +import com.google.android.gms.wearable.CapabilityClient +import com.google.android.gms.wearable.MessageClient +import com.google.android.gms.wearable.Wearable import com.theveloper.pixelplay.BuildConfig import com.theveloper.pixelplay.PixelPlayApplication import com.theveloper.pixelplay.data.database.AlbumArtThemeDao @@ -122,6 +126,20 @@ object AppModule { @MainDispatcher fun provideMainDispatcher(): CoroutineDispatcher = Dispatchers.Main + // Injected (unlike the rest of the wear/ package, which resolves these via + // Wearable.getXClient(application) internally) so PlaylistWatchTransferCoordinator can be + // constructed with fakes in tests — CapabilityClient/MessageClient are non-final abstract + // classes, so MockK can subclass them directly with no inline-mocking agent involved. + @Singleton + @Provides + fun provideCapabilityClient(application: Application): CapabilityClient = + Wearable.getCapabilityClient(application) + + @Singleton + @Provides + fun provideMessageClient(application: Application): MessageClient = + Wearable.getMessageClient(application) + @Singleton @Provides fun provideWorkManager(@ApplicationContext context: Context): WorkManager { From 2d375784b71e8834061ebb54be8cc902c60673a7 Mon Sep 17 00:00:00 2001 From: PonceGL Date: Sat, 8 Aug 2026 12:45:00 -0600 Subject: [PATCH 14/40] feat(wear): add playlist batch transfer state to the phone-side store 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. --- .../wear/PhoneWatchTransferStateStore.kt | 152 ++++++++++++++++++ 1 file changed, 152 insertions(+) diff --git a/app/src/main/java/com/theveloper/pixelplay/data/service/wear/PhoneWatchTransferStateStore.kt b/app/src/main/java/com/theveloper/pixelplay/data/service/wear/PhoneWatchTransferStateStore.kt index 752c10e61..e9a69f9c5 100644 --- a/app/src/main/java/com/theveloper/pixelplay/data/service/wear/PhoneWatchTransferStateStore.kt +++ b/app/src/main/java/com/theveloper/pixelplay/data/service/wear/PhoneWatchTransferStateStore.kt @@ -33,11 +33,36 @@ data class PhoneWatchTransferState( } } +/** + * Aggregate state of a whole-playlist watch transfer, driven by [PlaylistWatchTransferCoordinator]. + * [currentSongProgress] is the 0f..1f progress of whichever song [activeRequestId] refers to + * (weighted across transcode+transfer phases by the coordinator) — the per-song byte-level detail + * lives in [PhoneWatchTransferState], keyed by that same requestId. + */ +data class PhoneWatchBatchTransferState( + val batchId: String, + val playlistId: String, + val playlistName: String, + val totalSongCount: Int, + val completedSongCount: Int = 0, + val failedSongCount: Int = 0, + val status: String = WearTransferProgress.STATUS_TRANSFERRING, + val activeRequestId: String? = null, + val currentSongTitle: String = "", + val currentSongProgress: Float = 0f, + val errorMessage: String? = null, + val updatedAtMillis: Long = System.currentTimeMillis(), +) { + val processedSongCount: Int get() = completedSongCount + failedSongCount +} + @Singleton class PhoneWatchTransferStateStore @Inject constructor() { private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) private val _transfers = MutableStateFlow>(emptyMap()) val transfers: StateFlow> = _transfers.asStateFlow() + private val _batchTransfers = MutableStateFlow>(emptyMap()) + val batchTransfers: StateFlow> = _batchTransfers.asStateFlow() private val _reachableWatchNodeIds = MutableStateFlow>(emptySet()) val reachableWatchNodeIds: StateFlow> = _reachableWatchNodeIds.asStateFlow() private val _watchLibrarySyncedNodeIds = MutableStateFlow>(emptySet()) @@ -232,6 +257,133 @@ class PhoneWatchTransferStateStore @Inject constructor() { } } + // --- Playlist batch transfers, driven by PlaylistWatchTransferCoordinator --- + + private val batchCleanupJobs = ConcurrentHashMap() + + fun markBatchStarted(batchId: String, playlistId: String, playlistName: String, totalSongCount: Int) { + batchCleanupJobs.remove(batchId)?.cancel() + _batchTransfers.update { map -> + map + (batchId to PhoneWatchBatchTransferState( + batchId = batchId, + playlistId = playlistId, + playlistName = playlistName, + totalSongCount = totalSongCount, + status = WearTransferProgress.STATUS_TRANSFERRING, + )) + } + } + + fun markBatchSongStarted( + batchId: String, + activeRequestId: String, + songTitle: String, + startingProgress: Float = 0f, + ) { + _batchTransfers.update { map -> + val current = map[batchId] ?: return@update map + map + (batchId to current.copy( + activeRequestId = activeRequestId, + currentSongTitle = songTitle, + currentSongProgress = startingProgress.coerceIn(0f, 1f), + updatedAtMillis = System.currentTimeMillis(), + )) + } + } + + /** [status] is informational only here — [progress] is what actually drives the notification/UI. */ + fun markBatchSongProgress(batchId: String, status: String, progress: Float) { + _batchTransfers.update { map -> + val current = map[batchId] ?: return@update map + map + (batchId to current.copy( + currentSongProgress = progress.coerceIn(0f, 1f), + updatedAtMillis = System.currentTimeMillis(), + )) + } + } + + fun markBatchSongCompleted(batchId: String) { + _batchTransfers.update { map -> + val current = map[batchId] ?: return@update map + map + (batchId to current.copy( + completedSongCount = current.completedSongCount + 1, + activeRequestId = null, + currentSongProgress = 0f, + updatedAtMillis = System.currentTimeMillis(), + )) + } + } + + fun markBatchSongFailed(batchId: String, errorMessage: String? = null) { + _batchTransfers.update { map -> + val current = map[batchId] ?: return@update map + map + (batchId to current.copy( + failedSongCount = current.failedSongCount + 1, + activeRequestId = null, + currentSongProgress = 0f, + errorMessage = errorMessage ?: current.errorMessage, + updatedAtMillis = System.currentTimeMillis(), + )) + } + } + + fun markBatchCancelled(batchId: String) { + _batchTransfers.update { map -> + val current = map[batchId] ?: return@update map + map + (batchId to current.copy( + status = WearTransferProgress.STATUS_CANCELLED, + activeRequestId = null, + updatedAtMillis = System.currentTimeMillis(), + )) + } + scheduleBatchTerminalCleanup(batchId) + } + + fun markBatchFailed(batchId: String, errorMessage: String) { + _batchTransfers.update { map -> + val current = map[batchId] ?: return@update map + map + (batchId to current.copy( + status = WearTransferProgress.STATUS_FAILED, + errorMessage = errorMessage, + activeRequestId = null, + updatedAtMillis = System.currentTimeMillis(), + )) + } + scheduleBatchTerminalCleanup(batchId) + } + + fun markBatchCompleted(batchId: String) { + _batchTransfers.update { map -> + val current = map[batchId] ?: return@update map + map + (batchId to current.copy( + status = WearTransferProgress.STATUS_COMPLETED, + activeRequestId = null, + updatedAtMillis = System.currentTimeMillis(), + )) + } + scheduleBatchTerminalCleanup(batchId) + } + + private fun scheduleBatchTerminalCleanup(batchId: String) { + batchCleanupJobs.remove(batchId)?.cancel() + batchCleanupJobs[batchId] = scope.launch { + delay(TERMINAL_STATE_VISIBILITY_MS) + _batchTransfers.update { map -> + val current = map[batchId] + if (current != null && + (current.status == WearTransferProgress.STATUS_COMPLETED || + current.status == WearTransferProgress.STATUS_FAILED || + current.status == WearTransferProgress.STATUS_CANCELLED) + ) { + map - batchId + } else { + map + } + } + batchCleanupJobs.remove(batchId) + } + } + private companion object { const val TERMINAL_STATE_VISIBILITY_MS = 3500L } From 495d1dddb4868b05223356ffa359cfa8ed9cf83b Mon Sep 17 00:00:00 2001 From: PonceGL Date: Sat, 8 Aug 2026 12:45:11 -0600 Subject: [PATCH 15/40] feat(wear): add playlist batch transfer coordinator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../wear/PlaylistWatchTransferCoordinator.kt | 322 ++++++++++++++++++ .../data/service/wear/WatchAudioTranscoder.kt | 8 + 2 files changed, 330 insertions(+) create mode 100644 app/src/main/java/com/theveloper/pixelplay/data/service/wear/PlaylistWatchTransferCoordinator.kt diff --git a/app/src/main/java/com/theveloper/pixelplay/data/service/wear/PlaylistWatchTransferCoordinator.kt b/app/src/main/java/com/theveloper/pixelplay/data/service/wear/PlaylistWatchTransferCoordinator.kt new file mode 100644 index 000000000..03d94c042 --- /dev/null +++ b/app/src/main/java/com/theveloper/pixelplay/data/service/wear/PlaylistWatchTransferCoordinator.kt @@ -0,0 +1,322 @@ +package com.theveloper.pixelplay.data.service.wear + +import android.app.Application +import com.google.android.gms.wearable.CapabilityClient +import com.google.android.gms.wearable.MessageClient +import com.google.android.gms.wearable.Node +import com.theveloper.pixelplay.data.model.Song +import com.theveloper.pixelplay.data.repository.MusicRepository +import com.theveloper.pixelplay.di.AppScope +import com.theveloper.pixelplay.shared.WearCapabilities +import com.theveloper.pixelplay.shared.WearDataPaths +import com.theveloper.pixelplay.shared.WearPlaylistSync +import com.theveloper.pixelplay.shared.WearTransferProgress +import java.util.UUID +import java.util.concurrent.ConcurrentHashMap +import javax.inject.Inject +import javax.inject.Singleton +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.mapNotNull +import kotlinx.coroutines.launch +import kotlinx.coroutines.tasks.await +import kotlinx.coroutines.withTimeoutOrNull +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import timber.log.Timber + +/** + * Orchestrates sending a whole playlist to the watch: syncs the playlist's membership/order + * first (so the watch can show it, and start playing it, before every song has arrived), then + * transfers songs that aren't already on the watch one at a time — never in parallel, to avoid + * saturating the single Bluetooth channel and spiking CPU/battery on the watch (see + * [WatchAudioTranscoder]'s doc for why the encode itself is also sequential per song). + * + * Reuses the existing single-song pipeline end to end: [WatchAudioTranscoder] decides/produces + * the audio to send, and [PhoneDirectWatchTransferCoordinator] still owns the actual chunked + * ChannelClient streaming (via its [PhoneDirectWatchTransferCoordinator.WatchAudioOverride] hook) + * and per-song cancellation. + */ +@Singleton +class PlaylistWatchTransferCoordinator @Inject constructor( + private val application: Application, + private val musicRepository: MusicRepository, + private val watchAudioTranscoder: WatchAudioTranscoder, + private val directTransferCoordinator: PhoneDirectWatchTransferCoordinator, + private val wearPhoneTransferSender: WearPhoneTransferSender, + private val transferStateStore: PhoneWatchTransferStateStore, + // Injected directly (unlike most of this package, which resolves these via + // Wearable.getXClient(application) internally) so this coordinator is constructible with + // fakes in tests without needing to mock a static Java method. + private val capabilityClient: CapabilityClient, + private val messageClient: MessageClient, + @AppScope private val scope: CoroutineScope, +) { + private val json = Json { ignoreUnknownKeys = true } + private val cancelledBatchIds = ConcurrentHashMap.newKeySet() + + /** + * Deliberately an instance property, not a companion `const`/`var`: tests shrink it on their + * own coordinator instance, so runs never leak a mutated timeout into unrelated tests the way + * a shared static field would. + */ + internal var songTransferAwaitTimeoutMs: Long = DEFAULT_SONG_TRANSFER_AWAIT_TIMEOUT_MS + + /** Returns the generated batchId immediately; the transfer itself runs asynchronously. */ + fun requestPlaylistTransfer(playlistId: String, playlistName: String, songIds: List): String { + val batchId = UUID.randomUUID().toString() + if (songIds.isEmpty()) return batchId + + scope.launch { + runBatchTransfer(batchId, playlistId, playlistName, songIds) + } + return batchId + } + + fun cancelPlaylistTransfer(batchId: String) { + cancelledBatchIds.add(batchId) + val activeRequestId = transferStateStore.batchTransfers.value[batchId]?.activeRequestId + if (activeRequestId != null) { + scope.launch { wearPhoneTransferSender.cancelTransfer(activeRequestId) } + } + transferStateStore.markBatchCancelled(batchId) + } + + private suspend fun runBatchTransfer( + batchId: String, + playlistId: String, + playlistName: String, + songIds: List, + ) { + val nodes = resolveReachableNodes() + transferStateStore.markBatchStarted(batchId, playlistId, playlistName, songIds.size) + + if (nodes.isEmpty()) { + transferStateStore.markBatchFailed(batchId, "No reachable watch with PixelPlay") + return + } + transferStateStore.retainReachableWatchNodes(nodes.map { it.id }.toSet()) + + WatchTransferForegroundService.start(application) + sendPlaylistSyncToNodes(nodes, playlistId, playlistName, songIds) + + val alreadyPresentCount = songIds.count { transferStateStore.isSongSavedOnAllReachableWatches(it) } + repeat(alreadyPresentCount) { transferStateStore.markBatchSongCompleted(batchId) } + + val pendingSongIds = songIds.filterNot { transferStateStore.isSongSavedOnAllReachableWatches(it) } + + for (songId in pendingSongIds) { + if (cancelledBatchIds.contains(batchId)) break + + val song = musicRepository.getSongsByIds(listOf(songId)).first().firstOrNull() + if (song == null) { + Timber.tag(TAG).w("Song not found for playlist transfer: songId=%s", songId) + transferStateStore.markBatchSongFailed(batchId) + continue + } + + val outcome = transferSongToAllNodes(batchId, nodes, song) + if (outcome.completed) { + transferStateStore.markBatchSongCompleted(batchId) + } else { + transferStateStore.markBatchSongFailed(batchId, outcome.errorCode) + } + } + + cancelledBatchIds.remove(batchId) + if (transferStateStore.batchTransfers.value[batchId]?.status != WearTransferProgress.STATUS_CANCELLED) { + transferStateStore.markBatchCompleted(batchId) + } + } + + private suspend fun resolveReachableNodes(): List { + return try { + capabilityClient.getCapability( + WearCapabilities.PIXELPLAY_WEAR_APP, + CapabilityClient.FILTER_REACHABLE, + ).await().nodes.toList() + } catch (error: CancellationException) { + throw error + } catch (error: Exception) { + Timber.tag(TAG).w(error, "Failed to resolve reachable watches for playlist transfer") + emptyList() + } + } + + private suspend fun sendPlaylistSyncToNodes( + nodes: List, + playlistId: String, + playlistName: String, + songIds: List, + ) { + val syncPayload = json.encodeToString(WearPlaylistSync(playlistId, playlistName, songIds)) + .toByteArray(Charsets.UTF_8) + nodes.forEach { node -> + try { + messageClient.sendMessage(node.id, WearDataPaths.PLAYLIST_SYNC, syncPayload).await() + } catch (error: CancellationException) { + throw error + } catch (error: Exception) { + Timber.tag(TAG).w(error, "Failed to send playlist sync to node=%s", node.id) + } + } + } + + /** Transcodes [song] once (if needed) and streams it to every reachable [nodes] in turn. */ + private suspend fun transferSongToAllNodes( + batchId: String, + nodes: List, + song: Song, + ): SongTransferResult { + if (cancelledBatchIds.contains(batchId)) return SongTransferResult(completed = false) + + val transcodeRequestId = UUID.randomUUID().toString() + transferStateStore.markBatchSongStarted(batchId, transcodeRequestId, song.title) + + val transcodeResult = watchAudioTranscoder.transcodeIfNeeded( + song = song, + requestId = transcodeRequestId, + onProgress = { fraction -> + transferStateStore.markBatchSongProgress( + batchId, + WearTransferProgress.STATUS_TRANSCODING, + fraction.coerceIn(0f, 1f) * TRANSCODE_PHASE_WEIGHT, + ) + }, + ) + if (transcodeResult is WatchAudioTranscoder.TranscodeResult.Failed) { + Timber.tag(TAG).w(transcodeResult.error, "Transcode failed for songId=%s, skipping", song.id) + return SongTransferResult(completed = false, errorCode = WearTransferProgress.ERROR_CODE_GENERIC) + } + if (cancelledBatchIds.contains(batchId)) { + watchAudioTranscoder.cleanup(transcodeResult) + return SongTransferResult(completed = false) + } + + val audioOverride = (transcodeResult as? WatchAudioTranscoder.TranscodeResult.Transcoded)?.let { transcoded -> + PhoneDirectWatchTransferCoordinator.WatchAudioOverride( + file = transcoded.outputFile, + mimeType = WatchAudioTranscoder.TRANSCODED_OUTPUT_MIME_TYPE, + bitrateBps = WatchAudioTranscoder.TARGET_BITRATE_BPS, + ) + } + val wasTranscoded = audioOverride != null + + // Send to every reachable node (not just the first) — with multiple paired watches this + // song should land on all of them. Present on at least one counts as done overall; if + // every node failed, report whichever node failed last (good enough for the UI's + // single-line failure summary). + var succeededOnAnyNode = false + var lastFailureErrorCode: String? = null + for (node in nodes) { + if (cancelledBatchIds.contains(batchId)) break + val nodeOutcome = transferSongToNode(batchId, node, song, audioOverride, wasTranscoded) + if (nodeOutcome.completed) { + succeededOnAnyNode = true + } else { + lastFailureErrorCode = nodeOutcome.errorCode + } + } + + watchAudioTranscoder.cleanup(transcodeResult) + return SongTransferResult( + completed = succeededOnAnyNode, + errorCode = if (succeededOnAnyNode) null else lastFailureErrorCode, + ) + } + + private suspend fun transferSongToNode( + batchId: String, + node: Node, + song: Song, + audioOverride: PhoneDirectWatchTransferCoordinator.WatchAudioOverride?, + wasTranscoded: Boolean, + ): SongTransferResult { + val requestId = UUID.randomUUID().toString() + // Re-targets activeRequestId to this node's request without resetting the visible + // progress: if the song was transcoded, it's already sitting at TRANSCODE_PHASE_WEIGHT. + val startingProgress = if (wasTranscoded) TRANSCODE_PHASE_WEIGHT else 0f + transferStateStore.markBatchSongStarted(batchId, requestId, song.title, startingProgress) + + val progressWatcherJob: Job = scope.launch { + transferStateStore.transfers + .mapNotNull { it[requestId] } + .collect { state -> + if (state.status == WearTransferProgress.STATUS_TRANSFERRING) { + // Transferring is the second phase for a transcoded song: continue from + // TRANSCODE_PHASE_WEIGHT up to 1.0 instead of restarting at 0. + val overallProgress = if (wasTranscoded) { + TRANSCODE_PHASE_WEIGHT + state.progress * (1f - TRANSCODE_PHASE_WEIGHT) + } else { + state.progress + } + transferStateStore.markBatchSongProgress(batchId, state.status, overallProgress) + } + } + } + + directTransferCoordinator.startTransferToWatch( + nodeId = node.id, + requestId = requestId, + songId = song.id, + audioOverride = audioOverride, + ) + + val finalState = withTimeoutOrNull(songTransferAwaitTimeoutMs) { + transferStateStore.transfers + .mapNotNull { it[requestId] } + .first { it.status in TERMINAL_STATUSES } + } + progressWatcherJob.cancel() + + if (finalState == null) { + Timber.tag(TAG).w( + "Timed out awaiting watch confirmation: songId=%s requestId=%s", + song.id, + requestId, + ) + transferStateStore.markProgress( + requestId = requestId, + songId = song.id, + bytesTransferred = 0L, + totalBytes = 0L, + status = WearTransferProgress.STATUS_FAILED, + error = "Timed out waiting for watch confirmation", + ) + return SongTransferResult(completed = false, errorCode = WearTransferProgress.ERROR_CODE_TIMED_OUT) + } + + return SongTransferResult( + completed = finalState.status == WearTransferProgress.STATUS_COMPLETED, + errorCode = if (finalState.status == WearTransferProgress.STATUS_FAILED) { + WearTransferProgress.ERROR_CODE_GENERIC + } else { + null + }, + ) + } + + private data class SongTransferResult(val completed: Boolean, val errorCode: String? = null) + + internal companion object { + private const val TAG = "PlaylistWatchTransfer" + + // Transcoding and transferring both report 0f..1f progress for the same song; weighting + // them into one continuous 0..1 scale (instead of each resetting to 0) avoids the visible + // jump-then-reset when a song moves from one phase to the other. + private const val TRANSCODE_PHASE_WEIGHT = 0.3f + + // Deliberately generous relative to the watch's own idle watchdog: leaves room for slow + // transcoding plus a slow Bluetooth link on large files. Better to wait too long than to + // mark a legitimately-slow transfer as failed. + private const val DEFAULT_SONG_TRANSFER_AWAIT_TIMEOUT_MS = 300_000L + + private val TERMINAL_STATUSES = setOf( + WearTransferProgress.STATUS_COMPLETED, + WearTransferProgress.STATUS_FAILED, + WearTransferProgress.STATUS_CANCELLED, + ) + } +} diff --git a/app/src/main/java/com/theveloper/pixelplay/data/service/wear/WatchAudioTranscoder.kt b/app/src/main/java/com/theveloper/pixelplay/data/service/wear/WatchAudioTranscoder.kt index 6c1274877..793df4882 100644 --- a/app/src/main/java/com/theveloper/pixelplay/data/service/wear/WatchAudioTranscoder.kt +++ b/app/src/main/java/com/theveloper/pixelplay/data/service/wear/WatchAudioTranscoder.kt @@ -193,6 +193,14 @@ class WatchAudioTranscoder @Inject constructor( companion object { /** Also used by [WatchPlaylistTransferEstimator] to size-estimate songs that will be transcoded. */ const val TARGET_BITRATE_BPS = 128_000 + + /** + * Container mime type of [transcodeIfNeeded]'s output file (an .m4a produced by + * [Transformer]'s default muxer) — used by callers reporting [WatchAudioOverride][ + * PhoneDirectWatchTransferCoordinator.WatchAudioOverride] metadata to the watch. + */ + const val TRANSCODED_OUTPUT_MIME_TYPE = "audio/mp4" + private const val TAG = "WatchAudioTranscoder" private const val MAX_PASSTHROUGH_BITRATE_BPS = 256_000 private const val PROGRESS_POLL_INTERVAL_MS = 250L From 44bbffb8cdc685363565f57c6bb3d580596082e2 Mon Sep 17 00:00:00 2001 From: PonceGL Date: Sat, 8 Aug 2026 12:45:17 -0600 Subject: [PATCH 16/40] feat(wear): show playlist batch progress in the transfer notification MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../wear/WatchTransferForegroundService.kt | 117 +++++++++++++++--- app/src/main/res/values/strings_library.xml | 3 + 2 files changed, 104 insertions(+), 16 deletions(-) diff --git a/app/src/main/java/com/theveloper/pixelplay/data/service/wear/WatchTransferForegroundService.kt b/app/src/main/java/com/theveloper/pixelplay/data/service/wear/WatchTransferForegroundService.kt index 5a956da61..a799a86b5 100644 --- a/app/src/main/java/com/theveloper/pixelplay/data/service/wear/WatchTransferForegroundService.kt +++ b/app/src/main/java/com/theveloper/pixelplay/data/service/wear/WatchTransferForegroundService.kt @@ -24,6 +24,7 @@ import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.launch import timber.log.Timber @@ -43,7 +44,10 @@ class WatchTransferForegroundService : Service() { } override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { - val notification = buildNotification(transferStateStore.transfers.value.values.toList()) + val notification = buildNotification( + transferStateStore.transfers.value.values.toList(), + transferStateStore.batchTransfers.value.values.toList(), + ) if (!hasStartedForeground) { startInForeground(notification) } else { @@ -63,21 +67,24 @@ class WatchTransferForegroundService : Service() { private fun observeTransfers() { transferObserverJob?.cancel() transferObserverJob = serviceScope.launch { - transferStateStore.transfers.collect { transfers -> - val states = transfers.values.toList() - if (states.isEmpty()) { - stopForegroundCompat() - stopSelf() - return@collect - } - - val notification = buildNotification(states) - if (!hasStartedForeground) { - startInForeground(notification) - } else { - notificationManager().notify(NOTIFICATION_ID, notification) + combine( + transferStateStore.transfers, + transferStateStore.batchTransfers, + ) { transfers, batches -> transfers.values.toList() to batches.values.toList() } + .collect { (transferStates, batchStates) -> + if (transferStates.isEmpty() && batchStates.isEmpty()) { + stopForegroundCompat() + stopSelf() + return@collect + } + + val notification = buildNotification(transferStates, batchStates) + if (!hasStartedForeground) { + startInForeground(notification) + } else { + notificationManager().notify(NOTIFICATION_ID, notification) + } } - } } } @@ -110,7 +117,85 @@ class WatchTransferForegroundService : Service() { hasStartedForeground = false } - private fun buildNotification(transfers: List): Notification { + /** + * A playlist batch, when one is active or just finished, takes priority over any concurrent + * lone single-song transfer (e.g. from the song info sheet) — it's the longer-running, more + * significant operation, and showing both at once would make the notification unreadable. + */ + private fun buildNotification( + transfers: List, + batches: List, + ): Notification { + val selectedBatch = batches.firstOrNull { it.status == WearTransferProgress.STATUS_TRANSFERRING } + ?: batches.maxByOrNull { it.updatedAtMillis } + return if (selectedBatch != null) { + buildBatchNotification(selectedBatch) + } else { + buildSongNotification(transfers) + } + } + + private fun buildBatchNotification(batch: PhoneWatchBatchTransferState): Notification { + val isOngoing = batch.status == WearTransferProgress.STATUS_TRANSFERRING + val title = when (batch.status) { + WearTransferProgress.STATUS_TRANSFERRING -> + getString(R.string.watch_transfer_status_sending_playlist_to_watch, batch.playlistName) + WearTransferProgress.STATUS_COMPLETED -> getString(R.string.watch_transfer_status_complete_service) + WearTransferProgress.STATUS_FAILED -> getString(R.string.watch_transfer_status_failed_service) + WearTransferProgress.STATUS_CANCELLED -> getString(R.string.watch_transfer_status_cancelled_service) + else -> getString(R.string.watch_transfer_status_preparing_service) + } + val contentText = getString( + R.string.watch_transfer_batch_progress, + batch.processedSongCount, + batch.totalSongCount, + ) + val overallProgress = if (batch.totalSongCount > 0) { + ((batch.processedSongCount + batch.currentSongProgress) / batch.totalSongCount.toFloat()) + } else { + 0f + }.coerceIn(0f, 1f) + val progressPercent = (overallProgress * 100f).toInt().coerceIn(0, 100) + + val builder = NotificationCompat.Builder(this, NOTIFICATION_CHANNEL_ID) + .setSmallIcon(R.drawable.monochrome_player) + .setContentTitle(title) + .setContentText(contentText) + .setContentIntent(createOpenAppPendingIntent()) + .setCategory(NotificationCompat.CATEGORY_SERVICE) + .setPriority(NotificationCompat.PRIORITY_LOW) + .setOnlyAlertOnce(true) + .setSilent(true) + .setOngoing(isOngoing) + .setShowWhen(false) + .setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE) + + if (isOngoing) { + builder.setProgress(100, progressPercent, false) + } else { + builder.setProgress(0, 0, false) + } + + val detailText = buildBatchDetailedText(batch) + if (detailText.isNotBlank()) { + builder.setStyle(NotificationCompat.BigTextStyle().bigText(detailText)) + } + + return builder.build() + } + + private fun buildBatchDetailedText(batch: PhoneWatchBatchTransferState): String { + val songLine = batch.currentSongTitle.ifBlank { null } + val failedLine = if (batch.failedSongCount > 0) { + getString(R.string.watch_transfer_batch_failed_count, batch.failedSongCount) + } else { + null + } + val errorLine = batch.errorMessage?.takeIf { it.isNotBlank() } + return listOfNotNull(songLine, failedLine, errorLine).joinToString(separator = "\n") + } + + private fun buildSongNotification(transfers: List): Notification { val activeTransfers = transfers.filter { it.status == WearTransferProgress.STATUS_TRANSFERRING } val selectedTransfer = activeTransfers.maxByOrNull { it.updatedAtMillis } ?: transfers.maxByOrNull { it.updatedAtMillis } diff --git a/app/src/main/res/values/strings_library.xml b/app/src/main/res/values/strings_library.xml index 8b7c6c2c6..539617f73 100644 --- a/app/src/main/res/values/strings_library.xml +++ b/app/src/main/res/values/strings_library.xml @@ -265,6 +265,8 @@ Cancel transfer + %1$d songs failed + %1$d of %2$d songs %1$s / %2$s Shows live progress for phone-to-watch music transfers Watch transfers @@ -281,6 +283,7 @@ Preparing watch transfer Preparing transfer… Sending %1$d songs to watch + Sending \"%1$s\" to watch Sending to watch Starting transfer… Starting From e93517a18d7e10885a8abe2d0984b6e0a1ee3d6d Mon Sep 17 00:00:00 2001 From: PonceGL Date: Sat, 8 Aug 2026 12:45:30 -0600 Subject: [PATCH 17/40] test(wear): add unit tests for the playlist batch coordinator and phone transfer state store MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../wear/PhoneWatchTransferStateStoreTest.kt | 225 ++++++++++++++ .../PlaylistWatchTransferCoordinatorTest.kt | 280 ++++++++++++++++++ 2 files changed, 505 insertions(+) create mode 100644 app/src/test/java/com/theveloper/pixelplay/data/service/wear/PhoneWatchTransferStateStoreTest.kt create mode 100644 app/src/test/java/com/theveloper/pixelplay/data/service/wear/PlaylistWatchTransferCoordinatorTest.kt diff --git a/app/src/test/java/com/theveloper/pixelplay/data/service/wear/PhoneWatchTransferStateStoreTest.kt b/app/src/test/java/com/theveloper/pixelplay/data/service/wear/PhoneWatchTransferStateStoreTest.kt new file mode 100644 index 000000000..514dab13c --- /dev/null +++ b/app/src/test/java/com/theveloper/pixelplay/data/service/wear/PhoneWatchTransferStateStoreTest.kt @@ -0,0 +1,225 @@ +package com.theveloper.pixelplay.data.service.wear + +import com.google.common.truth.Truth.assertThat +import com.theveloper.pixelplay.shared.WearTransferProgress +import org.junit.jupiter.api.Test + +/** + * Doesn't exercise the store's terminal-state cleanup (it runs on an internal, non-injectable + * `Dispatchers.Default` scope after a fixed real-time delay — asserting on it here would mean + * either a real sleep, which `GEN-TEST-04` rules out, or refactoring the store's scope handling, + * which is out of scope for this change). Every test below only asserts on state transitions that + * are visible synchronously. + */ +class PhoneWatchTransferStateStoreTest { + + private val store = PhoneWatchTransferStateStore() + + // --- Per-song transfers (existing, previously untested) --- + + @Test + fun `markRequested creates a transferring entry`() { + store.markRequested(requestId = "r1", songId = "s1", songTitle = "Song") + + val state = store.transfers.value["r1"] + assertThat(state?.songId).isEqualTo("s1") + assertThat(state?.status).isEqualTo(WearTransferProgress.STATUS_TRANSFERRING) + } + + @Test + fun `markProgress keeps the highest bytesTransferred seen, never regresses`() { + store.markProgress("r1", "s1", bytesTransferred = 500L, totalBytes = 1000L, status = WearTransferProgress.STATUS_TRANSFERRING) + store.markProgress("r1", "s1", bytesTransferred = 200L, totalBytes = 1000L, status = WearTransferProgress.STATUS_TRANSFERRING) + + assertThat(store.transfers.value["r1"]?.bytesTransferred).isEqualTo(500L) + } + + @Test + fun `progress is the clamped ratio of bytesTransferred to totalBytes`() { + val state = PhoneWatchTransferState(requestId = "r1", songId = "s1", bytesTransferred = 50L, totalBytes = 100L) + assertThat(state.progress).isEqualTo(0.5f) + } + + @Test + fun `progress is zero when totalBytes is not yet known`() { + val state = PhoneWatchTransferState(requestId = "r1", songId = "s1", bytesTransferred = 0L, totalBytes = 0L) + assertThat(state.progress).isEqualTo(0f) + } + + @Test + fun `markCancelled marks an existing transfer as cancelled without creating a new one`() { + store.markRequested("r1", "s1") + store.markCancelled("r1", error = "user cancelled") + + val state = store.transfers.value["r1"] + assertThat(state?.status).isEqualTo(WearTransferProgress.STATUS_CANCELLED) + assertThat(state?.error).isEqualTo("user cancelled") + } + + @Test + fun `markCancelled for an unknown requestId is a no-op`() { + store.markCancelled("unknown") + assertThat(store.transfers.value).isEmpty() + } + + @Test + fun `markSongPresentOnWatch and isSongSavedOnAllReachableWatches agree once every reachable node has it`() { + store.retainReachableWatchNodes(setOf("node-1", "node-2")) + + assertThat(store.isSongSavedOnAllReachableWatches("s1")).isFalse() + + store.markSongPresentOnWatch("node-1", "s1") + assertThat(store.isSongSavedOnAllReachableWatches("s1")).isFalse() + + store.markSongPresentOnWatch("node-2", "s1") + assertThat(store.isSongSavedOnAllReachableWatches("s1")).isTrue() + } + + @Test + fun `isSongSavedOnAllReachableWatches is false when there are no reachable watches`() { + assertThat(store.isSongSavedOnAllReachableWatches("s1")).isFalse() + } + + @Test + fun `retainReachableWatchNodes forgets song presence recorded for a node that dropped out`() { + store.retainReachableWatchNodes(setOf("node-1")) + store.markSongPresentOnWatch("node-1", "s1") + assertThat(store.isSongSavedOnAllReachableWatches("s1")).isTrue() + + store.retainReachableWatchNodes(setOf("node-2")) + + assertThat(store.watchSongIds.value).isEmpty() + } + + // --- Playlist batch transfers --- + + @Test + fun `markBatchStarted publishes the initial aggregate state`() { + store.markBatchStarted("b1", "playlist-1", "Running mix", totalSongCount = 20) + + val batch = store.batchTransfers.value["b1"] + assertThat(batch?.playlistName).isEqualTo("Running mix") + assertThat(batch?.totalSongCount).isEqualTo(20) + assertThat(batch?.completedSongCount).isEqualTo(0) + assertThat(batch?.failedSongCount).isEqualTo(0) + assertThat(batch?.status).isEqualTo(WearTransferProgress.STATUS_TRANSFERRING) + } + + @Test + fun `song lifecycle updates activeRequestId, progress and completed count`() { + store.markBatchStarted("b1", "p1", "Playlist", totalSongCount = 2) + + store.markBatchSongStarted("b1", activeRequestId = "r1", songTitle = "Track 1") + var batch = store.batchTransfers.value["b1"] + assertThat(batch?.activeRequestId).isEqualTo("r1") + assertThat(batch?.currentSongTitle).isEqualTo("Track 1") + + store.markBatchSongProgress("b1", WearTransferProgress.STATUS_TRANSFERRING, progress = 0.6f) + batch = store.batchTransfers.value["b1"] + assertThat(batch?.currentSongProgress).isEqualTo(0.6f) + + store.markBatchSongCompleted("b1") + batch = store.batchTransfers.value["b1"] + assertThat(batch?.completedSongCount).isEqualTo(1) + assertThat(batch?.activeRequestId).isNull() + assertThat(batch?.currentSongProgress).isEqualTo(0f) + } + + @Test + fun `markBatchSongProgress clamps out-of-range values`() { + store.markBatchStarted("b1", "p1", "Playlist", totalSongCount = 1) + + store.markBatchSongProgress("b1", WearTransferProgress.STATUS_TRANSFERRING, progress = 1.5f) + assertThat(store.batchTransfers.value["b1"]?.currentSongProgress).isEqualTo(1f) + + store.markBatchSongProgress("b1", WearTransferProgress.STATUS_TRANSFERRING, progress = -0.5f) + assertThat(store.batchTransfers.value["b1"]?.currentSongProgress).isEqualTo(0f) + } + + @Test + fun `markBatchSongFailed increments the failure count and records the reason`() { + store.markBatchStarted("b1", "p1", "Playlist", totalSongCount = 1) + + store.markBatchSongFailed("b1", errorMessage = WearTransferProgress.ERROR_CODE_TIMED_OUT) + + val batch = store.batchTransfers.value["b1"] + assertThat(batch?.failedSongCount).isEqualTo(1) + assertThat(batch?.errorMessage).isEqualTo(WearTransferProgress.ERROR_CODE_TIMED_OUT) + } + + @Test + fun `markBatchSongFailed without a new reason keeps the previous one`() { + store.markBatchStarted("b1", "p1", "Playlist", totalSongCount = 2) + store.markBatchSongFailed("b1", errorMessage = "first failure") + + store.markBatchSongFailed("b1", errorMessage = null) + + assertThat(store.batchTransfers.value["b1"]?.errorMessage).isEqualTo("first failure") + assertThat(store.batchTransfers.value["b1"]?.failedSongCount).isEqualTo(2) + } + + @Test + fun `markBatchCompleted sets the terminal status and clears the active song`() { + store.markBatchStarted("b1", "p1", "Playlist", totalSongCount = 1) + store.markBatchSongStarted("b1", "r1", "Track") + + store.markBatchCompleted("b1") + + val batch = store.batchTransfers.value["b1"] + assertThat(batch?.status).isEqualTo(WearTransferProgress.STATUS_COMPLETED) + assertThat(batch?.activeRequestId).isNull() + } + + @Test + fun `markBatchFailed records the error message and sets the terminal status`() { + store.markBatchStarted("b1", "p1", "Playlist", totalSongCount = 1) + + store.markBatchFailed("b1", "No reachable watch with PixelPlay") + + val batch = store.batchTransfers.value["b1"] + assertThat(batch?.status).isEqualTo(WearTransferProgress.STATUS_FAILED) + assertThat(batch?.errorMessage).isEqualTo("No reachable watch with PixelPlay") + } + + @Test + fun `markBatchCancelled sets the cancelled status and keeps the song counts so far`() { + store.markBatchStarted("b1", "p1", "Playlist", totalSongCount = 3) + store.markBatchSongCompleted("b1") + + store.markBatchCancelled("b1") + + val batch = store.batchTransfers.value["b1"] + assertThat(batch?.status).isEqualTo(WearTransferProgress.STATUS_CANCELLED) + assertThat(batch?.completedSongCount).isEqualTo(1) + } + + @Test + fun `updates for an unknown batchId are ignored rather than creating a partial entry`() { + store.markBatchSongCompleted("never-started") + store.markBatchSongProgress("never-started", WearTransferProgress.STATUS_TRANSFERRING, 0.5f) + store.markBatchCompleted("never-started") + + assertThat(store.batchTransfers.value).isEmpty() + } + + @Test + fun `processedSongCount sums completed and failed songs`() { + store.markBatchStarted("b1", "p1", "Playlist", totalSongCount = 5) + store.markBatchSongCompleted("b1") + store.markBatchSongCompleted("b1") + store.markBatchSongFailed("b1") + + assertThat(store.batchTransfers.value["b1"]?.processedSongCount).isEqualTo(3) + } + + @Test + fun `two concurrent batches keep independent state`() { + store.markBatchStarted("b1", "p1", "Playlist 1", totalSongCount = 2) + store.markBatchStarted("b2", "p2", "Playlist 2", totalSongCount = 5) + + store.markBatchSongCompleted("b1") + + assertThat(store.batchTransfers.value["b1"]?.completedSongCount).isEqualTo(1) + assertThat(store.batchTransfers.value["b2"]?.completedSongCount).isEqualTo(0) + } +} diff --git a/app/src/test/java/com/theveloper/pixelplay/data/service/wear/PlaylistWatchTransferCoordinatorTest.kt b/app/src/test/java/com/theveloper/pixelplay/data/service/wear/PlaylistWatchTransferCoordinatorTest.kt new file mode 100644 index 000000000..2609a3494 --- /dev/null +++ b/app/src/test/java/com/theveloper/pixelplay/data/service/wear/PlaylistWatchTransferCoordinatorTest.kt @@ -0,0 +1,280 @@ +package com.theveloper.pixelplay.data.service.wear + +import android.app.Application +import com.google.android.gms.tasks.Tasks +import com.google.android.gms.wearable.CapabilityClient +import com.google.android.gms.wearable.CapabilityInfo +import com.google.android.gms.wearable.MessageClient +import com.google.android.gms.wearable.Node +import com.google.common.truth.Truth.assertThat +import com.theveloper.pixelplay.data.model.Song +import com.theveloper.pixelplay.data.repository.MusicRepository +import com.theveloper.pixelplay.shared.WearTransferProgress +import io.mockk.Runs +import io.mockk.coEvery +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test + +/** + * capabilityClient/messageClient are constructor-injected into the coordinator (unlike most of + * the wear/ package, which resolves them via `Wearable.getXClient(application)` internally) so + * they can be faked here directly — both are non-final abstract GMS classes, so MockK subclasses + * them with no inline-mocking agent involved. Mocking `Wearable`'s static factory methods instead + * would need that agent, which hangs indefinitely under this environment's sandboxing. + */ +class PlaylistWatchTransferCoordinatorTest { + + private val application = mockk(relaxed = true) + private val musicRepository = mockk() + private val watchAudioTranscoder = mockk() + private val directTransferCoordinator = mockk(relaxed = true) + private val wearPhoneTransferSender = mockk(relaxed = true) + private val transferStateStore = PhoneWatchTransferStateStore() + private val capabilityClient = mockk() + private val messageClient = mockk() + + private val transferredSongIdsInOrder = mutableListOf() + + @BeforeEach + fun setUp() { + // Default: no song needs transcoding. transcodeIfNeeded is what the coordinator actually + // calls — requiresTranscoding lives inside it and is never invoked directly by the + // coordinator, so stubbing that instead would silently test nothing. + coEvery { watchAudioTranscoder.transcodeIfNeeded(any(), any(), any()) } returns + WatchAudioTranscoder.TranscodeResult.Passthrough + every { watchAudioTranscoder.cleanup(any()) } just Runs + + // Tasks.forResult builds a real, already-completed Task — play-services-tasks has no + // Android framework dependency for this, so it resolves correctly off-device. + every { messageClient.sendMessage(any(), any(), any()) } returns Tasks.forResult(0) + + every { musicRepository.getSongsByIds(any()) } answers { + val requestedIds = firstArg>() + flowOf(requestedIds.mapNotNull { id -> songsById[id] }) + } + } + + private val songsById = mutableMapOf() + + private fun song(id: String, title: String = "Song $id"): Song { + val song = Song.emptySong().copy(id = id, title = title) + songsById[id] = song + return song + } + + private fun stubReachableNodes(vararg nodeIds: String) { + val nodes = nodeIds.map { nodeId -> mockk { every { id } returns nodeId } }.toSet() + val capabilityInfo = mockk { every { this@mockk.nodes } returns nodes } + every { capabilityClient.getCapability(any(), any()) } returns Tasks.forResult(capabilityInfo) + } + + /** Every startTransferToWatch call resolves to [status] as soon as it's invoked. */ + private fun stubTransfersResolveTo(status: String) { + every { + directTransferCoordinator.startTransferToWatch( + nodeId = any(), + requestId = any(), + songId = any(), + transferMode = any(), + startPositionMs = any(), + autoPlay = any(), + audioOverride = any(), + ) + } answers { + val requestId = secondArg() + val songId = thirdArg() + transferredSongIdsInOrder += songId + transferStateStore.markProgress( + requestId = requestId, + songId = songId, + bytesTransferred = 100L, + totalBytes = 100L, + status = status, + ) + } + } + + private fun buildCoordinator(scope: kotlinx.coroutines.CoroutineScope) = PlaylistWatchTransferCoordinator( + application = application, + musicRepository = musicRepository, + watchAudioTranscoder = watchAudioTranscoder, + directTransferCoordinator = directTransferCoordinator, + wearPhoneTransferSender = wearPhoneTransferSender, + transferStateStore = transferStateStore, + capabilityClient = capabilityClient, + messageClient = messageClient, + scope = scope, + ) + + @Test + fun `an empty playlist does not start a batch`() = runTest { + val coordinator = buildCoordinator(this) + + coordinator.requestPlaylistTransfer("p1", "Empty", emptyList()) + advanceUntilIdle() + + assertThat(transferStateStore.batchTransfers.value).isEmpty() + verify(exactly = 0) { directTransferCoordinator.startTransferToWatch(any(), any(), any(), any(), any(), any(), any()) } + } + + @Test + fun `fails the batch when no watch is reachable`() = runTest { + stubReachableNodes() + song("s1") + val coordinator = buildCoordinator(this) + + val batchId = coordinator.requestPlaylistTransfer("p1", "Playlist", listOf("s1")) + advanceUntilIdle() + + val batch = transferStateStore.batchTransfers.value[batchId] + assertThat(batch?.status).isEqualTo(WearTransferProgress.STATUS_FAILED) + } + + @Test + fun `transfers songs in playlist order`() = runTest { + stubReachableNodes("node-1") + stubTransfersResolveTo(WearTransferProgress.STATUS_COMPLETED) + song("s3"); song("s1"); song("s2") + val coordinator = buildCoordinator(this) + + coordinator.requestPlaylistTransfer("p1", "Playlist", listOf("s3", "s1", "s2")) + advanceUntilIdle() + + assertThat(transferredSongIdsInOrder).containsExactly("s3", "s1", "s2").inOrder() + } + + @Test + fun `songs already saved on every reachable watch are not re-transferred`() = runTest { + stubReachableNodes("node-1") + transferStateStore.retainReachableWatchNodes(setOf("node-1")) + transferStateStore.markSongPresentOnWatch("node-1", "already-there") + stubTransfersResolveTo(WearTransferProgress.STATUS_COMPLETED) + song("already-there"); song("pending") + val coordinator = buildCoordinator(this) + + val batchId = coordinator.requestPlaylistTransfer("p1", "Playlist", listOf("already-there", "pending")) + advanceUntilIdle() + + assertThat(transferredSongIdsInOrder).containsExactly("pending") + val batch = transferStateStore.batchTransfers.value[batchId] + assertThat(batch?.completedSongCount).isEqualTo(2) + } + + @Test + fun `one song failing does not abort the rest of the batch`() = runTest { + stubReachableNodes("node-1") + song("s1"); song("s2"); song("s3") + every { + directTransferCoordinator.startTransferToWatch( + nodeId = any(), requestId = any(), songId = any(), + transferMode = any(), startPositionMs = any(), autoPlay = any(), audioOverride = any(), + ) + } answers { + val requestId = secondArg() + val songId = thirdArg() + transferredSongIdsInOrder += songId + val status = if (songId == "s2") WearTransferProgress.STATUS_FAILED else WearTransferProgress.STATUS_COMPLETED + transferStateStore.markProgress(requestId, songId, 0L, 0L, status) + } + val coordinator = buildCoordinator(this) + + val batchId = coordinator.requestPlaylistTransfer("p1", "Playlist", listOf("s1", "s2", "s3")) + advanceUntilIdle() + + assertThat(transferredSongIdsInOrder).containsExactly("s1", "s2", "s3").inOrder() + val batch = transferStateStore.batchTransfers.value[batchId] + assertThat(batch?.completedSongCount).isEqualTo(2) + assertThat(batch?.failedSongCount).isEqualTo(1) + assertThat(batch?.status).isEqualTo(WearTransferProgress.STATUS_COMPLETED) + } + + @Test + fun `cancelling a batch stops remaining songs from being transferred`() = runTest { + stubReachableNodes("node-1") + song("s1"); song("s2"); song("s3") + val coordinator = buildCoordinator(this) + lateinit var batchId: String + + every { + directTransferCoordinator.startTransferToWatch( + nodeId = any(), requestId = any(), songId = any(), + transferMode = any(), startPositionMs = any(), autoPlay = any(), audioOverride = any(), + ) + } answers { + val requestId = secondArg() + val songId = thirdArg() + transferredSongIdsInOrder += songId + if (songId == "s1") { + // Cancel mid-batch, right after the first song starts, before it resolves. + coordinator.cancelPlaylistTransfer(batchId) + } + transferStateStore.markProgress(requestId, songId, 0L, 0L, WearTransferProgress.STATUS_COMPLETED) + } + + batchId = coordinator.requestPlaylistTransfer("p1", "Playlist", listOf("s1", "s2", "s3")) + advanceUntilIdle() + + assertThat(transferredSongIdsInOrder).containsExactly("s1") + val batch = transferStateStore.batchTransfers.value[batchId] + assertThat(batch?.status).isEqualTo(WearTransferProgress.STATUS_CANCELLED) + } + + @Test + fun `a song whose transfer never reaches a terminal state is failed as timed out`() = runTest { + stubReachableNodes("node-1") + song("s1") + // directTransferCoordinator is a relaxed mock here — startTransferToWatch is a no-op and + // never pushes a terminal state into transferStateStore, simulating a watch that never + // acknowledges the transfer. + val coordinator = buildCoordinator(this) + coordinator.songTransferAwaitTimeoutMs = 50L + + val batchId = coordinator.requestPlaylistTransfer("p1", "Playlist", listOf("s1")) + advanceUntilIdle() + + val batch = transferStateStore.batchTransfers.value[batchId] + assertThat(batch?.failedSongCount).isEqualTo(1) + assertThat(batch?.status).isEqualTo(WearTransferProgress.STATUS_COMPLETED) + } + + @Test + fun `sends the song to every reachable node, counting it as one completed song`() = runTest { + stubReachableNodes("node-1", "node-2") + stubTransfersResolveTo(WearTransferProgress.STATUS_COMPLETED) + song("s1") + val coordinator = buildCoordinator(this) + + val batchId = coordinator.requestPlaylistTransfer("p1", "Playlist", listOf("s1")) + advanceUntilIdle() + + verify(exactly = 2) { + directTransferCoordinator.startTransferToWatch( + nodeId = any(), requestId = any(), songId = any(), + transferMode = any(), startPositionMs = any(), autoPlay = any(), audioOverride = any(), + ) + } + val batch = transferStateStore.batchTransfers.value[batchId] + assertThat(batch?.completedSongCount).isEqualTo(1) + } + + @Test + fun `a song missing from the library is counted as failed, not silently dropped`() = runTest { + stubReachableNodes("node-1") + // "missing" is never registered via song(), so musicRepository.getSongsByIds returns nothing for it. + val coordinator = buildCoordinator(this) + + val batchId = coordinator.requestPlaylistTransfer("p1", "Playlist", listOf("missing")) + advanceUntilIdle() + + val batch = transferStateStore.batchTransfers.value[batchId] + assertThat(batch?.failedSongCount).isEqualTo(1) + assertThat(batch?.completedSongCount).isEqualTo(0) + } +} From f1a0b4554655cf1721544b39e39dac31930d5b01 Mon Sep 17 00:00:00 2001 From: PonceGL Date: Sat, 8 Aug 2026 13:13:43 -0600 Subject: [PATCH 18/40] feat(app): add playlist watch-transfer methods to PlaylistViewModel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../viewmodel/PlaylistViewModel.kt | 78 +++++++++++++++++++ 1 file changed, 78 insertions(+) diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/PlaylistViewModel.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/PlaylistViewModel.kt index 8c5c620ef..a95ed3dde 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/PlaylistViewModel.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/PlaylistViewModel.kt @@ -14,6 +14,14 @@ import com.theveloper.pixelplay.data.model.SortOption import com.theveloper.pixelplay.data.playlist.M3uManager import com.theveloper.pixelplay.data.preferences.PlaylistPreferencesRepository import com.theveloper.pixelplay.data.repository.MusicRepository +import com.theveloper.pixelplay.data.service.wear.PhoneWatchBatchTransferState +import com.theveloper.pixelplay.data.service.wear.PhoneWatchTransferStateStore +import com.theveloper.pixelplay.data.service.wear.PlaylistWatchTransferCoordinator +import com.theveloper.pixelplay.data.service.wear.WatchAudioTranscoder +import com.theveloper.pixelplay.data.service.wear.WatchPlaylistTransferEstimate +import com.theveloper.pixelplay.data.service.wear.WatchPlaylistTransferEstimator +import com.theveloper.pixelplay.data.service.wear.WearPhoneTransferSender +import com.theveloper.pixelplay.shared.WearTransferProgress import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow @@ -22,6 +30,9 @@ import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -75,6 +86,10 @@ class PlaylistViewModel @Inject constructor( private val dailyMixManager: DailyMixManager, private val aiPlaylistGenerator: AiPlaylistGenerator, private val m3uManager: M3uManager, + private val playlistWatchTransferCoordinator: PlaylistWatchTransferCoordinator, + private val watchTransferStateStore: PhoneWatchTransferStateStore, + private val wearPhoneTransferSender: WearPhoneTransferSender, + private val watchAudioTranscoder: WatchAudioTranscoder, @ApplicationContext private val context: Context ) : ViewModel() { @@ -87,10 +102,34 @@ class PlaylistViewModel @Inject constructor( ) val playlistCreationEvent: SharedFlow = _playlistCreationEvent.asSharedFlow() + private val _isPixelPlayWatchAvailable = MutableStateFlow(false) + val isPixelPlayWatchAvailable: StateFlow = _isPixelPlayWatchAvailable.asStateFlow() + private val _isRefreshingWatchAvailability = MutableStateFlow(false) + val watchSongIds: StateFlow> = watchTransferStateStore.watchSongIds + + /** + * Whichever playlist batch transfer is currently active, regardless of which screen/ViewModel + * instance started it — queried off the shared [PhoneWatchTransferStateStore] instead of + * remembering "the last batchId this instance kicked off", so re-entering the detail screen + * for the same playlist still sees a batch already in flight. + */ + val activePlaylistBatchTransfer: StateFlow = watchTransferStateStore.batchTransfers + .map { batches -> batches.values.firstOrNull { it.status !in TERMINAL_BATCH_STATUSES } } + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000L), + initialValue = null, + ) + companion object { const val FOLDER_PLAYLIST_PREFIX = "folder_playlist:" private const val MANUAL_ORDER_MODE = "manual" private const val SMART_PLAYLIST_MAX_ITEMS = 100 + private val TERMINAL_BATCH_STATUSES = setOf( + WearTransferProgress.STATUS_COMPLETED, + WearTransferProgress.STATUS_FAILED, + WearTransferProgress.STATUS_CANCELLED, + ) fun sanitizeFileName(name: String): String { val sanitized = name.replace(Regex("[\\\\/:*?\"<>|\\s]+"), "_").trim('_') @@ -1224,4 +1263,43 @@ class PlaylistViewModel @Inject constructor( } } } + + // --- Watch transfer --- + + /** + * Refreshes reachable-watch capability + free storage. Call before showing the send-to-watch + * confirmation sheet so its estimate and availability state are current. + */ + fun refreshWatchAvailability() { + if (_isRefreshingWatchAvailability.value) return + + viewModelScope.launch { + _isRefreshingWatchAvailability.value = true + val available = wearPhoneTransferSender.isPixelPlayWatchAvailable() + _isPixelPlayWatchAvailable.value = available + _isRefreshingWatchAvailability.value = false + if (available) { + wearPhoneTransferSender.refreshWatchLibraryState() + } + } + } + + fun isPlaylistFullyOnWatch(songIds: List): Boolean { + return songIds.isNotEmpty() && songIds.all { watchTransferStateStore.isSongSavedOnAllReachableWatches(it) } + } + + /** Size/time estimate over only the songs from [songs] that aren't already on every reachable watch. */ + fun estimateWatchTransfer(songs: List): WatchPlaylistTransferEstimate { + val pendingSongs = songs.filterNot { watchTransferStateStore.isSongSavedOnAllReachableWatches(it.id) } + return WatchPlaylistTransferEstimator.estimate(songs, pendingSongs, watchAudioTranscoder) + } + + /** Returns the generated batchId immediately; the transfer itself runs asynchronously. */ + fun sendPlaylistToWatch(playlistId: String, playlistName: String, songIds: List): String { + return playlistWatchTransferCoordinator.requestPlaylistTransfer(playlistId, playlistName, songIds) + } + + fun cancelPlaylistTransfer(batchId: String) { + playlistWatchTransferCoordinator.cancelPlaylistTransfer(batchId) + } } From 53515e3499909739426151e98fb2f71534ff180b Mon Sep 17 00:00:00 2001 From: PonceGL Date: Sat, 8 Aug 2026 13:15:12 -0600 Subject: [PATCH 19/40] feat(app): add send-to-watch action and confirmation dialog to the playlist screen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../screens/PlaylistDetailScreen.kt | 170 ++++++++++++++++++ app/src/main/res/values/strings_screens.xml | 13 ++ 2 files changed, 183 insertions(+) diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/PlaylistDetailScreen.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/PlaylistDetailScreen.kt index 9783bf6c7..3c610da6c 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/PlaylistDetailScreen.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/PlaylistDetailScreen.kt @@ -136,6 +136,9 @@ import com.theveloper.pixelplay.ui.theme.GoogleSansRounded import com.theveloper.pixelplay.presentation.viewmodel.PlaylistSongsOrderMode import com.theveloper.pixelplay.utils.formatSongCount import com.theveloper.pixelplay.utils.formatTotalDuration +import com.theveloper.pixelplay.utils.formatListeningDurationCompact +import com.theveloper.pixelplay.data.service.wear.PhoneWatchBatchTransferState +import androidx.compose.material3.LinearProgressIndicator import racra.compose.smooth_corner_rect_library.AbsoluteSmoothCornerShape import sh.calvin.reorderable.ReorderableItem import sh.calvin.reorderable.rememberReorderableLazyListState @@ -183,6 +186,9 @@ fun PlaylistDetailScreen( val deletePlaylistLabel = stringResource(R.string.playlist_action_delete_playlist) val setDefaultTransitionLabel = stringResource(R.string.playlist_action_set_default_transition) val exportPlaylistLabel = stringResource(R.string.playlist_action_export_playlist) + val sendToWatchLabel = stringResource(R.string.playlist_action_send_to_watch) + val updateOnWatchLabel = stringResource(R.string.playlist_action_update_on_watch) + val sendToWatchCd = stringResource(R.string.playlist_cd_send_to_watch) val deletePlaylistConfirmTitle = stringResource(R.string.playlist_dialog_delete_title) val deletePlaylistConfirmBody = stringResource(R.string.playlist_dialog_delete_body) val sortSheetTitle = stringResource(R.string.playlist_sort_songs_title) @@ -204,6 +210,7 @@ fun PlaylistDetailScreen( var showPlaylistOptionsSheet by remember { mutableStateOf(false) } var showEditPlaylistDialog by remember { mutableStateOf(false) } var showDeleteConfirmation by remember { mutableStateOf(false) } + var showSendToWatchDialog by remember { mutableStateOf(false) } var searchQuery by remember(playlistId) { mutableStateOf("") } LaunchedEffect(searchQuery.isNotBlank()) { @@ -225,6 +232,13 @@ fun PlaylistDetailScreen( val selectedSongForInfo by playerViewModel.selectedSongForInfo.collectAsStateWithLifecycle() val favoriteIds by playerViewModel.favoriteSongIds.collectAsStateWithLifecycle() // Reintroducir favoriteIds aquí + val isPixelPlayWatchAvailable by playlistViewModel.isPixelPlayWatchAvailable.collectAsStateWithLifecycle() + val watchSongIds by playlistViewModel.watchSongIds.collectAsStateWithLifecycle() + val activeBatchTransfer by playlistViewModel.activePlaylistBatchTransfer.collectAsStateWithLifecycle() + val activePlaylistTransfer = activeBatchTransfer?.takeIf { it.playlistId == playlistId } + val isAnySongOnWatch = remember(songsInPlaylist, watchSongIds) { + songsInPlaylist.isNotEmpty() && songsInPlaylist.any { it.id in watchSongIds } + } val stableOnMoreOptionsClick: (Song) -> Unit = remember { { song -> playerViewModel.selectSongForInfo(song) @@ -367,6 +381,12 @@ fun PlaylistDetailScreen( .fillMaxSize() .padding(top = innerPadding.calculateTopPadding()) ) { + activePlaylistTransfer?.let { batch -> + WatchTransferProgressBanner( + batch = batch, + onCancelClick = { playlistViewModel.cancelPlaylistTransfer(batch.batchId) }, + ) + } val actionButtonsHeight = 42.dp val playbackControlBottomPadding = if (isFolderPlaylist) 8.dp else 6.dp if (searchQuery.isBlank()) { @@ -898,6 +918,15 @@ fun PlaylistDetailScreen( showEditPlaylistDialog = true } ) + PlaylistActionItem( + icon = painterResource(R.drawable.rounded_watch_arrow_down_24), + label = if (isAnySongOnWatch) updateOnWatchLabel else sendToWatchLabel, + onClick = { + showPlaylistOptionsSheet = false + playlistViewModel.refreshWatchAvailability() + showSendToWatchDialog = true + } + ) PlaylistActionItem( icon = painterResource(R.drawable.rounded_delete_24), label = deletePlaylistLabel, @@ -992,6 +1021,90 @@ fun PlaylistDetailScreen( ) } + if (showSendToWatchDialog && currentPlaylist != null) { + val playlistName = currentPlaylist.name + val estimate = remember(songsInPlaylist, watchSongIds) { + playlistViewModel.estimateWatchTransfer(songsInPlaylist) + } + val estimatedSizeText = android.text.format.Formatter.formatShortFileSize(context, estimate.estimatedBytes) + val estimatedTimeText = formatListeningDurationCompact(estimate.estimatedTransferSeconds * 1000L) + val canSend = isPixelPlayWatchAvailable && estimate.pendingSongCount > 0 + + AlertDialog( + onDismissRequest = { showSendToWatchDialog = false }, + title = { + Text( + if (isAnySongOnWatch) { + stringResource(R.string.playlist_send_to_watch_dialog_update_title, playlistName) + } else { + stringResource(R.string.playlist_send_to_watch_dialog_title, playlistName) + } + ) + }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + when { + !isPixelPlayWatchAvailable -> Text(stringResource(R.string.playlist_send_to_watch_dialog_watch_unavailable)) + estimate.pendingSongCount == 0 -> Text( + stringResource(R.string.playlist_send_to_watch_dialog_all_songs, estimate.totalSongCount) + ) + else -> { + Text( + if (estimate.pendingSongCount == estimate.totalSongCount) { + stringResource(R.string.playlist_send_to_watch_dialog_all_songs, estimate.totalSongCount) + } else { + stringResource( + R.string.playlist_send_to_watch_dialog_pending_songs, + estimate.pendingSongCount, + estimate.totalSongCount, + ) + } + ) + Text( + text = stringResource( + R.string.playlist_send_to_watch_dialog_estimate, + estimatedSizeText, + estimatedTimeText, + ), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + }, + confirmButton = { + TextButton( + enabled = canSend, + onClick = { + showSendToWatchDialog = false + playlistViewModel.sendPlaylistToWatch( + currentPlaylist.id, + playlistName, + songsInPlaylist.map { it.id }, + ) + playerViewModel.sendToast( + context.getString(R.string.playlist_watch_transfer_started_toast, playlistName) + ) + } + ) { + Text( + if (isAnySongOnWatch) { + stringResource(R.string.playlist_send_to_watch_dialog_update_confirm) + } else { + stringResource(R.string.playlist_send_to_watch_dialog_confirm) + } + ) + } + }, + dismissButton = { + TextButton(onClick = { showSendToWatchDialog = false }) { + Text(stringResource(R.string.common_cancel)) + } + } + ) + } + if (showSongInfoBottomSheet && selectedSongForInfo != null) { val currentSong = selectedSongForInfo val isFavorite = remember(currentSong?.id, favoriteIds) { @@ -1190,3 +1303,60 @@ private fun PlaylistActionItem( ) } } + +/** + * Non-blocking playlist-transfer indicator shown at the top of the songs list — the user can + * leave the screen (or the app) while it continues; the foreground notification (see + * `WatchTransferForegroundService`) is what tracks completion once they do. + */ +@Composable +private fun WatchTransferProgressBanner( + batch: PhoneWatchBatchTransferState, + onCancelClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val overallProgress by remember(batch.processedSongCount, batch.currentSongProgress, batch.totalSongCount) { + derivedStateOf { + if (batch.totalSongCount > 0) { + ((batch.processedSongCount + batch.currentSongProgress) / batch.totalSongCount.toFloat()) + .coerceIn(0f, 1f) + } else { + 0f + } + } + } + + Row( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 20.dp, vertical = 8.dp) + .clip(RoundedCornerShape(18.dp)) + .background(MaterialTheme.colorScheme.surfaceContainerHigh) + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = stringResource( + R.string.watch_transfer_batch_progress, + batch.processedSongCount, + batch.totalSongCount, + ), + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSurface, + ) + LinearProgressIndicator( + progress = { overallProgress }, + modifier = Modifier + .fillMaxWidth() + .padding(top = 6.dp) + .clip(CircleShape), + ) + } + TextButton(onClick = onCancelClick) { + Text(stringResource(R.string.watch_transfer_action_cancel), maxLines = 1, overflow = TextOverflow.Ellipsis) + } + } +} + diff --git a/app/src/main/res/values/strings_screens.xml b/app/src/main/res/values/strings_screens.xml index 90ec1b118..082191d31 100644 --- a/app/src/main/res/values/strings_screens.xml +++ b/app/src/main/res/values/strings_screens.xml @@ -126,6 +126,19 @@ Remove songs Reorder Reorder songs + Send to Watch + Update on Watch + Send playlist to watch + Send \"%1$s\" to your watch? + Update \"%1$s\" on your watch? + %1$d of %2$d songs to send + %1$d songs + %1$s · about %2$s + Send + Update + No watch connected + Sending \"%1$s\" to your watch + Couldn\'t start the transfer: %1$s Global transitions From 0618bf0183292b326f215a2ee6670ba3fed55029 Mon Sep 17 00:00:00 2001 From: PonceGL Date: Sat, 8 Aug 2026 13:15:19 -0600 Subject: [PATCH 20/40] feat(app): show playlist batch transfer progress in the library screen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../presentation/screens/LibraryScreen.kt | 195 +++++++++++++++++- 1 file changed, 192 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/LibraryScreen.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/LibraryScreen.kt index 5d37d029e..9e92c17ac 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/LibraryScreen.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/LibraryScreen.kt @@ -245,6 +245,7 @@ import com.theveloper.pixelplay.presentation.components.ExpressiveScrollBar import com.theveloper.pixelplay.ui.theme.LocalShowScrollbar import com.theveloper.pixelplay.presentation.components.LibrarySortBottomSheet import com.theveloper.pixelplay.presentation.components.subcomps.EnhancedSongListItem +import com.theveloper.pixelplay.data.service.wear.PhoneWatchBatchTransferState import com.theveloper.pixelplay.data.service.wear.PhoneWatchTransferState import com.theveloper.pixelplay.shared.WearTransferProgress import java.io.File @@ -385,6 +386,131 @@ private fun WatchTransferProgressDialog( } } +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +private fun WatchPlaylistBatchProgressDialog( + batch: PhoneWatchBatchTransferState, + onDismiss: () -> Unit, + onCancelTransfer: () -> Unit, +) { + val rawProgress = if (batch.totalSongCount > 0) { + ((batch.processedSongCount + batch.currentSongProgress) / batch.totalSongCount.toFloat()) + } else { + 0f + }.coerceIn(0f, 1f) + val animatedProgress by animateFloatAsState( + targetValue = rawProgress, + animationSpec = tween(durationMillis = 300), + label = "WatchPlaylistBatchProgressDialog" + ) + val progressPercent = (animatedProgress * 100f).toInt().coerceIn(0, 100) + val statusText = when (batch.status) { + WearTransferProgress.STATUS_TRANSFERRING -> stringResource(R.string.watch_transfer_status_transferring) + WearTransferProgress.STATUS_COMPLETED -> stringResource(R.string.watch_transfer_status_completed) + WearTransferProgress.STATUS_FAILED -> stringResource(R.string.watch_transfer_status_failed) + WearTransferProgress.STATUS_CANCELLED -> stringResource(R.string.watch_transfer_status_cancelled) + else -> stringResource(R.string.watch_transfer_status_preparing) + } + val songsText = stringResource( + R.string.watch_transfer_batch_progress, + batch.processedSongCount, + batch.totalSongCount, + ) + + Dialog( + onDismissRequest = onDismiss, + properties = DialogProperties( + dismissOnBackPress = true, + dismissOnClickOutside = true + ) + ) { + Surface( + shape = RoundedCornerShape(28.dp), + tonalElevation = 6.dp, + color = MaterialTheme.colorScheme.surface + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp, vertical = 20.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = stringResource(R.string.watch_transfer_dialog_title), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface, + fontWeight = FontWeight.SemiBold + ) + Box( + modifier = Modifier + .size(96.dp) + .padding(vertical = 20.dp), + contentAlignment = Alignment.Center + ) { + LoadingIndicator( + modifier = Modifier + .fillMaxSize() + .scale(1.84f), + color = MaterialTheme.colorScheme.primary + ) + Text( + text = stringResource(R.string.common_percentage_text, progressPercent), + style = MaterialTheme.typography.labelLarge.copy( + fontSize = MaterialTheme.typography.labelLarge.fontSize * 1.4f + ), + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onPrimary + ) + } + LinearWavyProgressIndicator( + progress = { animatedProgress }, + modifier = Modifier + .fillMaxWidth() + .height(8.dp) + .clip(RoundedCornerShape(50)), + color = MaterialTheme.colorScheme.primary, + trackColor = MaterialTheme.colorScheme.surfaceContainerHighest + ) + Text( + text = batch.playlistName, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + textAlign = TextAlign.Center + ) + Text( + text = stringResource(R.string.watch_transfer_bullet_step, statusText, songsText), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center + ) + if (batch.failedSongCount > 0) { + Text( + text = stringResource(R.string.watch_transfer_batch_failed_count, batch.failedSongCount), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + textAlign = TextAlign.Center + ) + } + if (batch.status == WearTransferProgress.STATUS_TRANSFERRING) { + Button( + modifier = Modifier.padding(top = 4.dp), + onClick = onCancelTransfer, + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.error, + contentColor = MaterialTheme.colorScheme.onError + ) + ) { + Text(text = stringResource(R.string.watch_transfer_action_cancel), maxLines = 1, overflow = TextOverflow.Ellipsis) + } + } + } + } + } +} + private data class LibraryScreenPlayerProjection( val currentFolder: MusicFolder? = null, val folderSourceRootPath: String = "", @@ -486,6 +612,11 @@ fun LibraryScreen( val isSendingToWatch by songInfoBottomSheetViewModel.isSendingToWatch.collectAsStateWithLifecycle() val activeWatchTransfer by songInfoBottomSheetViewModel.activeWatchTransfer.collectAsStateWithLifecycle() var showWatchTransferDialog by remember { mutableStateOf(false) } + // A playlist batch takes priority over a concurrent lone single-song transfer in this badge — + // same priority rule as the transfer notification (WatchTransferForegroundService): it's the + // longer-running, more significant operation. + val activePlaylistBatchTransfer by playlistViewModel.activePlaylistBatchTransfer.collectAsStateWithLifecycle() + var showWatchBatchProgressDialog by remember { mutableStateOf(false) } val canNavigateBackInFolders by remember(playerViewModel) { playerViewModel.playerUiState .map { uiState -> uiState.currentFolder != null && uiState.folderBackGestureNavigationEnabled } @@ -508,6 +639,12 @@ fun LibraryScreen( var showReorderTabsSheet by remember { mutableStateOf(false) } var showTabSwitcherSheet by remember { mutableStateOf(false) } + LaunchedEffect(activePlaylistBatchTransfer?.batchId) { + if (activePlaylistBatchTransfer == null) { + showWatchBatchProgressDialog = false + } + } + LaunchedEffect(activeWatchTransfer?.requestId) { if (activeWatchTransfer == null) { showWatchTransferDialog = false @@ -847,14 +984,15 @@ fun LibraryScreen( TopAppBar( title = { if (isCompactNavigation) { + val isShowingWatchBadge = activePlaylistBatchTransfer != null || isSendingToWatch LibraryNavigationPill( modifier = Modifier, title = currentTabTitle, isExpanded = showTabSwitcherSheet, - showIcon = !isSendingToWatch, + showIcon = !isShowingWatchBadge, iconRes = currentTab.iconRes(), pageIndex = pagerState.currentPage, - compressForWatchTransfer = isSendingToWatch, + compressForWatchTransfer = isShowingWatchBadge, onClick = { showTabSwitcherSheet = true }, @@ -873,7 +1011,45 @@ fun LibraryScreen( } }, actions = { - if (isSendingToWatch) { + val currentBatch = activePlaylistBatchTransfer + if (currentBatch != null) { + val batchProgress = if (currentBatch.totalSongCount > 0) { + ((currentBatch.processedSongCount + currentBatch.currentSongProgress) / currentBatch.totalSongCount.toFloat()) + .coerceIn(0f, 1f) + } else { + 0f + } + val batchPercent = (batchProgress * 100f).toInt().coerceIn(0, 100) + Surface( + modifier = Modifier + .padding(end = 8.dp) + .wrapContentWidth() + .height(40.dp) + .clip(CircleShape) + .clickable { showWatchBatchProgressDialog = true }, + shape = CircleShape, + color = MaterialTheme.colorScheme.secondaryContainer, + contentColor = MaterialTheme.colorScheme.onSecondaryContainer + ) { + Row( + modifier = Modifier + .padding(horizontal = 8.dp), + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + painter = painterResource(R.drawable.rounded_watch_arrow_down_24), + contentDescription = stringResource(R.string.library_cd_watch_transfer), + modifier = Modifier.size(20.dp) + ) + Text( + text = stringResource(R.string.common_percentage_text, batchPercent), + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.Bold + ) + } + } + } else if (isSendingToWatch) { val watchTransferProgress = activeWatchTransfer?.progress ?: 0f val watchTransferPercent = (watchTransferProgress * 100f).toInt().coerceIn(0, 100) Surface( @@ -1818,6 +1994,19 @@ fun LibraryScreen( } ) + if (showWatchBatchProgressDialog) { + activePlaylistBatchTransfer?.let { currentBatch -> + WatchPlaylistBatchProgressDialog( + batch = currentBatch, + onDismiss = { showWatchBatchProgressDialog = false }, + onCancelTransfer = { + playlistViewModel.cancelPlaylistTransfer(currentBatch.batchId) + showWatchBatchProgressDialog = false + } + ) + } + } + if (showWatchTransferDialog && activeWatchTransfer != null) { val currentWatchTransfer = activeWatchTransfer!! WatchTransferProgressDialog( From 577e55e15e06395ca2690ac6ca722c36df2d4aaf Mon Sep 17 00:00:00 2001 From: PonceGL Date: Sat, 8 Aug 2026 13:15:25 -0600 Subject: [PATCH 21/40] test(app): add unit tests for PlaylistViewModel's watch-transfer methods MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../viewmodel/PlaylistViewModelTest.kt | 160 ++++++++++++++++++ 1 file changed, 160 insertions(+) create mode 100644 app/src/test/java/com/theveloper/pixelplay/presentation/viewmodel/PlaylistViewModelTest.kt diff --git a/app/src/test/java/com/theveloper/pixelplay/presentation/viewmodel/PlaylistViewModelTest.kt b/app/src/test/java/com/theveloper/pixelplay/presentation/viewmodel/PlaylistViewModelTest.kt new file mode 100644 index 000000000..74f00ef06 --- /dev/null +++ b/app/src/test/java/com/theveloper/pixelplay/presentation/viewmodel/PlaylistViewModelTest.kt @@ -0,0 +1,160 @@ +package com.theveloper.pixelplay.presentation.viewmodel + +import android.content.Context +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import com.theveloper.pixelplay.MainCoroutineExtension +import com.theveloper.pixelplay.data.DailyMixManager +import com.theveloper.pixelplay.data.ai.AiPlaylistGenerator +import com.theveloper.pixelplay.data.model.Song +import com.theveloper.pixelplay.data.playlist.M3uManager +import com.theveloper.pixelplay.data.preferences.PlaylistPreferencesRepository +import com.theveloper.pixelplay.data.preferences.TelegramTopicDisplayMode +import com.theveloper.pixelplay.data.repository.MusicRepository +import com.theveloper.pixelplay.data.service.wear.PhoneWatchTransferStateStore +import com.theveloper.pixelplay.data.service.wear.PlaylistWatchTransferCoordinator +import com.theveloper.pixelplay.data.service.wear.WatchAudioTranscoder +import com.theveloper.pixelplay.data.service.wear.WearPhoneTransferSender +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith + +/** + * Covers only the watch-transfer surface this feature adds — estimateWatchTransfer, + * isPlaylistFullyOnWatch, sendPlaylistToWatch, cancelPlaylistTransfer, activePlaylistBatchTransfer, + * refreshWatchAvailability. The rest of PlaylistViewModel's large existing surface (CRUD, + * sorting, AI generation, M3U import/export) is out of scope for this change and untouched. + */ +@ExperimentalCoroutinesApi +@ExtendWith(MainCoroutineExtension::class) +class PlaylistViewModelTest { + + private val playlistPreferencesRepository = mockk() + private val musicRepository = mockk() + private val dailyMixManager = mockk(relaxed = true) + private val aiPlaylistGenerator = mockk(relaxed = true) + private val m3uManager = mockk(relaxed = true) + private val playlistWatchTransferCoordinator = mockk() + private val watchTransferStateStore = PhoneWatchTransferStateStore() + private val wearPhoneTransferSender = mockk() + private val watchAudioTranscoder = mockk() + private val context = mockk(relaxed = true) + + @BeforeEach + fun setUp() { + every { playlistPreferencesRepository.userPlaylistsFlow } returns flowOf(emptyList()) + every { playlistPreferencesRepository.playlistSongOrderModesFlow } returns flowOf(emptyMap()) + every { playlistPreferencesRepository.playlistsSortOptionFlow } returns flowOf("") + every { playlistPreferencesRepository.showTelegramCloudPlaylistsFlow } returns flowOf(true) + every { playlistPreferencesRepository.telegramTopicDisplayModeFlow } returns + flowOf(TelegramTopicDisplayMode.CHANNELS_AND_TOPICS) + } + + private fun buildViewModel() = PlaylistViewModel( + playlistPreferencesRepository = playlistPreferencesRepository, + musicRepository = musicRepository, + dailyMixManager = dailyMixManager, + aiPlaylistGenerator = aiPlaylistGenerator, + m3uManager = m3uManager, + playlistWatchTransferCoordinator = playlistWatchTransferCoordinator, + watchTransferStateStore = watchTransferStateStore, + wearPhoneTransferSender = wearPhoneTransferSender, + watchAudioTranscoder = watchAudioTranscoder, + context = context, + ) + + private fun song(id: String, mimeType: String = "audio/mpeg", bitrate: Int? = 128_000) = + Song.emptySong().copy(id = id, mimeType = mimeType, bitrate = bitrate) + + @Test + fun `estimateWatchTransfer only counts songs not already on every reachable watch`() = runTest { + coEvery { watchAudioTranscoder.transcodeIfNeeded(any(), any(), any()) } returns + WatchAudioTranscoder.TranscodeResult.Passthrough + every { watchAudioTranscoder.requiresTranscoding(any()) } returns false + watchTransferStateStore.retainReachableWatchNodes(setOf("node-1")) + watchTransferStateStore.markSongPresentOnWatch("node-1", "already-there") + val viewModel = buildViewModel() + + val estimate = viewModel.estimateWatchTransfer(listOf(song("already-there"), song("pending"))) + + assertThat(estimate.totalSongCount).isEqualTo(2) + assertThat(estimate.pendingSongCount).isEqualTo(1) + } + + @Test + fun `isPlaylistFullyOnWatch is false for an empty playlist`() { + val viewModel = buildViewModel() + assertThat(viewModel.isPlaylistFullyOnWatch(emptyList())).isFalse() + } + + @Test + fun `isPlaylistFullyOnWatch is true only once every song is on every reachable watch`() { + watchTransferStateStore.retainReachableWatchNodes(setOf("node-1")) + val viewModel = buildViewModel() + + assertThat(viewModel.isPlaylistFullyOnWatch(listOf("s1", "s2"))).isFalse() + + watchTransferStateStore.markSongPresentOnWatch("node-1", "s1") + assertThat(viewModel.isPlaylistFullyOnWatch(listOf("s1", "s2"))).isFalse() + + watchTransferStateStore.markSongPresentOnWatch("node-1", "s2") + assertThat(viewModel.isPlaylistFullyOnWatch(listOf("s1", "s2"))).isTrue() + } + + @Test + fun `sendPlaylistToWatch delegates to the coordinator and returns its batchId`() { + every { + playlistWatchTransferCoordinator.requestPlaylistTransfer("p1", "Playlist", listOf("s1", "s2")) + } returns "batch-123" + val viewModel = buildViewModel() + + val batchId = viewModel.sendPlaylistToWatch("p1", "Playlist", listOf("s1", "s2")) + + assertThat(batchId).isEqualTo("batch-123") + } + + @Test + fun `cancelPlaylistTransfer delegates to the coordinator`() { + every { playlistWatchTransferCoordinator.cancelPlaylistTransfer("batch-123") } returns Unit + val viewModel = buildViewModel() + + viewModel.cancelPlaylistTransfer("batch-123") + + io.mockk.verify { playlistWatchTransferCoordinator.cancelPlaylistTransfer("batch-123") } + } + + @Test + fun `activePlaylistBatchTransfer reflects the only non-terminal batch in the shared store`() = runTest { + // stateIn(WhileSubscribed) only starts collecting the upstream flow once something + // subscribes — reading .value without a collector never triggers it, so this needs an + // actual subscriber (Turbine's test{}), not a bare .value read. + val viewModel = buildViewModel() + + viewModel.activePlaylistBatchTransfer.test { + assertThat(awaitItem()).isNull() + + watchTransferStateStore.markBatchStarted("b1", "p1", "Playlist", totalSongCount = 3) + + assertThat(awaitItem()?.batchId).isEqualTo("b1") + } + } + + @Test + fun `refreshWatchAvailability updates isPixelPlayWatchAvailable from the sender`() = runTest { + coEvery { wearPhoneTransferSender.isPixelPlayWatchAvailable() } returns true + coEvery { wearPhoneTransferSender.refreshWatchLibraryState() } returns Result.success(Unit) + val viewModel = buildViewModel() + + viewModel.refreshWatchAvailability() + advanceUntilIdle() + + assertThat(viewModel.isPixelPlayWatchAvailable.value).isTrue() + } +} From ae9502e3d3b73f9c5afc1b43b99ba682a4319718 Mon Sep 17 00:00:00 2001 From: PonceGL Date: Sat, 8 Aug 2026 14:52:09 -0600 Subject: [PATCH 22/40] feat(wear): receive playlist syncs from the phone into local storage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 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. --- wear/src/main/AndroidManifest.xml | 4 + .../pixelplay/data/WearDataListenerService.kt | 16 ++ .../pixelplay/data/WearTransferRepository.kt | 32 ++++ .../WearTransferRepositoryPlaylistSyncTest.kt | 162 ++++++++++++++++++ 4 files changed, 214 insertions(+) create mode 100644 wear/src/test/java/com/theveloper/pixelplay/data/WearTransferRepositoryPlaylistSyncTest.kt diff --git a/wear/src/main/AndroidManifest.xml b/wear/src/main/AndroidManifest.xml index 4a049d1d6..1b06e4246 100644 --- a/wear/src/main/AndroidManifest.xml +++ b/wear/src/main/AndroidManifest.xml @@ -112,6 +112,10 @@ android:scheme="wear" android:host="*" android:pathPrefix="/volume_state" /> + diff --git a/wear/src/main/java/com/theveloper/pixelplay/data/WearDataListenerService.kt b/wear/src/main/java/com/theveloper/pixelplay/data/WearDataListenerService.kt index 16c99756a..ec841510d 100644 --- a/wear/src/main/java/com/theveloper/pixelplay/data/WearDataListenerService.kt +++ b/wear/src/main/java/com/theveloper/pixelplay/data/WearDataListenerService.kt @@ -17,11 +17,13 @@ import com.theveloper.pixelplay.shared.WearDataPaths import com.theveloper.pixelplay.shared.WearFavoriteSyncResponse import com.theveloper.pixelplay.shared.WearPlaybackResult import com.theveloper.pixelplay.shared.WearPlayerState +import com.theveloper.pixelplay.shared.WearPlaylistSync import com.theveloper.pixelplay.shared.WearTransferMetadata import com.theveloper.pixelplay.shared.WearTransferProgress import com.theveloper.pixelplay.shared.WearTransferRequest import com.theveloper.pixelplay.shared.WearVolumeState import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob @@ -252,6 +254,20 @@ class WearDataListenerService : WearableListenerService() { } } + WearDataPaths.PLAYLIST_SYNC -> { + scope.launch { + try { + val syncJson = String(messageEvent.data, Charsets.UTF_8) + val sync = json.decodeFromString(syncJson) + transferRepository.onPlaylistSyncReceived(sync) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Timber.tag(TAG).e(e, "Failed to process playlist sync") + } + } + } + WearDataPaths.TRANSFER_PROGRESS -> { try { val progressJson = String(messageEvent.data, Charsets.UTF_8) diff --git a/wear/src/main/java/com/theveloper/pixelplay/data/WearTransferRepository.kt b/wear/src/main/java/com/theveloper/pixelplay/data/WearTransferRepository.kt index dbe804084..4821fbdb9 100644 --- a/wear/src/main/java/com/theveloper/pixelplay/data/WearTransferRepository.kt +++ b/wear/src/main/java/com/theveloper/pixelplay/data/WearTransferRepository.kt @@ -6,10 +6,14 @@ import android.webkit.MimeTypeMap import com.google.android.gms.wearable.ChannelClient import com.google.android.gms.wearable.MessageClient import com.google.android.gms.wearable.NodeClient +import com.theveloper.pixelplay.data.local.LocalPlaylistDao +import com.theveloper.pixelplay.data.local.LocalPlaylistEntity +import com.theveloper.pixelplay.data.local.LocalPlaylistSongCrossRef import com.theveloper.pixelplay.data.local.LocalSongDao import com.theveloper.pixelplay.data.local.LocalSongEntity import com.theveloper.pixelplay.shared.WearDataPaths import com.theveloper.pixelplay.shared.WearLibraryState +import com.theveloper.pixelplay.shared.WearPlaylistSync import com.theveloper.pixelplay.shared.WearTransferMetadata import com.theveloper.pixelplay.shared.WearTransferProgress import com.theveloper.pixelplay.shared.WearTransferRequest @@ -70,6 +74,7 @@ data class TransferState( class WearTransferRepository @Inject constructor( private val application: Application, private val localSongDao: LocalSongDao, + private val localPlaylistDao: LocalPlaylistDao, private val channelClient: ChannelClient, private val messageClient: MessageClient, private val nodeClient: NodeClient, @@ -866,6 +871,33 @@ class WearTransferRepository @Inject constructor( } } + /** + * Called when a playlist sync arrives from the phone — sent once up front, before any of its + * songs' audio has necessarily finished transferring, so the watch can show the playlist and + * start playing whatever's already local right away. Idempotent: re-syncing the same + * [WearPlaylistSync.playlistId] (e.g. after the user edits the playlist on the phone) replaces + * membership/order in one transaction rather than merging with the stale cross-refs. + */ + suspend fun onPlaylistSyncReceived(sync: WearPlaylistSync) { + val now = System.currentTimeMillis() + val existing = localPlaylistDao.getPlaylistById(sync.playlistId) + val entity = LocalPlaylistEntity( + playlistId = sync.playlistId, + name = sync.name, + createdAt = existing?.createdAt ?: now, + updatedAt = now, + ) + val crossRefs = sync.songIds.mapIndexed { index, songId -> + LocalPlaylistSongCrossRef(playlistId = sync.playlistId, songId = songId, position = index) + } + localPlaylistDao.upsertPlaylist(entity, crossRefs) + Timber.tag(TAG).d( + "Playlist synced: %s (%d songs)", + sync.name, + sync.songIds.size, + ) + } + /** * Called when artwork bytes arrive over the dedicated artwork channel. * If song row exists, artwork is persisted immediately; otherwise cached until audio finishes. diff --git a/wear/src/test/java/com/theveloper/pixelplay/data/WearTransferRepositoryPlaylistSyncTest.kt b/wear/src/test/java/com/theveloper/pixelplay/data/WearTransferRepositoryPlaylistSyncTest.kt new file mode 100644 index 000000000..e081fb10b --- /dev/null +++ b/wear/src/test/java/com/theveloper/pixelplay/data/WearTransferRepositoryPlaylistSyncTest.kt @@ -0,0 +1,162 @@ +package com.theveloper.pixelplay.data + +import android.app.Application +import com.google.android.gms.wearable.ChannelClient +import com.google.android.gms.wearable.MessageClient +import com.google.android.gms.wearable.NodeClient +import com.google.common.truth.Truth.assertThat +import com.theveloper.pixelplay.MainCoroutineExtension +import com.theveloper.pixelplay.data.local.LocalPlaylistDao +import com.theveloper.pixelplay.data.local.LocalPlaylistEntity +import com.theveloper.pixelplay.data.local.LocalPlaylistSongCrossRef +import com.theveloper.pixelplay.data.local.LocalSongDao +import com.theveloper.pixelplay.shared.WearPlaylistSync +import io.mockk.Runs +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.slot +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.RegisterExtension + +/** + * Covers [WearTransferRepository.onPlaylistSyncReceived] only — everything else on the repository + * (song-by-song ChannelClient transfer, artwork, watchdogs) is exercised on-device, not here. + * + * [WearLocalPlayerRepository] and [WearPlaybackController] are constructed for real rather than + * mocked: both are final Kotlin classes (no `open`), so MockK could only fake them via its + * inline-mocking Java agent — which hangs indefinitely under this sandbox (see + * `PlaylistWatchTransferCoordinatorTest` in `:app` for the same constraint on the GMS side). + * Real construction needs no agent and is safe here because `onPlaylistSyncReceived` never calls + * either collaborator; [MainCoroutineExtension] supplies the `Dispatchers.Main` both of their + * `init` blocks need to launch on. + */ +@OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class) +class WearTransferRepositoryPlaylistSyncTest { + + companion object { + @JvmField + @RegisterExtension + val mainCoroutineExtension = MainCoroutineExtension() + } + + private val application = mockk(relaxed = true) + private val localSongDao = mockk() + private val localPlaylistDao = mockk() + private val channelClient = mockk() + private val messageClient = mockk() + private val nodeClient = mockk() + + private lateinit var repository: WearTransferRepository + + @BeforeEach + fun setUp() { + every { localSongDao.getAllSongs() } returns flowOf(emptyList()) + coEvery { localPlaylistDao.upsertPlaylist(any(), any()) } just Runs + + val stateRepository = WearStateRepository() + val localPlayerRepository = WearLocalPlayerRepository(application, localSongDao) + val playbackController = WearPlaybackController(application, stateRepository) + + repository = WearTransferRepository( + application = application, + localSongDao = localSongDao, + localPlaylistDao = localPlaylistDao, + channelClient = channelClient, + messageClient = messageClient, + nodeClient = nodeClient, + localPlayerRepository = localPlayerRepository, + stateRepository = stateRepository, + playbackController = playbackController, + ) + } + + @Test + fun `first sync sets createdAt equal to updatedAt`() = runTest { + coEvery { localPlaylistDao.getPlaylistById("p1") } returns null + val entitySlot = slot() + coEvery { localPlaylistDao.upsertPlaylist(capture(entitySlot), any()) } just Runs + + repository.onPlaylistSyncReceived(WearPlaylistSync(playlistId = "p1", name = "Road trip", songIds = listOf("s1"))) + + assertThat(entitySlot.captured.createdAt).isEqualTo(entitySlot.captured.updatedAt) + } + + @Test + fun `re-sync preserves original createdAt but bumps updatedAt`() = runTest { + val originalCreatedAt = 1_000L + coEvery { localPlaylistDao.getPlaylistById("p1") } returns LocalPlaylistEntity( + playlistId = "p1", + name = "Road trip", + createdAt = originalCreatedAt, + updatedAt = originalCreatedAt, + ) + val entitySlot = slot() + coEvery { localPlaylistDao.upsertPlaylist(capture(entitySlot), any()) } just Runs + + repository.onPlaylistSyncReceived(WearPlaylistSync(playlistId = "p1", name = "Road trip", songIds = listOf("s1", "s2"))) + + assertThat(entitySlot.captured.createdAt).isEqualTo(originalCreatedAt) + assertThat(entitySlot.captured.updatedAt).isGreaterThan(originalCreatedAt) + } + + @Test + fun `re-sync with a different song set replaces membership, not merges it`() = runTest { + coEvery { localPlaylistDao.getPlaylistById("p1") } returns null + val crossRefsSlot = slot>() + coEvery { localPlaylistDao.upsertPlaylist(any(), capture(crossRefsSlot)) } just Runs + + repository.onPlaylistSyncReceived(WearPlaylistSync(playlistId = "p1", name = "Road trip", songIds = listOf("a", "b"))) + repository.onPlaylistSyncReceived(WearPlaylistSync(playlistId = "p1", name = "Road trip", songIds = listOf("c"))) + + // The repository always regenerates the full cross-ref list from the incoming sync's + // songIds alone — it never reads current membership back in — so the last call's payload + // is exactly the new set, with no trace of the songs from the first call. + assertThat(crossRefsSlot.captured).containsExactly( + LocalPlaylistSongCrossRef(playlistId = "p1", songId = "c", position = 0), + ) + } + + @Test + fun `cross-refs preserve songId order and position from the sync payload`() = runTest { + coEvery { localPlaylistDao.getPlaylistById("p1") } returns null + val crossRefsSlot = slot>() + coEvery { localPlaylistDao.upsertPlaylist(any(), capture(crossRefsSlot)) } just Runs + + repository.onPlaylistSyncReceived( + WearPlaylistSync(playlistId = "p1", name = "Road trip", songIds = listOf("s3", "s1", "s2")) + ) + + assertThat(crossRefsSlot.captured).containsExactly( + LocalPlaylistSongCrossRef(playlistId = "p1", songId = "s3", position = 0), + LocalPlaylistSongCrossRef(playlistId = "p1", songId = "s1", position = 1), + LocalPlaylistSongCrossRef(playlistId = "p1", songId = "s2", position = 2), + ).inOrder() + } + + @Test + fun `empty song list still upserts an empty cross-ref list, not a no-op`() = runTest { + coEvery { localPlaylistDao.getPlaylistById("p1") } returns null + + repository.onPlaylistSyncReceived(WearPlaylistSync(playlistId = "p1", name = "Empty playlist", songIds = emptyList())) + + coVerify(exactly = 1) { localPlaylistDao.upsertPlaylist(any(), emptyList()) } + } + + @Test + fun `entity carries the synced name and playlistId through`() = runTest { + coEvery { localPlaylistDao.getPlaylistById("p1") } returns null + val entitySlot = slot() + coEvery { localPlaylistDao.upsertPlaylist(capture(entitySlot), any()) } just Runs + + repository.onPlaylistSyncReceived(WearPlaylistSync(playlistId = "p1", name = "Summer mix", songIds = listOf("s1"))) + + assertThat(entitySlot.captured.playlistId).isEqualTo("p1") + assertThat(entitySlot.captured.name).isEqualTo("Summer mix") + } +} From 062f8220c347a7b79c3664e31f53100a926ca65c Mon Sep 17 00:00:00 2001 From: PonceGL Date: Sat, 8 Aug 2026 14:52:14 -0600 Subject: [PATCH 23/40] feat(wear): add WearLocalPlaylistViewModel 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. --- .../viewmodel/WearLocalPlaylistViewModel.kt | 115 +++++++ .../WearLocalPlaylistViewModelTest.kt | 305 ++++++++++++++++++ 2 files changed, 420 insertions(+) create mode 100644 wear/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/WearLocalPlaylistViewModel.kt create mode 100644 wear/src/test/java/com/theveloper/pixelplay/presentation/viewmodel/WearLocalPlaylistViewModelTest.kt diff --git a/wear/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/WearLocalPlaylistViewModel.kt b/wear/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/WearLocalPlaylistViewModel.kt new file mode 100644 index 000000000..c681e9b20 --- /dev/null +++ b/wear/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/WearLocalPlaylistViewModel.kt @@ -0,0 +1,115 @@ +package com.theveloper.pixelplay.presentation.viewmodel + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.theveloper.pixelplay.data.TransferState +import com.theveloper.pixelplay.data.WearLocalPlayerRepository +import com.theveloper.pixelplay.data.WearOutputTarget +import com.theveloper.pixelplay.data.WearStateRepository +import com.theveloper.pixelplay.data.local.LocalPlaylistDao +import com.theveloper.pixelplay.data.local.LocalPlaylistEntity +import com.theveloper.pixelplay.data.local.LocalSongDao +import com.theveloper.pixelplay.data.local.LocalSongEntity +import com.theveloper.pixelplay.data.WearTransferRepository +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.stateIn + +/** A song's position in a local playlist snapshot, resolved against what's actually on disk. */ +data class WearLocalPlaylistSongItem( + val songId: String, + val song: LocalSongEntity?, +) { + val isAvailable: Boolean get() = song != null +} + +/** + * Backs [com.theveloper.pixelplay.presentation.screens.LocalPlaylistsScreen] and + * [com.theveloper.pixelplay.presentation.screens.LocalPlaylistDetailScreen]. Song availability is + * resolved reactively by joining the playlist's song order against [LocalSongDao.getAllSongs], so + * a song that finishes transferring while the detail screen is open flips from pending to + * playable without the user needing to back out and re-enter. + */ +@OptIn(ExperimentalCoroutinesApi::class) +@HiltViewModel +class WearLocalPlaylistViewModel @Inject constructor( + private val localPlaylistDao: LocalPlaylistDao, + private val localSongDao: LocalSongDao, + private val localPlayerRepository: WearLocalPlayerRepository, + private val stateRepository: WearStateRepository, + transferRepository: WearTransferRepository, +) : ViewModel() { + + val playlists: StateFlow> = localPlaylistDao.observePlaylists() + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000L), emptyList()) + + /** In-flight song transfers from the phone, keyed by requestId — for on-screen receive feedback. */ + val activeTransfers: StateFlow> = transferRepository.activeTransfers + + /** Playlists that currently have at least one of their songs actively transferring. */ + val playlistIdsReceiving: StateFlow> = combine( + localPlaylistDao.observeAllPlaylistSongCrossRefs(), + transferRepository.activeTransfers, + ) { crossRefs, transfers -> + if (transfers.isEmpty()) { + emptySet() + } else { + val activeSongIds = transfers.values.map { it.songId }.toSet() + crossRefs.filter { it.songId in activeSongIds }.map { it.playlistId }.toSet() + } + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000L), emptySet()) + + private val _playlistId = MutableStateFlow(null) + + val playlistDetails: StateFlow = combine( + playlists, + _playlistId, + ) { allPlaylists, playlistId -> + allPlaylists.find { it.playlistId == playlistId } + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000L), null) + + val playlistSongs: StateFlow> = _playlistId + .flatMapLatest { playlistId -> + if (playlistId == null) { + flowOf(emptyList()) + } else { + combine( + localPlaylistDao.observePlaylistSongs(playlistId), + localSongDao.getAllSongs(), + ) { crossRefs, allSongs -> + val songsById = allSongs.associateBy { it.songId } + crossRefs.map { ref -> WearLocalPlaylistSongItem(ref.songId, songsById[ref.songId]) } + } + } + } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000L), emptyList()) + + fun loadPlaylist(playlistId: String) { + if (_playlistId.value == playlistId) return + _playlistId.value = playlistId + } + + /** Plays every available (already-transferred) song in order, from the start. */ + fun playAll() { + val available = playlistSongs.value.mapNotNull { it.song } + if (available.isEmpty()) return + localPlayerRepository.playLocalSongs(available, startIndex = 0) + stateRepository.setOutputTarget(WearOutputTarget.WATCH) + } + + /** Plays every available song, starting from [songId] — pending songs aren't tappable. */ + fun playFrom(songId: String) { + val available = playlistSongs.value.mapNotNull { it.song } + val startIndex = available.indexOfFirst { it.songId == songId } + if (startIndex == -1) return + localPlayerRepository.playLocalSongs(available, startIndex = startIndex) + stateRepository.setOutputTarget(WearOutputTarget.WATCH) + } +} diff --git a/wear/src/test/java/com/theveloper/pixelplay/presentation/viewmodel/WearLocalPlaylistViewModelTest.kt b/wear/src/test/java/com/theveloper/pixelplay/presentation/viewmodel/WearLocalPlaylistViewModelTest.kt new file mode 100644 index 000000000..3b666f93a --- /dev/null +++ b/wear/src/test/java/com/theveloper/pixelplay/presentation/viewmodel/WearLocalPlaylistViewModelTest.kt @@ -0,0 +1,305 @@ +package com.theveloper.pixelplay.presentation.viewmodel + +import android.app.Application +import app.cash.turbine.test +import com.google.android.gms.wearable.ChannelClient +import com.google.android.gms.wearable.MessageClient +import com.google.android.gms.wearable.NodeClient +import com.google.common.truth.Truth.assertThat +import com.theveloper.pixelplay.MainCoroutineExtension +import com.theveloper.pixelplay.data.WearLocalPlayerRepository +import com.theveloper.pixelplay.data.WearOutputTarget +import com.theveloper.pixelplay.data.WearPlaybackController +import com.theveloper.pixelplay.data.WearStateRepository +import com.theveloper.pixelplay.data.WearTransferRepository +import com.theveloper.pixelplay.data.local.LocalPlaylistDao +import com.theveloper.pixelplay.data.local.LocalPlaylistEntity +import com.theveloper.pixelplay.data.local.LocalPlaylistSongCrossRef +import com.theveloper.pixelplay.data.local.LocalSongDao +import com.theveloper.pixelplay.data.local.LocalSongEntity +import com.theveloper.pixelplay.shared.WearTransferProgress +import io.mockk.Runs +import io.mockk.coEvery +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest +import java.io.File +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows +import org.junit.jupiter.api.extension.RegisterExtension + +/** + * [WearLocalPlayerRepository], [WearStateRepository], [WearPlaybackController] and + * [WearTransferRepository] are all constructed for real, not mocked: they're final Kotlin + * classes, and MockK can only fake a final class through its inline-mocking Java agent — which + * hangs indefinitely under this sandbox (documented in `WearTransferRepositoryPlaylistSyncTest` + * and `PlaylistWatchTransferCoordinatorTest` in `:app`). + * + * That constrains what `playAll`/`playFrom` can assert: [WearLocalPlayerRepository.playLocalSongs] + * itself is not verifiable here (it launches a coroutine that tries to bind a real + * `MediaController` to `WearPlaybackService`, which fails fast — and silently — off-device with + * no Android runtime present). What *is* real production behavior, reachable without a device, is + * the guard clause in the ViewModel that decides whether to call it at all, and the + * `stateRepository.setOutputTarget(WATCH)` call right after it — both are asserted via + * [WearStateRepository.outputTarget], a real (not mocked) collaborator. + * + * Every `stateIn`-backed property here (`playlists`, `playlistDetails`, `playlistSongs`, + * `playlistIdsReceiving`) delivers its `stateIn` initial value as a first, synchronous event to + * any new collector — *before* the upstream's real current value has had a chance to run through + * `WhileSubscribed`'s forwarding coroutine. Tests that assert on the sequence of emissions, or + * that read `.value` right after establishing a subscription, account for that placeholder + * explicitly rather than assuming the first `awaitItem()` is already the "real" one. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class WearLocalPlaylistViewModelTest { + + companion object { + @JvmField + @RegisterExtension + val mainCoroutineExtension = MainCoroutineExtension() + } + + private val application = mockk(relaxed = true) + private val localPlaylistDao = mockk() + private val localSongDao = mockk() + private val channelClient = mockk() + private val messageClient = mockk() + private val nodeClient = mockk() + + private val playlistsFlow = MutableStateFlow>(emptyList()) + private val allCrossRefsFlow = MutableStateFlow>(emptyList()) + private val playlistSongsFlowById = mutableMapOf>>() + private val allSongsFlow = MutableStateFlow>(emptyList()) + private val tempFiles = mutableListOf() + + private lateinit var stateRepository: WearStateRepository + private lateinit var transferRepository: WearTransferRepository + private lateinit var viewModel: WearLocalPlaylistViewModel + + @BeforeEach + fun setUp() { + every { localPlaylistDao.observePlaylists() } returns playlistsFlow + every { localPlaylistDao.observeAllPlaylistSongCrossRefs() } returns allCrossRefsFlow + every { localPlaylistDao.observePlaylistSongs(any()) } answers { + val playlistId = firstArg() + playlistSongsFlowById.getOrPut(playlistId) { MutableStateFlow(emptyList()) } + } + every { localSongDao.getAllSongs() } returns allSongsFlow + // WearTransferRepository's own init block treats any LocalSongEntity whose localPath + // doesn't resolve to a real, non-empty file as stale and deletes it — irrelevant to what + // this ViewModel does, but its background collector still runs and would call this on + // every song() fixture below if we didn't back them with real files (we do, see song()). + coEvery { localSongDao.deleteById(any()) } just Runs + + stateRepository = WearStateRepository() + val localPlayerRepository = WearLocalPlayerRepository(application, localSongDao) + val playbackController = WearPlaybackController(application, stateRepository) + transferRepository = WearTransferRepository( + application = application, + localSongDao = localSongDao, + localPlaylistDao = localPlaylistDao, + channelClient = channelClient, + messageClient = messageClient, + nodeClient = nodeClient, + localPlayerRepository = localPlayerRepository, + stateRepository = stateRepository, + playbackController = playbackController, + ) + + viewModel = WearLocalPlaylistViewModel( + localPlaylistDao = localPlaylistDao, + localSongDao = localSongDao, + localPlayerRepository = localPlayerRepository, + stateRepository = stateRepository, + transferRepository = transferRepository, + ) + } + + @AfterEach + fun tearDown() { + tempFiles.forEach { it.delete() } + tempFiles.clear() + } + + /** Backed by a real, non-empty file so `hasPlayableLocalFile()`-style checks see it as valid. */ + private fun song(id: String): LocalSongEntity { + val file = File.createTempFile("local-song-$id", ".m4a").apply { + writeBytes(byteArrayOf(1, 2, 3, 4)) + deleteOnExit() + } + tempFiles += file + return LocalSongEntity( + songId = id, + title = "Title $id", + artist = "Artist", + album = "Album", + albumId = 1L, + duration = 180_000L, + mimeType = "audio/mp4", + fileSize = file.length(), + bitrate = 128_000, + sampleRate = 44_100, + localPath = file.absolutePath, + transferredAt = 0L, + ) + } + + private fun crossRef(playlistId: String, songId: String, position: Int) = + LocalPlaylistSongCrossRef(playlistId = playlistId, songId = songId, position = position) + + /** Subscribes long enough for `WhileSubscribed`'s forwarding coroutine to run and update + * `.value` past the `stateIn` placeholder, then lets go — `.value` keeps the real result. */ + private suspend fun warmUp(flow: kotlinx.coroutines.flow.StateFlow<*>) { + flow.test { + awaitItem() // stateIn's initial placeholder + awaitItem() // the real, upstream-derived value + } + } + + /** + * `playAll`/`playFrom` call into [WearLocalPlayerRepository.playLocalSongs], which + * fire-and-forgets a coroutine on `Dispatchers.Main` that ends up calling + * `android.net.Uri.fromFile` — unstubbed on a bare JVM, so it NPEs. That NPE is a pure artifact + * of running off-device (see the class doc) and unrelated to what these two tests actually + * assert — but `runTest` re-resolves `Dispatchers.Main` at cleanup and drains whatever is + * queued on it before returning, so the NPE always surfaces by the time `runTest` itself + * returns, *after* the test body (and its assertions) already ran to completion. Rather than + * fight `runTest`'s cleanup — every attempt to reroute `Dispatchers.Main` away from it still + * gets drained, since the lookup happens fresh at cleanup time, not once at start — this names + * the crash explicitly instead of letting it surface as an unexplained failure. + */ + private fun expectFireAndForgetPlaybackCrash(body: suspend TestScope.() -> Unit) { + val error = assertThrows { runTest { body() } } + assertThat(error.message).contains("fromFile") + } + + @Test + fun `playlists mirrors the DAO's observePlaylists flow`() = runTest { + viewModel.playlists.test { + assertThat(awaitItem()).isEmpty() + playlistsFlow.value = listOf(LocalPlaylistEntity("p1", "Road trip", 0L, 0L)) + assertThat(awaitItem()).containsExactly(LocalPlaylistEntity("p1", "Road trip", 0L, 0L)) + } + } + + @Test + fun `playlistDetails resolves the entity matching the loaded playlistId`() = runTest { + playlistsFlow.value = listOf( + LocalPlaylistEntity("p1", "Road trip", 0L, 0L), + LocalPlaylistEntity("p2", "Gym", 0L, 0L), + ) + + viewModel.playlistDetails.test { + assertThat(awaitItem()).isNull() + viewModel.loadPlaylist("p2") + assertThat(awaitItem()?.playlistId).isEqualTo("p2") + } + } + + @Test + fun `playlistSongs marks songs without a matching local file as unavailable, in sync order`() = runTest { + playlistSongsFlowById["p1"] = MutableStateFlow( + listOf(crossRef("p1", "s1", 0), crossRef("p1", "s2", 1), crossRef("p1", "s3", 2)) + ) + allSongsFlow.value = listOf(song("s1"), song("s3")) // s2 hasn't arrived yet + + viewModel.loadPlaylist("p1") + viewModel.playlistSongs.test { + awaitItem() // stateIn's initial placeholder (emptyList) + val items = awaitItem() + assertThat(items.map { it.songId }).containsExactly("s1", "s2", "s3").inOrder() + assertThat(items.first { it.songId == "s1" }.isAvailable).isTrue() + assertThat(items.first { it.songId == "s2" }.isAvailable).isFalse() + assertThat(items.first { it.songId == "s3" }.isAvailable).isTrue() + } + } + + @Test + fun `a song flips from pending to available as soon as it lands, without reloading`() = runTest { + playlistSongsFlowById["p1"] = MutableStateFlow(listOf(crossRef("p1", "s1", 0))) + viewModel.loadPlaylist("p1") + + viewModel.playlistSongs.test { + awaitItem() // stateIn's initial placeholder (emptyList) + assertThat(awaitItem().single().isAvailable).isFalse() + allSongsFlow.value = listOf(song("s1")) + assertThat(awaitItem().single().isAvailable).isTrue() + } + } + + @Test + fun `playlistIdsReceiving reports playlists with an actively transferring member`() = runTest { + allCrossRefsFlow.value = listOf(crossRef("p1", "s1", 0), crossRef("p2", "s2", 0)) + + viewModel.playlistIdsReceiving.test { + // Unlike playlistSongs, the placeholder (emptySet) and the real first combined value + // (also emptySet, since there's no active transfer yet) are structurally equal — + // StateFlow dedups them into a single emission, so there's only one item to await here. + assertThat(awaitItem()).isEmpty() + transferRepository.onProgressReceived( + WearTransferProgress( + requestId = "r1", + songId = "s1", + bytesTransferred = 10L, + totalBytes = 100L, + status = WearTransferProgress.STATUS_TRANSFERRING, + ) + ) + assertThat(awaitItem()).containsExactly("p1") + } + } + + @Test + fun `playAll switches output to watch when at least one song is available`() = expectFireAndForgetPlaybackCrash { + playlistSongsFlowById["p1"] = MutableStateFlow(listOf(crossRef("p1", "s1", 0))) + allSongsFlow.value = listOf(song("s1")) + viewModel.loadPlaylist("p1") + warmUp(viewModel.playlistSongs) + + assertThat(stateRepository.outputTarget.value).isEqualTo(WearOutputTarget.PHONE) + viewModel.playAll() + assertThat(stateRepository.outputTarget.value).isEqualTo(WearOutputTarget.WATCH) + } + + @Test + fun `playAll is a no-op when every song is still pending transfer`() = runTest { + playlistSongsFlowById["p1"] = MutableStateFlow(listOf(crossRef("p1", "s1", 0))) + viewModel.loadPlaylist("p1") + warmUp(viewModel.playlistSongs) // s1 has no matching LocalSongEntity: pending + + viewModel.playAll() + assertThat(stateRepository.outputTarget.value).isEqualTo(WearOutputTarget.PHONE) + } + + @Test + fun `playFrom is a no-op when the requested song is still pending transfer`() = runTest { + playlistSongsFlowById["p1"] = MutableStateFlow( + listOf(crossRef("p1", "s1", 0), crossRef("p1", "s2", 1)) + ) + allSongsFlow.value = listOf(song("s1")) // s2 is pending + viewModel.loadPlaylist("p1") + warmUp(viewModel.playlistSongs) + + viewModel.playFrom("s2") + assertThat(stateRepository.outputTarget.value).isEqualTo(WearOutputTarget.PHONE) + } + + @Test + fun `playFrom an available song switches output to watch`() = expectFireAndForgetPlaybackCrash { + playlistSongsFlowById["p1"] = MutableStateFlow( + listOf(crossRef("p1", "s1", 0), crossRef("p1", "s2", 1)) + ) + allSongsFlow.value = listOf(song("s1"), song("s2")) + viewModel.loadPlaylist("p1") + warmUp(viewModel.playlistSongs) + + viewModel.playFrom("s2") + assertThat(stateRepository.outputTarget.value).isEqualTo(WearOutputTarget.WATCH) + } +} From 355dd9cc737348d480dbe65e8473353f1876dc6c Mon Sep 17 00:00:00 2001 From: PonceGL Date: Sat, 8 Aug 2026 14:52:24 -0600 Subject: [PATCH 24/40] feat(wear): add local playlists screens and wire up navigation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../pixelplay/presentation/WearNavigation.kt | 43 ++- .../presentation/screens/DownloadsScreen.kt | 29 ++ .../screens/LocalPlaylistDetailScreen.kt | 267 ++++++++++++++++++ .../screens/LocalPlaylistsScreen.kt | 179 ++++++++++++ wear/src/main/res/values/strings_wear.xml | 8 + 5 files changed, 525 insertions(+), 1 deletion(-) create mode 100644 wear/src/main/java/com/theveloper/pixelplay/presentation/screens/LocalPlaylistDetailScreen.kt create mode 100644 wear/src/main/java/com/theveloper/pixelplay/presentation/screens/LocalPlaylistsScreen.kt diff --git a/wear/src/main/java/com/theveloper/pixelplay/presentation/WearNavigation.kt b/wear/src/main/java/com/theveloper/pixelplay/presentation/WearNavigation.kt index a0f7e3dc5..a918647dd 100644 --- a/wear/src/main/java/com/theveloper/pixelplay/presentation/WearNavigation.kt +++ b/wear/src/main/java/com/theveloper/pixelplay/presentation/WearNavigation.kt @@ -9,6 +9,8 @@ import androidx.wear.compose.navigation.rememberSwipeDismissableNavController import com.theveloper.pixelplay.presentation.screens.BrowseScreen import com.theveloper.pixelplay.presentation.screens.DownloadsScreen import com.theveloper.pixelplay.presentation.screens.LibraryListScreen +import com.theveloper.pixelplay.presentation.screens.LocalPlaylistDetailScreen +import com.theveloper.pixelplay.presentation.screens.LocalPlaylistsScreen import com.theveloper.pixelplay.presentation.screens.MoreScreen import com.theveloper.pixelplay.presentation.screens.OutputScreen import com.theveloper.pixelplay.presentation.screens.PlayerScreen @@ -39,6 +41,8 @@ object WearScreens { const val DOWNLOADS = "downloads" const val LIBRARY_LIST = "library_list/{browseType}/{title}" const val SONG_LIST = "song_list/{browseType}/{contextId}/{title}" + const val LOCAL_PLAYLISTS = "local_playlists" + const val LOCAL_PLAYLIST_DETAIL = "local_playlist_detail/{playlistId}/{title}" fun libraryListRoute(browseType: String, title: String): String { return "library_list/$browseType/${URLEncoder.encode(title, "UTF-8")}" @@ -47,6 +51,10 @@ object WearScreens { fun songListRoute(browseType: String, contextId: String, title: String): String { return "song_list/$browseType/$contextId/${URLEncoder.encode(title, "UTF-8")}" } + + fun localPlaylistDetailRoute(playlistId: String, title: String): String { + return "local_playlist_detail/$playlistId/${URLEncoder.encode(title, "UTF-8")}" + } } @Composable @@ -138,7 +146,40 @@ fun WearNavigation() { } composable(WearScreens.DOWNLOADS) { - DownloadsScreen() + DownloadsScreen( + onPlaylistsClick = { + navController.navigate(WearScreens.LOCAL_PLAYLISTS) { + launchSingleTop = true + } + }, + ) + } + + composable(WearScreens.LOCAL_PLAYLISTS) { + LocalPlaylistsScreen( + onPlaylistClick = { playlistId, title -> + navController.navigate( + WearScreens.localPlaylistDetailRoute(playlistId, title) + ) + }, + ) + } + + composable( + route = WearScreens.LOCAL_PLAYLIST_DETAIL, + arguments = listOf( + navArgument("playlistId") { type = NavType.StringType }, + navArgument("title") { type = NavType.StringType }, + ), + ) { backStackEntry -> + val playlistId = backStackEntry.arguments?.getString("playlistId") ?: "" + val title = URLDecoder.decode( + backStackEntry.arguments?.getString("title") ?: "", "UTF-8" + ) + LocalPlaylistDetailScreen( + playlistId = playlistId, + title = title, + ) } composable(WearScreens.BROWSE) { diff --git a/wear/src/main/java/com/theveloper/pixelplay/presentation/screens/DownloadsScreen.kt b/wear/src/main/java/com/theveloper/pixelplay/presentation/screens/DownloadsScreen.kt index 64141b3fa..b2aa48d7b 100644 --- a/wear/src/main/java/com/theveloper/pixelplay/presentation/screens/DownloadsScreen.kt +++ b/wear/src/main/java/com/theveloper/pixelplay/presentation/screens/DownloadsScreen.kt @@ -17,6 +17,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.QueueMusic import androidx.compose.material.icons.rounded.Close import androidx.compose.material.icons.rounded.Delete import androidx.compose.material.icons.rounded.ErrorOutline @@ -81,6 +82,7 @@ import kotlinx.coroutines.flow.collect */ @Composable fun DownloadsScreen( + onPlaylistsClick: () -> Unit = {}, viewModel: WearDownloadsViewModel = hiltViewModel(), playerViewModel: WearPlayerViewModel = hiltViewModel(), ) { @@ -183,6 +185,33 @@ fun DownloadsScreen( ) } + item { + Chip( + label = { + Text( + text = stringResource(R.string.wear_local_playlists_entry), + color = palette.textPrimary, + ) + }, + icon = { + Icon( + imageVector = Icons.AutoMirrored.Rounded.QueueMusic, + contentDescription = null, + tint = palette.textSecondary, + modifier = Modifier.size(18.dp), + ) + }, + onClick = onPlaylistsClick, + colors = ChipDefaults.chipColors( + backgroundColor = surfaceContainer, + contentColor = palette.chipContent, + ), + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 4.dp), + ) + } + if (inlineMessage != null) { item { Chip( diff --git a/wear/src/main/java/com/theveloper/pixelplay/presentation/screens/LocalPlaylistDetailScreen.kt b/wear/src/main/java/com/theveloper/pixelplay/presentation/screens/LocalPlaylistDetailScreen.kt new file mode 100644 index 000000000..250086cf0 --- /dev/null +++ b/wear/src/main/java/com/theveloper/pixelplay/presentation/screens/LocalPlaylistDetailScreen.kt @@ -0,0 +1,267 @@ +package com.theveloper.pixelplay.presentation.screens + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.MusicNote +import androidx.compose.material.icons.rounded.PlayArrow +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.zIndex +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.wear.compose.foundation.lazy.items +import androidx.wear.compose.material.Chip +import androidx.wear.compose.material.ChipDefaults +import androidx.wear.compose.material.CircularProgressIndicator +import androidx.wear.compose.material.Icon +import androidx.wear.compose.material.MaterialTheme +import androidx.wear.compose.material.Text +import com.google.android.horologist.compose.layout.ScalingLazyColumn +import com.google.android.horologist.compose.layout.rememberResponsiveColumnState +import com.theveloper.pixelplay.R +import com.theveloper.pixelplay.presentation.components.AlwaysOnScalingPositionIndicator +import com.theveloper.pixelplay.presentation.components.PlayingEqIcon +import com.theveloper.pixelplay.presentation.components.WearTopTimeText +import com.theveloper.pixelplay.presentation.theme.LocalWearPalette +import com.theveloper.pixelplay.presentation.theme.screenBackgroundColor +import com.theveloper.pixelplay.presentation.theme.surfaceContainerColor +import com.theveloper.pixelplay.presentation.theme.surfaceContainerHighColor +import com.theveloper.pixelplay.presentation.viewmodel.WearLocalPlaylistSongItem +import com.theveloper.pixelplay.presentation.viewmodel.WearLocalPlaylistViewModel +import com.theveloper.pixelplay.presentation.viewmodel.WearPlayerViewModel + +/** + * Songs in a phone playlist synced locally on the watch. Songs still awaiting transfer show as + * disabled with a "waiting to transfer" label rather than being hidden — the list order and count + * matches the phone immediately, only playability lags behind. + */ +@Composable +fun LocalPlaylistDetailScreen( + playlistId: String, + title: String, + viewModel: WearLocalPlaylistViewModel = hiltViewModel(), + playerViewModel: WearPlayerViewModel = hiltViewModel(), +) { + val playlistDetails by viewModel.playlistDetails.collectAsStateWithLifecycle() + val songs by viewModel.playlistSongs.collectAsStateWithLifecycle() + val playerState by playerViewModel.playerState.collectAsStateWithLifecycle() + val palette = LocalWearPalette.current + val columnState = rememberResponsiveColumnState() + val background = palette.screenBackgroundColor() + val displayTitle = playlistDetails?.name ?: title + val availableCount = songs.count { it.isAvailable } + + LaunchedEffect(playlistId) { + viewModel.loadPlaylist(playlistId) + } + + Box( + modifier = Modifier + .fillMaxSize() + .background(background), + ) { + ScalingLazyColumn( + modifier = Modifier.fillMaxSize(), + columnState = columnState, + ) { + item { Spacer(modifier = Modifier.height(18.dp)) } + + item { + Text( + text = displayTitle, + style = MaterialTheme.typography.title2, + fontWeight = FontWeight(760), + color = palette.textPrimary, + textAlign = TextAlign.Center, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 2.dp), + ) + } + + if (songs.isNotEmpty()) { + item { + Text( + text = stringResource( + R.string.wear_playlist_pending_songs, + availableCount, + songs.size, + ), + style = MaterialTheme.typography.caption2, + color = palette.textSecondary.copy(alpha = 0.82f), + textAlign = TextAlign.Center, + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 6.dp), + ) + } + + item { + val playAllEnabled = availableCount > 0 + val playAllContentColor = if (playAllEnabled) { + palette.textPrimary + } else { + palette.textSecondary.copy(alpha = 0.72f) + } + Chip( + label = { Text(text = stringResource(R.string.wear_play_all), color = playAllContentColor) }, + icon = { + Icon( + imageVector = Icons.Rounded.PlayArrow, + contentDescription = null, + tint = playAllContentColor, + modifier = Modifier.size(18.dp), + ) + }, + onClick = { viewModel.playAll() }, + enabled = playAllEnabled, + colors = ChipDefaults.chipColors( + backgroundColor = if (playAllEnabled) { + palette.shuffleActive.copy(alpha = 0.38f) + } else { + palette.surfaceContainerHighColor() + }, + contentColor = palette.chipContent, + ), + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 4.dp), + ) + } + } + + if (songs.isEmpty()) { + item { + Text( + text = stringResource(R.string.wear_playlist_empty), + style = MaterialTheme.typography.body2, + color = palette.textSecondary.copy(alpha = 0.7f), + textAlign = TextAlign.Center, + modifier = Modifier + .fillMaxWidth() + .padding(top = 8.dp), + ) + } + } else { + items(items = songs, key = { it.songId }) { item -> + val isCurrentSong = item.song != null && + item.songId == playerState.songId && + playerState.songId.isNotBlank() + val isPlayingSong = isCurrentSong && playerState.isPlaying + LocalPlaylistSongChip( + item = item, + isCurrentSong = isCurrentSong, + isPlayingSong = isPlayingSong, + onClick = { if (item.isAvailable) viewModel.playFrom(item.songId) }, + ) + } + } + } + + AlwaysOnScalingPositionIndicator( + listState = columnState.state, + modifier = Modifier.align(Alignment.CenterEnd), + color = palette.textPrimary, + ) + + WearTopTimeText( + modifier = Modifier + .align(Alignment.TopCenter) + .zIndex(5f), + color = palette.textPrimary, + ) + } +} + +@Composable +private fun LocalPlaylistSongChip( + item: WearLocalPlaylistSongItem, + isCurrentSong: Boolean, + isPlayingSong: Boolean, + onClick: () -> Unit, +) { + val palette = LocalWearPalette.current + val song = item.song + val title = song?.title ?: item.songId + val containerColor = if (isCurrentSong) palette.surfaceContainerHighColor() else palette.surfaceContainerColor() + val contentAlpha = if (item.isAvailable) 1f else 0.55f + + Chip( + label = { + Text( + text = title, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + color = palette.textPrimary.copy(alpha = contentAlpha), + ) + }, + secondaryLabel = when { + !item.isAvailable -> { + { + Text( + text = stringResource(R.string.wear_song_pending_transfer), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + color = palette.textSecondary.copy(alpha = 0.72f), + ) + } + } + !song?.artist.isNullOrEmpty() -> { + { + Text( + text = song.artist.orEmpty(), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + color = palette.textSecondary.copy(alpha = 0.78f), + ) + } + } + else -> null + }, + icon = { + when { + isCurrentSong -> PlayingEqIcon( + color = if (isPlayingSong) palette.shuffleActive else palette.textSecondary, + isPlaying = isPlayingSong, + modifier = Modifier.size(18.dp), + ) + !item.isAvailable -> CircularProgressIndicator( + indicatorColor = palette.textSecondary.copy(alpha = 0.6f), + trackColor = palette.surfaceContainerColor(), + modifier = Modifier.size(18.dp), + strokeWidth = 2.dp, + ) + else -> Icon( + imageVector = Icons.Rounded.MusicNote, + contentDescription = null, + tint = palette.textSecondary, + modifier = Modifier.size(18.dp), + ) + } + }, + onClick = onClick, + enabled = item.isAvailable, + colors = ChipDefaults.chipColors( + backgroundColor = containerColor, + contentColor = palette.chipContent, + ), + modifier = Modifier.fillMaxWidth(), + ) +} diff --git a/wear/src/main/java/com/theveloper/pixelplay/presentation/screens/LocalPlaylistsScreen.kt b/wear/src/main/java/com/theveloper/pixelplay/presentation/screens/LocalPlaylistsScreen.kt new file mode 100644 index 000000000..87acec1a8 --- /dev/null +++ b/wear/src/main/java/com/theveloper/pixelplay/presentation/screens/LocalPlaylistsScreen.kt @@ -0,0 +1,179 @@ +package com.theveloper.pixelplay.presentation.screens + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.QueueMusic +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.zIndex +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.wear.compose.foundation.lazy.items +import androidx.wear.compose.material.Chip +import androidx.wear.compose.material.ChipDefaults +import androidx.wear.compose.material.CircularProgressIndicator +import androidx.wear.compose.material.Icon +import androidx.wear.compose.material.MaterialTheme +import androidx.wear.compose.material.Text +import com.google.android.horologist.compose.layout.ScalingLazyColumn +import com.google.android.horologist.compose.layout.rememberResponsiveColumnState +import com.theveloper.pixelplay.R +import com.theveloper.pixelplay.data.local.LocalPlaylistEntity +import com.theveloper.pixelplay.presentation.components.AlwaysOnScalingPositionIndicator +import com.theveloper.pixelplay.presentation.components.WearTopTimeText +import com.theveloper.pixelplay.presentation.theme.LocalWearPalette +import com.theveloper.pixelplay.presentation.theme.screenBackgroundColor +import com.theveloper.pixelplay.presentation.theme.surfaceContainerColor +import com.theveloper.pixelplay.presentation.theme.surfaceContainerHighColor +import com.theveloper.pixelplay.presentation.viewmodel.WearLocalPlaylistViewModel + +/** + * Playlists synced from the phone, stored locally on the watch. Tapping one opens + * [LocalPlaylistDetailScreen] regardless of whether every song has finished transferring yet — + * the playlist's membership/order arrives before its audio (see `WearPlaylistSync`), so the list + * itself is meaningful immediately. + */ +@Composable +fun LocalPlaylistsScreen( + onPlaylistClick: (playlistId: String, title: String) -> Unit, + viewModel: WearLocalPlaylistViewModel = hiltViewModel(), +) { + val playlists by viewModel.playlists.collectAsStateWithLifecycle() + val playlistIdsReceiving by viewModel.playlistIdsReceiving.collectAsStateWithLifecycle() + val palette = LocalWearPalette.current + val columnState = rememberResponsiveColumnState() + val background = palette.screenBackgroundColor() + + Box( + modifier = Modifier + .fillMaxSize() + .background(background), + ) { + ScalingLazyColumn( + modifier = Modifier.fillMaxSize(), + columnState = columnState, + ) { + item { Spacer(modifier = Modifier.height(18.dp)) } + + item { + Text( + text = stringResource(R.string.wear_playlists_title), + style = MaterialTheme.typography.title2, + fontWeight = FontWeight(760), + color = palette.textPrimary, + textAlign = TextAlign.Center, + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 4.dp), + ) + } + + if (playlists.isEmpty()) { + item { + Text( + text = stringResource(R.string.wear_no_local_playlists), + style = MaterialTheme.typography.body2, + color = palette.textSecondary.copy(alpha = 0.7f), + textAlign = TextAlign.Center, + modifier = Modifier + .fillMaxWidth() + .padding(top = 8.dp), + ) + } + } else { + items(items = playlists, key = { it.playlistId }) { playlist -> + LocalPlaylistChip( + playlist = playlist, + isReceiving = playlistIdsReceiving.contains(playlist.playlistId), + onClick = { onPlaylistClick(playlist.playlistId, playlist.name) }, + ) + } + } + } + + AlwaysOnScalingPositionIndicator( + listState = columnState.state, + modifier = Modifier.align(Alignment.CenterEnd), + color = palette.textPrimary, + ) + + WearTopTimeText( + modifier = Modifier + .align(Alignment.TopCenter) + .zIndex(5f), + color = palette.textPrimary, + ) + } +} + +@Composable +private fun LocalPlaylistChip( + playlist: LocalPlaylistEntity, + isReceiving: Boolean, + onClick: () -> Unit, +) { + val palette = LocalWearPalette.current + Chip( + label = { + Text( + text = playlist.name, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + color = palette.textPrimary, + ) + }, + secondaryLabel = if (isReceiving) { + { + Text( + text = stringResource(R.string.wear_playlist_receiving), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + color = palette.shuffleActive.copy(alpha = 0.90f), + ) + } + } else { + null + }, + icon = { + if (isReceiving) { + CircularProgressIndicator( + indicatorColor = palette.shuffleActive, + trackColor = palette.surfaceContainerColor(), + modifier = Modifier.size(18.dp), + strokeWidth = 2.dp, + ) + } else { + Icon( + imageVector = Icons.AutoMirrored.Rounded.QueueMusic, + contentDescription = null, + tint = palette.textSecondary, + modifier = Modifier.size(18.dp), + ) + } + }, + onClick = onClick, + colors = ChipDefaults.chipColors( + backgroundColor = if (isReceiving) { + palette.surfaceContainerHighColor() + } else { + palette.surfaceContainerColor() + }, + contentColor = palette.chipContent, + ), + modifier = Modifier.fillMaxWidth(), + ) +} diff --git a/wear/src/main/res/values/strings_wear.xml b/wear/src/main/res/values/strings_wear.xml index 454dcf99c..39fd8f360 100644 --- a/wear/src/main/res/values/strings_wear.xml +++ b/wear/src/main/res/values/strings_wear.xml @@ -14,6 +14,14 @@ Scanning watch storage… Retry scan No local songs found + Playlists + No playlists sent from your phone yet + Receiving… + Playlists + %1$d of %2$d ready + Play all + This playlist has no songs + Waiting to transfer… Playing Current More options From c108c66f64dbba46988dd1d84506d8d178675feb Mon Sep 17 00:00:00 2001 From: PonceGL Date: Sun, 9 Aug 2026 09:53:58 -0600 Subject: [PATCH 25/40] feat(app): add PlaylistBatchTransferPersistence for in-flight batches MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Persists at most one in-flight playlist batch transfer intent (batchId, playlistId, playlistName, songIds, requestedAtMillis) to the app's shared DataStore, 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. --- .../wear/PlaylistBatchTransferPersistence.kt | 87 +++++++++++++ .../PlaylistBatchTransferPersistenceTest.kt | 117 ++++++++++++++++++ 2 files changed, 204 insertions(+) create mode 100644 app/src/main/java/com/theveloper/pixelplay/data/service/wear/PlaylistBatchTransferPersistence.kt create mode 100644 app/src/test/java/com/theveloper/pixelplay/data/service/wear/PlaylistBatchTransferPersistenceTest.kt diff --git a/app/src/main/java/com/theveloper/pixelplay/data/service/wear/PlaylistBatchTransferPersistence.kt b/app/src/main/java/com/theveloper/pixelplay/data/service/wear/PlaylistBatchTransferPersistence.kt new file mode 100644 index 000000000..89e3214f1 --- /dev/null +++ b/app/src/main/java/com/theveloper/pixelplay/data/service/wear/PlaylistBatchTransferPersistence.kt @@ -0,0 +1,87 @@ +package com.theveloper.pixelplay.data.service.wear + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import javax.inject.Inject +import javax.inject.Singleton +import kotlinx.coroutines.flow.first +import kotlinx.serialization.Serializable +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import timber.log.Timber + +/** + * A playlist batch transfer request, in just enough detail to resume it after the phone process + * dies mid-transfer — a realistic outcome for a transfer that can run tens of minutes over + * Bluetooth, not a theoretical one (see the plan's §R-06). [songIds] is the original request, not + * whatever subset was still pending when the process died: [PlaylistWatchTransferCoordinator] + * already re-derives which of them are still needed by asking the watch what it already has, the + * same way it does for a fresh, non-resumed send. + */ +@Serializable +data class PersistedPlaylistBatchIntent( + val batchId: String, + val playlistId: String, + val playlistName: String, + val songIds: List, + val requestedAtMillis: Long, +) + +/** + * Persists at most one in-flight playlist batch intent — deliberately not the rest of + * [PhoneWatchTransferStateStore]'s state (per-song byte progress, reachable nodes, ...), which is + * UI-only, cheap to rebuild, and churns too fast to persist sensibly. Only the intent — "this + * playlist batch was requested and hadn't finished" — needs to survive a process restart. + * + * Reuses the app's single shared `DataStore` (see [com.theveloper.pixelplay.di.AppModule]) + * rather than a dedicated file, matching the existing `*PreferencesRepository` convention. + */ +@Singleton +class PlaylistBatchTransferPersistence @Inject constructor( + private val dataStore: DataStore, +) { + private val json = Json { ignoreUnknownKeys = true } + + suspend fun saveInFlightBatch(intent: PersistedPlaylistBatchIntent) { + dataStore.edit { preferences -> + preferences[Keys.IN_FLIGHT_BATCH] = json.encodeToString(intent) + } + } + + /** + * No-ops if [batchId] isn't the one currently stored: a newer batch (e.g. the user sent + * another playlist while this one was still finishing up) may already have overwritten it, + * and clearing unconditionally here would drop that newer, still-in-flight intent instead. + */ + suspend fun clearInFlightBatch(batchId: String) { + dataStore.edit { preferences -> + val stored = preferences[Keys.IN_FLIGHT_BATCH]?.let(::decode) + if (stored?.batchId == batchId) { + preferences.remove(Keys.IN_FLIGHT_BATCH) + } + } + } + + suspend fun getInFlightBatch(): PersistedPlaylistBatchIntent? { + val stored = dataStore.data.first()[Keys.IN_FLIGHT_BATCH] ?: return null + return decode(stored) + } + + private fun decode(raw: String): PersistedPlaylistBatchIntent? = try { + json.decodeFromString(raw) + } catch (e: Exception) { + Timber.tag(TAG).w(e, "Failed to decode persisted playlist batch intent, discarding it") + null + } + + private object Keys { + val IN_FLIGHT_BATCH = stringPreferencesKey("wear_playlist_batch_in_flight_v1") + } + + private companion object { + const val TAG = "PlaylistBatchPersist" + } +} diff --git a/app/src/test/java/com/theveloper/pixelplay/data/service/wear/PlaylistBatchTransferPersistenceTest.kt b/app/src/test/java/com/theveloper/pixelplay/data/service/wear/PlaylistBatchTransferPersistenceTest.kt new file mode 100644 index 000000000..fbed1de71 --- /dev/null +++ b/app/src/test/java/com/theveloper/pixelplay/data/service/wear/PlaylistBatchTransferPersistenceTest.kt @@ -0,0 +1,117 @@ +package com.theveloper.pixelplay.data.service.wear + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import com.google.common.truth.Truth.assertThat +import java.nio.file.Files +import java.nio.file.Path +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test + +class PlaylistBatchTransferPersistenceTest { + + // DataStore's internal write-actor needs a CoroutineScope that outlives any single test + // method's own `runTest {}` block — a `runTest`-scoped `backgroundScope` gets cancelled the + // moment that particular runTest call returns, which would tear this down mid-test if it were + // built in @BeforeEach's own runTest instead of here. + private lateinit var dataStoreScope: CoroutineScope + private lateinit var tempDir: Path + private lateinit var dataStore: DataStore + private lateinit var persistence: PlaylistBatchTransferPersistence + + @BeforeEach + fun setUp() { + tempDir = Files.createTempDirectory("playlist-batch-persistence-test") + dataStoreScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + dataStore = PreferenceDataStoreFactory.create( + scope = dataStoreScope, + produceFile = { tempDir.resolve("settings.preferences_pb").toFile() }, + ) + persistence = PlaylistBatchTransferPersistence(dataStore) + } + + @AfterEach + fun tearDown() { + dataStoreScope.cancel() + tempDir.toFile().deleteRecursively() + } + + private fun intent( + batchId: String = "batch-1", + playlistId: String = "p1", + songIds: List = listOf("s1", "s2"), + ) = PersistedPlaylistBatchIntent( + batchId = batchId, + playlistId = playlistId, + playlistName = "Road trip", + songIds = songIds, + requestedAtMillis = 1_000L, + ) + + @Test + fun `nothing persisted returns null`() = runTest { + assertThat(persistence.getInFlightBatch()).isNull() + } + + @Test + fun `save then get round-trips the intent`() = runTest { + val saved = intent() + persistence.saveInFlightBatch(saved) + + assertThat(persistence.getInFlightBatch()).isEqualTo(saved) + } + + @Test + fun `saving a second batch overwrites the first`() = runTest { + persistence.saveInFlightBatch(intent(batchId = "batch-1", playlistId = "p1")) + persistence.saveInFlightBatch(intent(batchId = "batch-2", playlistId = "p2")) + + assertThat(persistence.getInFlightBatch()?.batchId).isEqualTo("batch-2") + } + + @Test + fun `clearing with the matching batchId removes it`() = runTest { + persistence.saveInFlightBatch(intent(batchId = "batch-1")) + + persistence.clearInFlightBatch("batch-1") + + assertThat(persistence.getInFlightBatch()).isNull() + } + + @Test + fun `clearing with a stale batchId is a no-op, so a newer batch survives`() = runTest { + persistence.saveInFlightBatch(intent(batchId = "batch-1")) + persistence.saveInFlightBatch(intent(batchId = "batch-2")) + + // Batch 1's own coordinator finally reaches its terminal state and tries to clear itself, + // but batch 2 already overwrote the stored intent — must not clear batch 2's. + persistence.clearInFlightBatch("batch-1") + + assertThat(persistence.getInFlightBatch()?.batchId).isEqualTo("batch-2") + } + + @Test + fun `clearing when nothing is stored does not throw`() = runTest { + persistence.clearInFlightBatch("batch-1") + + assertThat(persistence.getInFlightBatch()).isNull() + } + + @Test + fun `malformed stored data is treated as nothing persisted, not a crash`() = runTest { + dataStore.edit { preferences -> + preferences[stringPreferencesKey("wear_playlist_batch_in_flight_v1")] = "{not valid json" + } + + assertThat(persistence.getInFlightBatch()).isNull() + } +} From 01f9dfaaf72c4bbdd4e2b36f57bfc8c4c6134df3 Mon Sep 17 00:00:00 2001 From: PonceGL Date: Sun, 9 Aug 2026 09:54:07 -0600 Subject: [PATCH 26/40] feat(app): resume an interrupted playlist watch transfer at startup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../pixelplay/PixelPlayApplication.kt | 19 +++ .../wear/PlaylistWatchTransferCoordinator.kt | 43 ++++++ .../PlaylistWatchTransferCoordinatorTest.kt | 129 ++++++++++++++++-- 3 files changed, 180 insertions(+), 11 deletions(-) diff --git a/app/src/main/java/com/theveloper/pixelplay/PixelPlayApplication.kt b/app/src/main/java/com/theveloper/pixelplay/PixelPlayApplication.kt index 30014e1d0..48cf1d8ab 100644 --- a/app/src/main/java/com/theveloper/pixelplay/PixelPlayApplication.kt +++ b/app/src/main/java/com/theveloper/pixelplay/PixelPlayApplication.kt @@ -16,6 +16,7 @@ import coil.ImageLoaderFactory import com.theveloper.pixelplay.data.preferences.UserPreferencesRepository import com.theveloper.pixelplay.data.diagnostics.AdvancedPerformanceDiagnosticsController import com.theveloper.pixelplay.data.repository.ArtistImageRepository +import com.theveloper.pixelplay.data.service.wear.PlaylistWatchTransferCoordinator import com.theveloper.pixelplay.data.telegram.TelegramRepository import com.theveloper.pixelplay.presentation.viewmodel.LibraryStateHolder import com.theveloper.pixelplay.presentation.viewmodel.ThemeStateHolder @@ -72,6 +73,9 @@ class PixelPlayApplication : Application(), ImageLoaderFactory, Configuration.Pr @Inject lateinit var advancedPerformanceDiagnosticsController: dagger.Lazy + @Inject + lateinit var playlistWatchTransferCoordinator: dagger.Lazy + private val startupScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) // AÑADE EL COMPANION OBJECT @@ -130,6 +134,21 @@ class PixelPlayApplication : Application(), ImageLoaderFactory, Configuration.Pr AlbumArtCacheManager.configuredCacheLimitMb = savedLimit.toLong() } } + + startupScope.launch { + // Best-effort: a cold start not directly triggered by the user (e.g. the system + // reviving the process for an unrelated broadcast) may be too restricted to start the + // foreground service this resumes into — resumePersistedBatchIfNeeded() just skips + // resuming this time rather than crashing app startup over it; the persisted intent + // stays put for the next launch that can. + try { + playlistWatchTransferCoordinator.get().resumePersistedBatchIfNeeded() + } catch (e: kotlinx.coroutines.CancellationException) { + throw e + } catch (e: Exception) { + Timber.w(e, "Failed to resume an interrupted playlist watch transfer") + } + } } override fun newImageLoader(): ImageLoader { diff --git a/app/src/main/java/com/theveloper/pixelplay/data/service/wear/PlaylistWatchTransferCoordinator.kt b/app/src/main/java/com/theveloper/pixelplay/data/service/wear/PlaylistWatchTransferCoordinator.kt index 03d94c042..ece0ac012 100644 --- a/app/src/main/java/com/theveloper/pixelplay/data/service/wear/PlaylistWatchTransferCoordinator.kt +++ b/app/src/main/java/com/theveloper/pixelplay/data/service/wear/PlaylistWatchTransferCoordinator.kt @@ -47,6 +47,7 @@ class PlaylistWatchTransferCoordinator @Inject constructor( private val directTransferCoordinator: PhoneDirectWatchTransferCoordinator, private val wearPhoneTransferSender: WearPhoneTransferSender, private val transferStateStore: PhoneWatchTransferStateStore, + private val batchPersistence: PlaylistBatchTransferPersistence, // Injected directly (unlike most of this package, which resolves these via // Wearable.getXClient(application) internally) so this coordinator is constructible with // fakes in tests without needing to mock a static Java method. @@ -82,6 +83,31 @@ class PlaylistWatchTransferCoordinator @Inject constructor( scope.launch { wearPhoneTransferSender.cancelTransfer(activeRequestId) } } transferStateStore.markBatchCancelled(batchId) + scope.launch { batchPersistence.clearInFlightBatch(batchId) } + } + + /** + * Called once at process start ([com.theveloper.pixelplay.PixelPlayApplication]). If the + * process died mid-transfer last time, [PlaylistBatchTransferPersistence] still has that + * batch's intent — re-running it from scratch is safe and correct: the watch itself rejects + * a duplicate transfer for a song it already has (`ERROR_ALREADY_ON_WATCH`), and + * [runBatchTransfer] already skips anything [PhoneWatchTransferStateStore] can confirm is + * already there. That confirmation is only as good as the watch-library snapshot in memory — + * empty right after a cold start — so this waits (briefly) for a fresh one before resuming, + * instead of re-attempting everything and relying solely on the watch's own rejection. + */ + suspend fun resumePersistedBatchIfNeeded() { + val persisted = batchPersistence.getInFlightBatch() ?: return + Timber.tag(TAG).i( + "Resuming playlist transfer interrupted by process death: playlistId=%s (%d songs)", + persisted.playlistId, + persisted.songIds.size, + ) + runCatching { wearPhoneTransferSender.refreshWatchLibraryState() } + withTimeoutOrNull(WATCH_LIBRARY_RESOLVE_TIMEOUT_MS) { + transferStateStore.isWatchLibraryResolved.first { it } + } + requestPlaylistTransfer(persisted.playlistId, persisted.playlistName, persisted.songIds) } private suspend fun runBatchTransfer( @@ -90,11 +116,22 @@ class PlaylistWatchTransferCoordinator @Inject constructor( playlistName: String, songIds: List, ) { + batchPersistence.saveInFlightBatch( + PersistedPlaylistBatchIntent( + batchId = batchId, + playlistId = playlistId, + playlistName = playlistName, + songIds = songIds, + requestedAtMillis = System.currentTimeMillis(), + ) + ) + val nodes = resolveReachableNodes() transferStateStore.markBatchStarted(batchId, playlistId, playlistName, songIds.size) if (nodes.isEmpty()) { transferStateStore.markBatchFailed(batchId, "No reachable watch with PixelPlay") + batchPersistence.clearInFlightBatch(batchId) return } transferStateStore.retainReachableWatchNodes(nodes.map { it.id }.toSet()) @@ -128,6 +165,7 @@ class PlaylistWatchTransferCoordinator @Inject constructor( cancelledBatchIds.remove(batchId) if (transferStateStore.batchTransfers.value[batchId]?.status != WearTransferProgress.STATUS_CANCELLED) { transferStateStore.markBatchCompleted(batchId) + batchPersistence.clearInFlightBatch(batchId) } } @@ -313,6 +351,11 @@ class PlaylistWatchTransferCoordinator @Inject constructor( // mark a legitimately-slow transfer as failed. private const val DEFAULT_SONG_TRANSFER_AWAIT_TIMEOUT_MS = 300_000L + // How long resumePersistedBatchIfNeeded() waits for a fresh watch-library snapshot before + // giving up and resuming anyway. Short: this only avoids some wasted duplicate-rejected + // round-trips, it's not load-bearing for correctness (the watch rejects duplicates itself). + private const val WATCH_LIBRARY_RESOLVE_TIMEOUT_MS = 10_000L + private val TERMINAL_STATUSES = setOf( WearTransferProgress.STATUS_COMPLETED, WearTransferProgress.STATUS_FAILED, diff --git a/app/src/test/java/com/theveloper/pixelplay/data/service/wear/PlaylistWatchTransferCoordinatorTest.kt b/app/src/test/java/com/theveloper/pixelplay/data/service/wear/PlaylistWatchTransferCoordinatorTest.kt index 2609a3494..aea06d83f 100644 --- a/app/src/test/java/com/theveloper/pixelplay/data/service/wear/PlaylistWatchTransferCoordinatorTest.kt +++ b/app/src/test/java/com/theveloper/pixelplay/data/service/wear/PlaylistWatchTransferCoordinatorTest.kt @@ -1,6 +1,7 @@ package com.theveloper.pixelplay.data.service.wear import android.app.Application +import androidx.datastore.preferences.core.PreferenceDataStoreFactory import com.google.android.gms.tasks.Tasks import com.google.android.gms.wearable.CapabilityClient import com.google.android.gms.wearable.CapabilityInfo @@ -12,13 +13,16 @@ import com.theveloper.pixelplay.data.repository.MusicRepository import com.theveloper.pixelplay.shared.WearTransferProgress import io.mockk.Runs import io.mockk.coEvery +import io.mockk.coVerify import io.mockk.every import io.mockk.just import io.mockk.mockk import io.mockk.verify +import java.nio.file.Files import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test @@ -41,9 +45,12 @@ class PlaylistWatchTransferCoordinatorTest { private val messageClient = mockk() private val transferredSongIdsInOrder = mutableListOf() + private lateinit var tempDir: java.nio.file.Path + private lateinit var batchPersistence: PlaylistBatchTransferPersistence @BeforeEach fun setUp() { + tempDir = Files.createTempDirectory("playlist-watch-transfer-coordinator-test") // Default: no song needs transcoding. transcodeIfNeeded is what the coordinator actually // calls — requiresTranscoding lives inside it and is never invoked directly by the // coordinator, so stubbing that instead would silently test nothing. @@ -61,6 +68,11 @@ class PlaylistWatchTransferCoordinatorTest { } } + @AfterEach + fun tearDown() { + tempDir.toFile().deleteRecursively() + } + private val songsById = mutableMapOf() private fun song(id: String, title: String = "Song $id"): Song { @@ -101,17 +113,26 @@ class PlaylistWatchTransferCoordinatorTest { } } - private fun buildCoordinator(scope: kotlinx.coroutines.CoroutineScope) = PlaylistWatchTransferCoordinator( - application = application, - musicRepository = musicRepository, - watchAudioTranscoder = watchAudioTranscoder, - directTransferCoordinator = directTransferCoordinator, - wearPhoneTransferSender = wearPhoneTransferSender, - transferStateStore = transferStateStore, - capabilityClient = capabilityClient, - messageClient = messageClient, - scope = scope, - ) + private fun buildCoordinator(scope: kotlinx.coroutines.CoroutineScope): PlaylistWatchTransferCoordinator { + batchPersistence = PlaylistBatchTransferPersistence( + dataStore = PreferenceDataStoreFactory.create( + scope = scope, + produceFile = { tempDir.resolve("settings.preferences_pb").toFile() }, + ), + ) + return PlaylistWatchTransferCoordinator( + application = application, + musicRepository = musicRepository, + watchAudioTranscoder = watchAudioTranscoder, + directTransferCoordinator = directTransferCoordinator, + wearPhoneTransferSender = wearPhoneTransferSender, + transferStateStore = transferStateStore, + batchPersistence = batchPersistence, + capabilityClient = capabilityClient, + messageClient = messageClient, + scope = scope, + ) + } @Test fun `an empty playlist does not start a batch`() = runTest { @@ -277,4 +298,90 @@ class PlaylistWatchTransferCoordinatorTest { assertThat(batch?.failedSongCount).isEqualTo(1) assertThat(batch?.completedSongCount).isEqualTo(0) } + + // --- Persistence: resuming a batch interrupted by process death (PR7) --- + + @Test + fun `a completed batch clears its persisted intent`() = runTest { + stubReachableNodes("node-1") + stubTransfersResolveTo(WearTransferProgress.STATUS_COMPLETED) + song("s1") + val coordinator = buildCoordinator(this) + + coordinator.requestPlaylistTransfer("p1", "Playlist", listOf("s1")) + advanceUntilIdle() + + assertThat(batchPersistence.getInFlightBatch()).isNull() + } + + @Test + fun `a batch that fails with no reachable watch clears its persisted intent`() = runTest { + stubReachableNodes() + song("s1") + val coordinator = buildCoordinator(this) + + coordinator.requestPlaylistTransfer("p1", "Playlist", listOf("s1")) + advanceUntilIdle() + + assertThat(batchPersistence.getInFlightBatch()).isNull() + } + + @Test + fun `cancelling a batch clears its persisted intent`() = runTest { + stubReachableNodes("node-1") + song("s1"); song("s2") + val coordinator = buildCoordinator(this) + lateinit var batchId: String + + every { + directTransferCoordinator.startTransferToWatch( + nodeId = any(), requestId = any(), songId = any(), + transferMode = any(), startPositionMs = any(), autoPlay = any(), audioOverride = any(), + ) + } answers { + val requestId = secondArg() + val songId = thirdArg() + coordinator.cancelPlaylistTransfer(batchId) + transferStateStore.markProgress(requestId, songId, 0L, 0L, WearTransferProgress.STATUS_COMPLETED) + } + + batchId = coordinator.requestPlaylistTransfer("p1", "Playlist", listOf("s1", "s2")) + advanceUntilIdle() + + assertThat(batchPersistence.getInFlightBatch()).isNull() + } + + @Test + fun `resuming with nothing persisted does not start a transfer`() = runTest { + val coordinator = buildCoordinator(this) + + coordinator.resumePersistedBatchIfNeeded() + advanceUntilIdle() + + assertThat(transferStateStore.batchTransfers.value).isEmpty() + verify(exactly = 0) { directTransferCoordinator.startTransferToWatch(any(), any(), any(), any(), any(), any(), any()) } + } + + @Test + fun `resuming a persisted intent re-runs the transfer for the same playlist and songs`() = runTest { + stubReachableNodes("node-1") + stubTransfersResolveTo(WearTransferProgress.STATUS_COMPLETED) + song("s1"); song("s2") + val coordinator = buildCoordinator(this) + batchPersistence.saveInFlightBatch( + PersistedPlaylistBatchIntent( + batchId = "orphaned-batch", + playlistId = "p1", + playlistName = "Playlist", + songIds = listOf("s1", "s2"), + requestedAtMillis = 0L, + ) + ) + + coordinator.resumePersistedBatchIfNeeded() + advanceUntilIdle() + + assertThat(transferredSongIdsInOrder).containsExactly("s1", "s2").inOrder() + coVerify { wearPhoneTransferSender.refreshWatchLibraryState() } + } } From d233966606ad6aaeac0c5d8661ecd93b7353c4fd Mon Sep 17 00:00:00 2001 From: PonceGL Date: Sun, 9 Aug 2026 11:53:48 -0600 Subject: [PATCH 27/40] fix(wear): make the transfer idle watchdog actually cancel the stream MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../pixelplay/data/WearTransferRepository.kt | 37 ++++++++++++++++++- 1 file changed, 35 insertions(+), 2 deletions(-) diff --git a/wear/src/main/java/com/theveloper/pixelplay/data/WearTransferRepository.kt b/wear/src/main/java/com/theveloper/pixelplay/data/WearTransferRepository.kt index 4821fbdb9..aaf4f0d33 100644 --- a/wear/src/main/java/com/theveloper/pixelplay/data/WearTransferRepository.kt +++ b/wear/src/main/java/com/theveloper/pixelplay/data/WearTransferRepository.kt @@ -125,6 +125,13 @@ class WearTransferRepository @Inject constructor( /** Failsafe timeout per transfer to avoid hanging states at 0%. */ private val transferWatchdogs = ConcurrentHashMap() + /** The live audio InputStream for a request, while onAudioChannelOpened is reading it — + * lets armTransferWatchdog actually interrupt a stuck read instead of just updating + * bookkeeping while the real transfer keeps running unaware. */ + private val openAudioStreams = ConcurrentHashMap() + /** Request IDs whose audio stream was closed by the watchdog, so onAudioChannelOpened's + * catch block can report "Transfer timed out" instead of a generic stream-closed message. */ + private val watchdogTimedOutRequestIds = ConcurrentHashMap.newKeySet() /** Request IDs currently receiving bytes through ChannelClient. */ private val activeChannelRequestIds = ConcurrentHashMap.newKeySet() /** Cancelled request IDs retained briefly so late metadata/progress/channel events are ignored safely. */ @@ -488,6 +495,7 @@ class WearTransferRepository @Inject constructor( if (!musicDir.exists()) musicDir.mkdirs() val tempFile = File(musicDir, "$requestId.part") var metadata: WearTransferMetadata? = pendingMetadata[requestId] + openAudioStreams[requestId] = inputStream try { if (isTransferCancelled(requestId)) { @@ -769,15 +777,18 @@ class WearTransferRepository @Inject constructor( "Transfer complete: ${resolvedMetadata.title} ($actualSize bytes) → ${localFile.absolutePath}" ) } catch (e: Exception) { + val timedOut = watchdogTimedOutRequestIds.remove(requestId) Timber.tag(TAG).e(e, "Failed to write transferred file") tempFile.delete() handleTransferError( requestId = requestId, songId = metadata?.songId ?: _activeTransfers.value[requestId]?.songId.orEmpty(), - message = e.message ?: "Write failed", + message = if (timedOut) "Transfer timed out" else (e.message ?: "Write failed"), ) } finally { activeChannelRequestIds.remove(requestId) + openAudioStreams.remove(requestId) + watchdogTimedOutRequestIds.remove(requestId) } } @@ -973,6 +984,8 @@ class WearTransferRepository @Inject constructor( pendingArtworkByRequestId.remove(requestId) clearTransferWatchdog(requestId) activeChannelRequestIds.remove(requestId) + openAudioStreams.remove(requestId) + watchdogTimedOutRequestIds.remove(requestId) } private fun handleTransferError(requestId: String, songId: String, message: String) { @@ -994,6 +1007,8 @@ class WearTransferRepository @Inject constructor( pendingArtworkByRequestId.remove(requestId) clearTransferWatchdog(requestId) activeChannelRequestIds.remove(requestId) + openAudioStreams.remove(requestId) + watchdogTimedOutRequestIds.remove(requestId) } private fun resolveTemporaryPlaybackStartPosition( @@ -1076,7 +1091,25 @@ class WearTransferRepository @Inject constructor( transferWatchdogs[requestId] = scope.launch { delay(TRANSFER_IDLE_TIMEOUT_MS) if (_activeTransfers.value.containsKey(requestId)) { - handleTransferError(requestId, songId, "Transfer timed out") + val stream = openAudioStreams[requestId] + if (stream != null) { + // A live audio stream is genuinely stuck: close it so the blocking + // read() in onAudioChannelOpened unblocks with an IOException and routes + // through that function's own catch block for cleanup — a single, + // consistent path instead of this watchdog declaring failure on its own + // while the read loop keeps running in the background, unaware anything + // happened. That's what let a "failed" transfer keep going and finish + // seconds later anyway, or worse, strip pendingMetadata out from under + // the still-running loop and turn a slow-but-fine transfer into a real + // failure ("Transfer metadata missing" from the loop's own metadata + // resolution not finding what this watchdog had just cleared). + watchdogTimedOutRequestIds.add(requestId) + runCatching { stream.close() } + } else { + // No audio stream open yet (still waiting on metadata/channel) — nothing + // to interrupt, so this is still the right place to declare failure. + handleTransferError(requestId, songId, "Transfer timed out") + } } } } From 51ffdef5047b21f9dbc624f0a76753bcda2ff5f9 Mon Sep 17 00:00:00 2001 From: PonceGL Date: Sun, 9 Aug 2026 11:53:54 -0600 Subject: [PATCH 28/40] fix(wear): stop counting failed transfers as still receiving MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../viewmodel/WearLocalPlaylistViewModel.kt | 18 ++++++++-- .../WearLocalPlaylistViewModelTest.kt | 35 +++++++++++++++++++ 2 files changed, 50 insertions(+), 3 deletions(-) diff --git a/wear/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/WearLocalPlaylistViewModel.kt b/wear/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/WearLocalPlaylistViewModel.kt index c681e9b20..543d48386 100644 --- a/wear/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/WearLocalPlaylistViewModel.kt +++ b/wear/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/WearLocalPlaylistViewModel.kt @@ -11,6 +11,7 @@ import com.theveloper.pixelplay.data.local.LocalPlaylistEntity import com.theveloper.pixelplay.data.local.LocalSongDao import com.theveloper.pixelplay.data.local.LocalSongEntity import com.theveloper.pixelplay.data.WearTransferRepository +import com.theveloper.pixelplay.shared.WearTransferProgress import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -53,15 +54,26 @@ class WearLocalPlaylistViewModel @Inject constructor( /** In-flight song transfers from the phone, keyed by requestId — for on-screen receive feedback. */ val activeTransfers: StateFlow> = transferRepository.activeTransfers - /** Playlists that currently have at least one of their songs actively transferring. */ + /** + * Playlists that currently have at least one of their songs actively transferring. + * + * Only [WearTransferProgress.STATUS_TRANSFERRING] counts as "still receiving" — a failed or + * cancelled transfer stays in [WearTransferRepository.activeTransfers] indefinitely (so + * DownloadsScreen can list it under "Transfer issues"), but that's a terminal state, not an + * in-progress one. Treating mere presence in the map as "active" left this badge stuck on + * forever once a song failed. + */ val playlistIdsReceiving: StateFlow> = combine( localPlaylistDao.observeAllPlaylistSongCrossRefs(), transferRepository.activeTransfers, ) { crossRefs, transfers -> - if (transfers.isEmpty()) { + val activeSongIds = transfers.values + .filter { it.status == WearTransferProgress.STATUS_TRANSFERRING } + .map { it.songId } + .toSet() + if (activeSongIds.isEmpty()) { emptySet() } else { - val activeSongIds = transfers.values.map { it.songId }.toSet() crossRefs.filter { it.songId in activeSongIds }.map { it.playlistId }.toSet() } }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000L), emptySet()) diff --git a/wear/src/test/java/com/theveloper/pixelplay/presentation/viewmodel/WearLocalPlaylistViewModelTest.kt b/wear/src/test/java/com/theveloper/pixelplay/presentation/viewmodel/WearLocalPlaylistViewModelTest.kt index 3b666f93a..6458ef107 100644 --- a/wear/src/test/java/com/theveloper/pixelplay/presentation/viewmodel/WearLocalPlaylistViewModelTest.kt +++ b/wear/src/test/java/com/theveloper/pixelplay/presentation/viewmodel/WearLocalPlaylistViewModelTest.kt @@ -255,6 +255,41 @@ class WearLocalPlaylistViewModelTest { } } + @Test + fun `a failed transfer no longer counts as receiving once it reaches a terminal state`() = runTest { + allCrossRefsFlow.value = listOf(crossRef("p1", "s1", 0)) + + viewModel.playlistIdsReceiving.test { + assertThat(awaitItem()).isEmpty() // deduped placeholder, see the test above + transferRepository.onProgressReceived( + WearTransferProgress( + requestId = "r1", + songId = "s1", + bytesTransferred = 10L, + totalBytes = 100L, + status = WearTransferProgress.STATUS_TRANSFERRING, + ) + ) + assertThat(awaitItem()).containsExactly("p1") + + // The transfer fails — WearTransferRepository deliberately keeps this entry in + // activeTransfers (DownloadsScreen lists failed transfers under "Transfer issues"), + // it doesn't remove it. playlistIdsReceiving must stop counting it anyway: mere + // presence in the map isn't "still receiving" once the status is terminal. + transferRepository.onProgressReceived( + WearTransferProgress( + requestId = "r1", + songId = "s1", + bytesTransferred = 10L, + totalBytes = 100L, + status = WearTransferProgress.STATUS_FAILED, + error = "Transfer timed out", + ) + ) + assertThat(awaitItem()).isEmpty() + } + } + @Test fun `playAll switches output to watch when at least one song is available`() = expectFireAndForgetPlaybackCrash { playlistSongsFlowById["p1"] = MutableStateFlow(listOf(crossRef("p1", "s1", 0))) From e4b13fc26735e0fd8b29ee224ad156ea64926938 Mon Sep 17 00:00:00 2001 From: PonceGL Date: Sun, 9 Aug 2026 11:54:01 -0600 Subject: [PATCH 29/40] feat(app): retry a song once after a transient transfer failure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../wear/PlaylistWatchTransferCoordinator.kt | 37 +++++++- .../PlaylistWatchTransferCoordinatorTest.kt | 88 ++++++++++++++++++- 2 files changed, 123 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/theveloper/pixelplay/data/service/wear/PlaylistWatchTransferCoordinator.kt b/app/src/main/java/com/theveloper/pixelplay/data/service/wear/PlaylistWatchTransferCoordinator.kt index ece0ac012..0c67288b1 100644 --- a/app/src/main/java/com/theveloper/pixelplay/data/service/wear/PlaylistWatchTransferCoordinator.kt +++ b/app/src/main/java/com/theveloper/pixelplay/data/service/wear/PlaylistWatchTransferCoordinator.kt @@ -18,6 +18,7 @@ import javax.inject.Singleton import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.mapNotNull import kotlinx.coroutines.launch @@ -154,7 +155,7 @@ class PlaylistWatchTransferCoordinator @Inject constructor( continue } - val outcome = transferSongToAllNodes(batchId, nodes, song) + val outcome = transferSongToAllNodesWithRetry(batchId, nodes, song) if (outcome.completed) { transferStateStore.markBatchSongCompleted(batchId) } else { @@ -202,6 +203,35 @@ class PlaylistWatchTransferCoordinator @Inject constructor( } } + /** + * Retries [song] once after a transient failure, with a short backoff. Real hardware + * testing showed a song can legitimately fail (watch-side idle watchdog closing a live but + * slow Bluetooth stream — see WearTransferRepository) while a retry moments later succeeds + * cleanly: the watch's Bluetooth radio is shared with any connected BT headset, and a + * transfer can genuinely stall for a while under that contention without anything actually + * being broken. Doesn't retry past a cancellation, and re-transcodes on the retry rather + * than caching the first attempt's output — simpler and safe (transcoding on a modern phone + * is a few seconds, not the bottleneck), at the cost of redoing work that likely already + * succeeded once. + */ + private suspend fun transferSongToAllNodesWithRetry( + batchId: String, + nodes: List, + song: Song, + ): SongTransferResult { + val firstAttempt = transferSongToAllNodes(batchId, nodes, song) + if (firstAttempt.completed || cancelledBatchIds.contains(batchId)) return firstAttempt + + Timber.tag(TAG).w( + "Retrying transfer after failure: songId=%s errorCode=%s", + song.id, + firstAttempt.errorCode, + ) + delay(RETRY_BACKOFF_MS) + if (cancelledBatchIds.contains(batchId)) return firstAttempt + return transferSongToAllNodes(batchId, nodes, song) + } + /** Transcodes [song] once (if needed) and streams it to every reachable [nodes] in turn. */ private suspend fun transferSongToAllNodes( batchId: String, @@ -351,6 +381,11 @@ class PlaylistWatchTransferCoordinator @Inject constructor( // mark a legitimately-slow transfer as failed. private const val DEFAULT_SONG_TRANSFER_AWAIT_TIMEOUT_MS = 300_000L + // Short on purpose: a retry exists for transient stalls (radio contention with a + // connected BT headset, momentary Bluetooth hiccups), not to wait out a genuinely dead + // link — a longer backoff would just make a real failure take longer to report. + private const val RETRY_BACKOFF_MS = 3_000L + // How long resumePersistedBatchIfNeeded() waits for a fresh watch-library snapshot before // giving up and resuming anyway. Short: this only avoids some wasted duplicate-rejected // round-trips, it's not load-bearing for correctness (the watch rejects duplicates itself). diff --git a/app/src/test/java/com/theveloper/pixelplay/data/service/wear/PlaylistWatchTransferCoordinatorTest.kt b/app/src/test/java/com/theveloper/pixelplay/data/service/wear/PlaylistWatchTransferCoordinatorTest.kt index aea06d83f..82f034525 100644 --- a/app/src/test/java/com/theveloper/pixelplay/data/service/wear/PlaylistWatchTransferCoordinatorTest.kt +++ b/app/src/test/java/com/theveloper/pixelplay/data/service/wear/PlaylistWatchTransferCoordinatorTest.kt @@ -201,6 +201,8 @@ class PlaylistWatchTransferCoordinatorTest { val requestId = secondArg() val songId = thirdArg() transferredSongIdsInOrder += songId + // s2 fails on every attempt, including its retry (see the dedicated retry tests + // below) — this test is only about the batch surviving a song that never recovers. val status = if (songId == "s2") WearTransferProgress.STATUS_FAILED else WearTransferProgress.STATUS_COMPLETED transferStateStore.markProgress(requestId, songId, 0L, 0L, status) } @@ -209,13 +211,97 @@ class PlaylistWatchTransferCoordinatorTest { val batchId = coordinator.requestPlaylistTransfer("p1", "Playlist", listOf("s1", "s2", "s3")) advanceUntilIdle() - assertThat(transferredSongIdsInOrder).containsExactly("s1", "s2", "s3").inOrder() + // s2 appears twice: the first attempt and its retry. + assertThat(transferredSongIdsInOrder).containsExactly("s1", "s2", "s2", "s3").inOrder() val batch = transferStateStore.batchTransfers.value[batchId] assertThat(batch?.completedSongCount).isEqualTo(2) assertThat(batch?.failedSongCount).isEqualTo(1) assertThat(batch?.status).isEqualTo(WearTransferProgress.STATUS_COMPLETED) } + @Test + fun `a song that fails once but succeeds on retry counts as completed`() = runTest { + stubReachableNodes("node-1") + song("s1") + var attempt = 0 + every { + directTransferCoordinator.startTransferToWatch( + nodeId = any(), requestId = any(), songId = any(), + transferMode = any(), startPositionMs = any(), autoPlay = any(), audioOverride = any(), + ) + } answers { + val requestId = secondArg() + val songId = thirdArg() + attempt += 1 + val status = if (attempt == 1) WearTransferProgress.STATUS_FAILED else WearTransferProgress.STATUS_COMPLETED + transferStateStore.markProgress(requestId, songId, 0L, 0L, status) + } + val coordinator = buildCoordinator(this) + + val batchId = coordinator.requestPlaylistTransfer("p1", "Playlist", listOf("s1")) + advanceUntilIdle() + + assertThat(attempt).isEqualTo(2) + val batch = transferStateStore.batchTransfers.value[batchId] + assertThat(batch?.completedSongCount).isEqualTo(1) + assertThat(batch?.failedSongCount).isEqualTo(0) + } + + @Test + fun `a song failing twice in a row is only retried once, not indefinitely`() = runTest { + stubReachableNodes("node-1") + song("s1") + var attempts = 0 + every { + directTransferCoordinator.startTransferToWatch( + nodeId = any(), requestId = any(), songId = any(), + transferMode = any(), startPositionMs = any(), autoPlay = any(), audioOverride = any(), + ) + } answers { + val requestId = secondArg() + val songId = thirdArg() + attempts += 1 + transferStateStore.markProgress(requestId, songId, 0L, 0L, WearTransferProgress.STATUS_FAILED) + } + val coordinator = buildCoordinator(this) + + val batchId = coordinator.requestPlaylistTransfer("p1", "Playlist", listOf("s1")) + advanceUntilIdle() + + assertThat(attempts).isEqualTo(2) + val batch = transferStateStore.batchTransfers.value[batchId] + assertThat(batch?.failedSongCount).isEqualTo(1) + assertThat(batch?.completedSongCount).isEqualTo(0) + } + + @Test + fun `cancelling during the backoff window skips the retry`() = runTest { + stubReachableNodes("node-1") + song("s1") + val coordinator = buildCoordinator(this) + lateinit var batchId: String + + every { + directTransferCoordinator.startTransferToWatch( + nodeId = any(), requestId = any(), songId = any(), + transferMode = any(), startPositionMs = any(), autoPlay = any(), audioOverride = any(), + ) + } answers { + val requestId = secondArg() + val songId = thirdArg() + transferredSongIdsInOrder += songId + coordinator.cancelPlaylistTransfer(batchId) + transferStateStore.markProgress(requestId, songId, 0L, 0L, WearTransferProgress.STATUS_FAILED) + } + + batchId = coordinator.requestPlaylistTransfer("p1", "Playlist", listOf("s1")) + advanceUntilIdle() + + assertThat(transferredSongIdsInOrder).containsExactly("s1") + val batch = transferStateStore.batchTransfers.value[batchId] + assertThat(batch?.status).isEqualTo(WearTransferProgress.STATUS_CANCELLED) + } + @Test fun `cancelling a batch stops remaining songs from being transferred`() = runTest { stubReachableNodes("node-1") From 895814eca406bfd62c4045c64e32a4364712027e Mon Sep 17 00:00:00 2001 From: PonceGL Date: Sun, 9 Aug 2026 12:26:51 -0600 Subject: [PATCH 30/40] feat(wear): show a song's real title while it awaits transfer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../wear/PlaylistWatchTransferCoordinator.kt | 16 +++- .../PlaylistWatchTransferCoordinatorTest.kt | 26 ++++++ .../pixelplay/shared/WearPlaylistSync.kt | 7 ++ .../pixelplay/shared/WearPlaylistSyncTest.kt | 26 ++++++ .../local/WearMusicDatabaseMigrationTest.kt | 90 ++++++++++++++++++- .../pixelplay/data/WearTransferRepository.kt | 10 ++- .../data/local/LocalPlaylistSongCrossRef.kt | 6 ++ .../pixelplay/data/local/WearMusicDatabase.kt | 14 ++- .../screens/LocalPlaylistDetailScreen.kt | 12 ++- .../viewmodel/WearLocalPlaylistViewModel.kt | 11 ++- .../WearTransferRepositoryPlaylistSyncTest.kt | 35 ++++++++ .../WearLocalPlaylistViewModelTest.kt | 30 ++++++- 12 files changed, 268 insertions(+), 15 deletions(-) diff --git a/app/src/main/java/com/theveloper/pixelplay/data/service/wear/PlaylistWatchTransferCoordinator.kt b/app/src/main/java/com/theveloper/pixelplay/data/service/wear/PlaylistWatchTransferCoordinator.kt index 0c67288b1..58b796eed 100644 --- a/app/src/main/java/com/theveloper/pixelplay/data/service/wear/PlaylistWatchTransferCoordinator.kt +++ b/app/src/main/java/com/theveloper/pixelplay/data/service/wear/PlaylistWatchTransferCoordinator.kt @@ -138,7 +138,8 @@ class PlaylistWatchTransferCoordinator @Inject constructor( transferStateStore.retainReachableWatchNodes(nodes.map { it.id }.toSet()) WatchTransferForegroundService.start(application) - sendPlaylistSyncToNodes(nodes, playlistId, playlistName, songIds) + val songTitles = resolveSongTitlesInOrder(songIds) + sendPlaylistSyncToNodes(nodes, playlistId, playlistName, songIds, songTitles) val alreadyPresentCount = songIds.count { transferStateStore.isSongSavedOnAllReachableWatches(it) } repeat(alreadyPresentCount) { transferStateStore.markBatchSongCompleted(batchId) } @@ -189,8 +190,9 @@ class PlaylistWatchTransferCoordinator @Inject constructor( playlistId: String, playlistName: String, songIds: List, + songTitles: List, ) { - val syncPayload = json.encodeToString(WearPlaylistSync(playlistId, playlistName, songIds)) + val syncPayload = json.encodeToString(WearPlaylistSync(playlistId, playlistName, songIds, songTitles)) .toByteArray(Charsets.UTF_8) nodes.forEach { node -> try { @@ -203,6 +205,16 @@ class PlaylistWatchTransferCoordinator @Inject constructor( } } + /** + * Titles for [songIds], same order, "" for any id the library doesn't resolve — purely + * cosmetic (lets the watch show a real name instead of a raw id for a song still awaiting + * transfer), so a missing title here is never a reason to fail or delay the sync. + */ + private suspend fun resolveSongTitlesInOrder(songIds: List): List { + val songsById = musicRepository.getSongsByIds(songIds).first().associateBy { it.id } + return songIds.map { songId -> songsById[songId]?.title.orEmpty() } + } + /** * Retries [song] once after a transient failure, with a short backoff. Real hardware * testing showed a song can legitimately fail (watch-side idle watchdog closing a live but diff --git a/app/src/test/java/com/theveloper/pixelplay/data/service/wear/PlaylistWatchTransferCoordinatorTest.kt b/app/src/test/java/com/theveloper/pixelplay/data/service/wear/PlaylistWatchTransferCoordinatorTest.kt index 82f034525..f3d72a14a 100644 --- a/app/src/test/java/com/theveloper/pixelplay/data/service/wear/PlaylistWatchTransferCoordinatorTest.kt +++ b/app/src/test/java/com/theveloper/pixelplay/data/service/wear/PlaylistWatchTransferCoordinatorTest.kt @@ -10,6 +10,8 @@ import com.google.android.gms.wearable.Node import com.google.common.truth.Truth.assertThat import com.theveloper.pixelplay.data.model.Song import com.theveloper.pixelplay.data.repository.MusicRepository +import com.theveloper.pixelplay.shared.WearDataPaths +import com.theveloper.pixelplay.shared.WearPlaylistSync import com.theveloper.pixelplay.shared.WearTransferProgress import io.mockk.Runs import io.mockk.coEvery @@ -22,6 +24,8 @@ import java.nio.file.Files import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.json.Json import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test @@ -36,6 +40,7 @@ import org.junit.jupiter.api.Test class PlaylistWatchTransferCoordinatorTest { private val application = mockk(relaxed = true) + private val json = Json { ignoreUnknownKeys = true } private val musicRepository = mockk() private val watchAudioTranscoder = mockk() private val directTransferCoordinator = mockk(relaxed = true) @@ -171,6 +176,27 @@ class PlaylistWatchTransferCoordinatorTest { assertThat(transferredSongIdsInOrder).containsExactly("s3", "s1", "s2").inOrder() } + @Test + fun `the playlist sync sent to the watch carries song titles in the same order as ids`() = runTest { + stubReachableNodes("node-1") + stubTransfersResolveTo(WearTransferProgress.STATUS_COMPLETED) + song("s3", title = "Third"); song("s1", title = "First"); song("s2", title = "Second") + val syncPayloads = mutableListOf() + every { messageClient.sendMessage(any(), WearDataPaths.PLAYLIST_SYNC, any()) } answers { + val bytes = thirdArg() + syncPayloads += json.decodeFromString(String(bytes, Charsets.UTF_8)) + Tasks.forResult(0) + } + val coordinator = buildCoordinator(this) + + coordinator.requestPlaylistTransfer("p1", "Playlist", listOf("s3", "s1", "s2")) + advanceUntilIdle() + + assertThat(syncPayloads).hasSize(1) + assertThat(syncPayloads.single().songIds).containsExactly("s3", "s1", "s2").inOrder() + assertThat(syncPayloads.single().songTitles).containsExactly("Third", "First", "Second").inOrder() + } + @Test fun `songs already saved on every reachable watch are not re-transferred`() = runTest { stubReachableNodes("node-1") diff --git a/shared/src/main/java/com/theveloper/pixelplay/shared/WearPlaylistSync.kt b/shared/src/main/java/com/theveloper/pixelplay/shared/WearPlaylistSync.kt index b27b196ce..6836d1088 100644 --- a/shared/src/main/java/com/theveloper/pixelplay/shared/WearPlaylistSync.kt +++ b/shared/src/main/java/com/theveloper/pixelplay/shared/WearPlaylistSync.kt @@ -10,10 +10,17 @@ import kotlinx.serialization.Serializable * on each sync, independent of whether the audio for those songs has already arrived. This * lets the watch show the full playlist and its intended order immediately, while individual * songs keep streaming in afterward. + * + * [songTitles] is a parallel list to [songIds] (same index = same song) rather than a list of + * pairs, so an older watch build ignores it (via `ignoreUnknownKeys`) and an older phone build + * omitting it still deserializes cleanly on a newer watch — it's purely cosmetic (lets a song + * still awaiting transfer show its real name instead of its raw ID) and never load-bearing for + * the transfer itself. */ @Serializable data class WearPlaylistSync( val playlistId: String, val name: String, val songIds: List, + val songTitles: List = emptyList(), ) diff --git a/shared/src/test/java/com/theveloper/pixelplay/shared/WearPlaylistSyncTest.kt b/shared/src/test/java/com/theveloper/pixelplay/shared/WearPlaylistSyncTest.kt index 1d777bd2e..ad84fb40e 100644 --- a/shared/src/test/java/com/theveloper/pixelplay/shared/WearPlaylistSyncTest.kt +++ b/shared/src/test/java/com/theveloper/pixelplay/shared/WearPlaylistSyncTest.kt @@ -45,4 +45,30 @@ class WearPlaylistSyncTest { WearPlaylistSync(playlistId = "playlist-1", name = "Running mix", songIds = listOf("1")), ) } + + @Test + fun `round-trips song titles in the same order as song ids`() { + val original = WearPlaylistSync( + playlistId = "playlist-1", + name = "Running mix", + songIds = listOf("3", "1", "2"), + songTitles = listOf("Third", "First", "Second"), + ) + + val decoded = json.decodeFromString(json.encodeToString(original)) + + assertThat(decoded.songTitles).containsExactly("Third", "First", "Second").inOrder() + } + + @Test + fun `a payload from an older phone without songTitles decodes with an empty list`() { + // The mirror case of the unknown-field test above: an OLDER sender that predates this + // field entirely, not a newer one adding an extra field this receiver doesn't know yet. + val payloadWithoutTitles = + """{"playlistId":"playlist-1","name":"Running mix","songIds":["1","2"]}""" + + val decoded = json.decodeFromString(payloadWithoutTitles) + + assertThat(decoded.songTitles).isEmpty() + } } diff --git a/wear/src/androidTest/java/com/theveloper/pixelplay/data/local/WearMusicDatabaseMigrationTest.kt b/wear/src/androidTest/java/com/theveloper/pixelplay/data/local/WearMusicDatabaseMigrationTest.kt index 50dc78a52..b06ca4a3b 100644 --- a/wear/src/androidTest/java/com/theveloper/pixelplay/data/local/WearMusicDatabaseMigrationTest.kt +++ b/wear/src/androidTest/java/com/theveloper/pixelplay/data/local/WearMusicDatabaseMigrationTest.kt @@ -6,18 +6,20 @@ import androidx.room.Room import androidx.test.core.app.ApplicationProvider import androidx.test.ext.junit.runners.AndroidJUnit4 import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.flow.first import kotlinx.coroutines.test.runTest import org.junit.After import org.junit.Test import org.junit.runner.RunWith /** - * Verifies [WearMusicDatabase.MIGRATION_5_6] against a hand-built v5 database file. + * Verifies [WearMusicDatabase.MIGRATION_5_6] and [WearMusicDatabase.MIGRATION_6_7] against + * hand-built database files. * * `:wear` doesn't export Room schema JSON (`exportSchema = false`), so [androidx.room.testing.MigrationTestHelper] * — which needs those fixtures — isn't available here. Instead this builds a real on-disk SQLite - * file matching the v5 `local_songs` shape (see [LocalSongEntity]), then opens it through Room - * with the migration attached, the same way a real upgrading device would. + * file matching the source version's shape, then opens it through Room with the migration(s) + * attached, the same way a real upgrading device would. */ @RunWith(AndroidJUnit4::class) class WearMusicDatabaseMigrationTest { @@ -35,7 +37,7 @@ class WearMusicDatabaseMigrationTest { seedVersion5Database() val migratedDb = Room.databaseBuilder(context, WearMusicDatabase::class.java, dbName) - .addMigrations(WearMusicDatabase.MIGRATION_5_6) + .addMigrations(WearMusicDatabase.MIGRATION_5_6, WearMusicDatabase.MIGRATION_6_7) .build() try { @@ -55,6 +57,25 @@ class WearMusicDatabaseMigrationTest { } } + @Test + fun migrate6To7_addsPendingTitleColumnDefaultingToEmpty() = runTest { + seedVersion6DatabaseWithPlaylistSong() + + val migratedDb = Room.databaseBuilder(context, WearMusicDatabase::class.java, dbName) + .addMigrations(WearMusicDatabase.MIGRATION_6_7) + .build() + + try { + // A row written before this migration existed has no pendingTitle — the migration's + // DEFAULT '' must apply, not a NULL that Room's non-null String column would choke on. + val crossRef = migratedDb.localPlaylistDao().observePlaylistSongs("p1").first().single() + assertThat(crossRef.songId).isEqualTo("s1") + assertThat(crossRef.pendingTitle).isEmpty() + } finally { + migratedDb.close() + } + } + /** Hand-writes a v5 database file: the `local_songs` shape frozen right before this migration. */ private fun seedVersion5Database() { context.deleteDatabase(dbName) @@ -92,4 +113,65 @@ class WearMusicDatabaseMigrationTest { db.version = 5 db.close() } + + /** Hand-writes a v6 database file with one playlist and one cross-ref row, the shape frozen + * right before [WearMusicDatabase.MIGRATION_6_7] added `pendingTitle`. */ + private fun seedVersion6DatabaseWithPlaylistSong() { + context.deleteDatabase(dbName) + val dbFile = context.getDatabasePath(dbName) + dbFile.parentFile?.mkdirs() + + val db = SQLiteDatabase.openOrCreateDatabase(dbFile, null) + db.execSQL( + "CREATE TABLE local_songs (" + + "songId TEXT NOT NULL PRIMARY KEY, " + + "title TEXT NOT NULL, " + + "artist TEXT NOT NULL, " + + "album TEXT NOT NULL, " + + "albumId INTEGER NOT NULL, " + + "duration INTEGER NOT NULL, " + + "mimeType TEXT NOT NULL, " + + "fileSize INTEGER NOT NULL, " + + "bitrate INTEGER NOT NULL, " + + "sampleRate INTEGER NOT NULL, " + + "isFavorite INTEGER NOT NULL, " + + "favoriteSyncPending INTEGER NOT NULL, " + + "paletteSeedArgb INTEGER, " + + "themePaletteJson TEXT, " + + "artworkPath TEXT, " + + "localPath TEXT NOT NULL, " + + "transferredAt INTEGER NOT NULL)" + ) + db.execSQL( + "CREATE TABLE local_playlists (" + + "playlistId TEXT NOT NULL PRIMARY KEY, " + + "name TEXT NOT NULL, " + + "createdAt INTEGER NOT NULL, " + + "updatedAt INTEGER NOT NULL)" + ) + db.execSQL( + "CREATE TABLE local_playlist_songs (" + + "playlistId TEXT NOT NULL, " + + "songId TEXT NOT NULL, " + + "position INTEGER NOT NULL, " + + "PRIMARY KEY(playlistId, songId))" + ) + db.execSQL( + "CREATE INDEX IF NOT EXISTS index_local_playlist_songs_playlistId_position " + + "ON local_playlist_songs(playlistId, position)" + ) + db.execSQL( + "CREATE INDEX IF NOT EXISTS index_local_playlist_songs_songId " + + "ON local_playlist_songs(songId)" + ) + db.execSQL( + "INSERT INTO local_playlists (playlistId, name, createdAt, updatedAt) VALUES " + + "('p1', 'Road trip', 1000, 1000)" + ) + db.execSQL( + "INSERT INTO local_playlist_songs (playlistId, songId, position) VALUES ('p1', 's1', 0)" + ) + db.version = 6 + db.close() + } } diff --git a/wear/src/main/java/com/theveloper/pixelplay/data/WearTransferRepository.kt b/wear/src/main/java/com/theveloper/pixelplay/data/WearTransferRepository.kt index aaf4f0d33..e28ae974f 100644 --- a/wear/src/main/java/com/theveloper/pixelplay/data/WearTransferRepository.kt +++ b/wear/src/main/java/com/theveloper/pixelplay/data/WearTransferRepository.kt @@ -899,7 +899,15 @@ class WearTransferRepository @Inject constructor( updatedAt = now, ) val crossRefs = sync.songIds.mapIndexed { index, songId -> - LocalPlaylistSongCrossRef(playlistId = sync.playlistId, songId = songId, position = index) + LocalPlaylistSongCrossRef( + playlistId = sync.playlistId, + songId = songId, + position = index, + // songTitles is a parallel list to songIds; an older phone build omits it + // entirely (defaults to emptyList()), so this falls back to "" per song rather + // than crashing on an index that isn't there. + pendingTitle = sync.songTitles.getOrElse(index) { "" }, + ) } localPlaylistDao.upsertPlaylist(entity, crossRefs) Timber.tag(TAG).d( diff --git a/wear/src/main/java/com/theveloper/pixelplay/data/local/LocalPlaylistSongCrossRef.kt b/wear/src/main/java/com/theveloper/pixelplay/data/local/LocalPlaylistSongCrossRef.kt index cea29754d..9680f2d06 100644 --- a/wear/src/main/java/com/theveloper/pixelplay/data/local/LocalPlaylistSongCrossRef.kt +++ b/wear/src/main/java/com/theveloper/pixelplay/data/local/LocalPlaylistSongCrossRef.kt @@ -8,6 +8,11 @@ import androidx.room.Index * no foreign key to `local_songs`: a playlist syncs its full membership/order up front, before * the audio for every song has finished transferring (see `WearPlaylistSync`), so a cross-ref * routinely points at a songId that doesn't have a matching [LocalSongEntity] row yet. + * + * [pendingTitle] is a best-effort display name from that same sync, used only while the song + * hasn't arrived — once [LocalSongEntity] exists for [songId], the UI reads the real title from + * there instead. Empty if the sync that created this row predates [pendingTitle] (an older phone + * build) or otherwise didn't include it; callers fall back to showing [songId] in that case. */ @Entity( tableName = "local_playlist_songs", @@ -21,4 +26,5 @@ data class LocalPlaylistSongCrossRef( val playlistId: String, val songId: String, val position: Int, + val pendingTitle: String = "", ) diff --git a/wear/src/main/java/com/theveloper/pixelplay/data/local/WearMusicDatabase.kt b/wear/src/main/java/com/theveloper/pixelplay/data/local/WearMusicDatabase.kt index 5a05a3b37..9afd9e13d 100644 --- a/wear/src/main/java/com/theveloper/pixelplay/data/local/WearMusicDatabase.kt +++ b/wear/src/main/java/com/theveloper/pixelplay/data/local/WearMusicDatabase.kt @@ -11,7 +11,7 @@ import androidx.sqlite.db.SupportSQLiteDatabase */ @Database( entities = [LocalSongEntity::class, LocalPlaylistEntity::class, LocalPlaylistSongCrossRef::class], - version = 6, + version = 7, exportSchema = false, ) abstract class WearMusicDatabase : RoomDatabase() { @@ -71,7 +71,17 @@ abstract class WearMusicDatabase : RoomDatabase() { } } + val MIGRATION_6_7 = object : Migration(6, 7) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL( + "ALTER TABLE local_playlist_songs ADD COLUMN pendingTitle TEXT NOT NULL DEFAULT ''" + ) + } + } + /** Every migration this database has ever declared, in order — wire all of them, not just the newest. */ - val ALL_MIGRATIONS = arrayOf(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5, MIGRATION_5_6) + val ALL_MIGRATIONS = arrayOf( + MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5, MIGRATION_5_6, MIGRATION_6_7, + ) } } diff --git a/wear/src/main/java/com/theveloper/pixelplay/presentation/screens/LocalPlaylistDetailScreen.kt b/wear/src/main/java/com/theveloper/pixelplay/presentation/screens/LocalPlaylistDetailScreen.kt index 250086cf0..f62b4095a 100644 --- a/wear/src/main/java/com/theveloper/pixelplay/presentation/screens/LocalPlaylistDetailScreen.kt +++ b/wear/src/main/java/com/theveloper/pixelplay/presentation/screens/LocalPlaylistDetailScreen.kt @@ -54,6 +54,7 @@ import com.theveloper.pixelplay.presentation.viewmodel.WearPlayerViewModel fun LocalPlaylistDetailScreen( playlistId: String, title: String, + onPlaybackStarted: () -> Unit = {}, viewModel: WearLocalPlaylistViewModel = hiltViewModel(), playerViewModel: WearPlayerViewModel = hiltViewModel(), ) { @@ -130,7 +131,7 @@ fun LocalPlaylistDetailScreen( modifier = Modifier.size(18.dp), ) }, - onClick = { viewModel.playAll() }, + onClick = { viewModel.playAll(); onPlaybackStarted() }, enabled = playAllEnabled, colors = ChipDefaults.chipColors( backgroundColor = if (playAllEnabled) { @@ -169,7 +170,12 @@ fun LocalPlaylistDetailScreen( item = item, isCurrentSong = isCurrentSong, isPlayingSong = isPlayingSong, - onClick = { if (item.isAvailable) viewModel.playFrom(item.songId) }, + onClick = { + if (item.isAvailable) { + viewModel.playFrom(item.songId) + onPlaybackStarted() + } + }, ) } } @@ -199,7 +205,7 @@ private fun LocalPlaylistSongChip( ) { val palette = LocalWearPalette.current val song = item.song - val title = song?.title ?: item.songId + val title = item.displayTitle val containerColor = if (isCurrentSong) palette.surfaceContainerHighColor() else palette.surfaceContainerColor() val contentAlpha = if (item.isAvailable) 1f else 0.55f diff --git a/wear/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/WearLocalPlaylistViewModel.kt b/wear/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/WearLocalPlaylistViewModel.kt index 543d48386..5e30700e3 100644 --- a/wear/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/WearLocalPlaylistViewModel.kt +++ b/wear/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/WearLocalPlaylistViewModel.kt @@ -27,8 +27,15 @@ import kotlinx.coroutines.flow.stateIn data class WearLocalPlaylistSongItem( val songId: String, val song: LocalSongEntity?, + /** Best-effort name from the phone's playlist sync, shown only while [song] is null (still + * pending transfer) — empty if the sync that created this row predates it. */ + val pendingTitle: String = "", ) { val isAvailable: Boolean get() = song != null + + /** Real title once transferred; otherwise the phone-provided pending title; the raw + * [songId] only as a last resort, for a sync from a phone build old enough to not send one. */ + val displayTitle: String get() = song?.title ?: pendingTitle.ifBlank { songId } } /** @@ -97,7 +104,9 @@ class WearLocalPlaylistViewModel @Inject constructor( localSongDao.getAllSongs(), ) { crossRefs, allSongs -> val songsById = allSongs.associateBy { it.songId } - crossRefs.map { ref -> WearLocalPlaylistSongItem(ref.songId, songsById[ref.songId]) } + crossRefs.map { ref -> + WearLocalPlaylistSongItem(ref.songId, songsById[ref.songId], ref.pendingTitle) + } } } } diff --git a/wear/src/test/java/com/theveloper/pixelplay/data/WearTransferRepositoryPlaylistSyncTest.kt b/wear/src/test/java/com/theveloper/pixelplay/data/WearTransferRepositoryPlaylistSyncTest.kt index e081fb10b..bcc37bb53 100644 --- a/wear/src/test/java/com/theveloper/pixelplay/data/WearTransferRepositoryPlaylistSyncTest.kt +++ b/wear/src/test/java/com/theveloper/pixelplay/data/WearTransferRepositoryPlaylistSyncTest.kt @@ -159,4 +159,39 @@ class WearTransferRepositoryPlaylistSyncTest { assertThat(entitySlot.captured.playlistId).isEqualTo("p1") assertThat(entitySlot.captured.name).isEqualTo("Summer mix") } + + @Test + fun `cross-refs carry the matching pending title from the sync, by index`() = runTest { + coEvery { localPlaylistDao.getPlaylistById("p1") } returns null + val crossRefsSlot = slot>() + coEvery { localPlaylistDao.upsertPlaylist(any(), capture(crossRefsSlot)) } just Runs + + repository.onPlaylistSyncReceived( + WearPlaylistSync( + playlistId = "p1", + name = "Road trip", + songIds = listOf("s1", "s2"), + songTitles = listOf("First song", "Second song"), + ) + ) + + assertThat(crossRefsSlot.captured).containsExactly( + LocalPlaylistSongCrossRef(playlistId = "p1", songId = "s1", position = 0, pendingTitle = "First song"), + LocalPlaylistSongCrossRef(playlistId = "p1", songId = "s2", position = 1, pendingTitle = "Second song"), + ).inOrder() + } + + @Test + fun `a sync from an older phone with no songTitles falls back to an empty pending title`() = runTest { + coEvery { localPlaylistDao.getPlaylistById("p1") } returns null + val crossRefsSlot = slot>() + coEvery { localPlaylistDao.upsertPlaylist(any(), capture(crossRefsSlot)) } just Runs + + // songTitles omitted entirely — WearPlaylistSync.songTitles defaults to emptyList(). + repository.onPlaylistSyncReceived( + WearPlaylistSync(playlistId = "p1", name = "Road trip", songIds = listOf("s1")) + ) + + assertThat(crossRefsSlot.captured.single().pendingTitle).isEmpty() + } } diff --git a/wear/src/test/java/com/theveloper/pixelplay/presentation/viewmodel/WearLocalPlaylistViewModelTest.kt b/wear/src/test/java/com/theveloper/pixelplay/presentation/viewmodel/WearLocalPlaylistViewModelTest.kt index 6458ef107..9977864b0 100644 --- a/wear/src/test/java/com/theveloper/pixelplay/presentation/viewmodel/WearLocalPlaylistViewModelTest.kt +++ b/wear/src/test/java/com/theveloper/pixelplay/presentation/viewmodel/WearLocalPlaylistViewModelTest.kt @@ -150,8 +150,13 @@ class WearLocalPlaylistViewModelTest { ) } - private fun crossRef(playlistId: String, songId: String, position: Int) = - LocalPlaylistSongCrossRef(playlistId = playlistId, songId = songId, position = position) + private fun crossRef(playlistId: String, songId: String, position: Int, pendingTitle: String = "") = + LocalPlaylistSongCrossRef( + playlistId = playlistId, + songId = songId, + position = position, + pendingTitle = pendingTitle, + ) /** Subscribes long enough for `WhileSubscribed`'s forwarding coroutine to run and update * `.value` past the `stateIn` placeholder, then lets go — `.value` keeps the real result. */ @@ -220,6 +225,27 @@ class WearLocalPlaylistViewModelTest { } } + @Test + fun `displayTitle prefers the real title, then the phone's pending title, then the raw id`() = runTest { + playlistSongsFlowById["p1"] = MutableStateFlow( + listOf( + crossRef("p1", "s1", 0, pendingTitle = "Ignored once available"), + crossRef("p1", "s2", 1, pendingTitle = "Still transferring"), + crossRef("p1", "s3", 2), // no pendingTitle — an older phone's sync + ) + ) + allSongsFlow.value = listOf(song("s1")) // only s1 has actually arrived + + viewModel.loadPlaylist("p1") + viewModel.playlistSongs.test { + awaitItem() // stateIn's initial placeholder (emptyList) + val items = awaitItem() + assertThat(items.first { it.songId == "s1" }.displayTitle).isEqualTo("Title s1") + assertThat(items.first { it.songId == "s2" }.displayTitle).isEqualTo("Still transferring") + assertThat(items.first { it.songId == "s3" }.displayTitle).isEqualTo("s3") + } + } + @Test fun `a song flips from pending to available as soon as it lands, without reloading`() = runTest { playlistSongsFlowById["p1"] = MutableStateFlow(listOf(crossRef("p1", "s1", 0))) From 9535bfe30481456a6bd35abc0e66770f34874a1c Mon Sep 17 00:00:00 2001 From: PonceGL Date: Sun, 9 Aug 2026 12:27:00 -0600 Subject: [PATCH 31/40] feat(app): redesign the inline watch-transfer banner, show current song MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../presentation/screens/LibraryScreen.kt | 10 +++ .../screens/PlaylistDetailScreen.kt | 64 ++++++++++++++++--- app/src/main/res/values/strings_library.xml | 1 + 3 files changed, 65 insertions(+), 10 deletions(-) diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/LibraryScreen.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/LibraryScreen.kt index 9e92c17ac..077397337 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/LibraryScreen.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/LibraryScreen.kt @@ -480,6 +480,16 @@ private fun WatchPlaylistBatchProgressDialog( overflow = TextOverflow.Ellipsis, textAlign = TextAlign.Center ) + if (batch.status == WearTransferProgress.STATUS_TRANSFERRING && batch.currentSongTitle.isNotBlank()) { + Text( + text = stringResource(R.string.watch_transfer_current_song, batch.currentSongTitle), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + textAlign = TextAlign.Center + ) + } Text( text = stringResource(R.string.watch_transfer_bullet_step, statusText, songsText), style = MaterialTheme.typography.bodySmall, diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/PlaylistDetailScreen.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/PlaylistDetailScreen.kt index 3c610da6c..158c2c245 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/PlaylistDetailScreen.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/PlaylistDetailScreen.kt @@ -9,6 +9,7 @@ import androidx.compose.animation.animateColorAsState import androidx.compose.animation.animateContentSize import androidx.compose.animation.core.animateDpAsState import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.clickable @@ -48,6 +49,7 @@ import androidx.compose.material.icons.filled.MusicOff import androidx.compose.material.icons.filled.RemoveCircleOutline import androidx.compose.material.icons.rounded.Check import androidx.compose.material.icons.rounded.Add +import androidx.compose.material.icons.rounded.Close import androidx.compose.material.icons.rounded.DragIndicator import androidx.compose.material.icons.rounded.PlayArrow import androidx.compose.material.icons.rounded.Search @@ -139,6 +141,7 @@ import com.theveloper.pixelplay.utils.formatTotalDuration import com.theveloper.pixelplay.utils.formatListeningDurationCompact import com.theveloper.pixelplay.data.service.wear.PhoneWatchBatchTransferState import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.LinearWavyProgressIndicator import racra.compose.smooth_corner_rect_library.AbsoluteSmoothCornerShape import sh.calvin.reorderable.ReorderableItem import sh.calvin.reorderable.rememberReorderableLazyListState @@ -1309,6 +1312,7 @@ private fun PlaylistActionItem( * leave the screen (or the app) while it continues; the foreground notification (see * `WatchTransferForegroundService`) is what tracks completion once they do. */ +@OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable private fun WatchTransferProgressBanner( batch: PhoneWatchBatchTransferState, @@ -1325,17 +1329,39 @@ private fun WatchTransferProgressBanner( } } } + val animatedProgress by animateFloatAsState( + targetValue = overallProgress, + animationSpec = tween(durationMillis = 300), + label = "WatchTransferProgressBanner", + ) + // Same icon-badge + row treatment as PlaylistActionItem right below it (40dp circular badge + // on surfaceContainerHighest, 16dp horizontal padding, 18dp corner radius) — this banner is + // conceptually one more row in that same list, not a separate, unrelated status card. Row( modifier = modifier .fillMaxWidth() - .padding(horizontal = 20.dp, vertical = 8.dp) + .padding(horizontal = 16.dp, vertical = 6.dp) .clip(RoundedCornerShape(18.dp)) .background(MaterialTheme.colorScheme.surfaceContainerHigh) - .padding(horizontal = 16.dp, vertical = 12.dp), + .padding(horizontal = 16.dp, vertical = 14.dp), verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp), ) { + Box( + modifier = Modifier + .size(40.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.surfaceContainerHighest), + contentAlignment = Alignment.Center, + ) { + Icon( + painter = painterResource(R.drawable.rounded_watch_arrow_down_24), + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(20.dp), + ) + } + Spacer(modifier = Modifier.width(14.dp)) Column(modifier = Modifier.weight(1f)) { Text( text = stringResource( @@ -1343,19 +1369,37 @@ private fun WatchTransferProgressBanner( batch.processedSongCount, batch.totalSongCount, ), - style = MaterialTheme.typography.labelLarge, + style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSurface, ) - LinearProgressIndicator( - progress = { overallProgress }, + if (batch.currentSongTitle.isNotBlank()) { + Text( + text = batch.currentSongTitle, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(top = 2.dp), + ) + } + LinearWavyProgressIndicator( + progress = { animatedProgress }, modifier = Modifier .fillMaxWidth() - .padding(top = 6.dp) - .clip(CircleShape), + .padding(top = 8.dp) + .height(6.dp) + .clip(RoundedCornerShape(50)), + color = MaterialTheme.colorScheme.primary, + trackColor = MaterialTheme.colorScheme.surfaceContainerHighest, ) } - TextButton(onClick = onCancelClick) { - Text(stringResource(R.string.watch_transfer_action_cancel), maxLines = 1, overflow = TextOverflow.Ellipsis) + Spacer(modifier = Modifier.width(4.dp)) + IconButton(onClick = onCancelClick) { + Icon( + imageVector = Icons.Rounded.Close, + contentDescription = stringResource(R.string.watch_transfer_action_cancel), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) } } } diff --git a/app/src/main/res/values/strings_library.xml b/app/src/main/res/values/strings_library.xml index 539617f73..7ee28af33 100644 --- a/app/src/main/res/values/strings_library.xml +++ b/app/src/main/res/values/strings_library.xml @@ -270,6 +270,7 @@ %1$s / %2$s Shows live progress for phone-to-watch music transfers Watch transfers + Sending: %1$s Sending to Watch Cancelled Transfer cancelled From a4ada86e615a3671b2926c1c6dbbe911e0708e22 Mon Sep 17 00:00:00 2001 From: PonceGL Date: Sun, 9 Aug 2026 12:27:06 -0600 Subject: [PATCH 32/40] feat(wear): jump straight to the player after starting playback 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. --- .../pixelplay/presentation/WearNavigation.kt | 12 ++++++++++++ .../presentation/screens/DownloadsScreen.kt | 3 +++ 2 files changed, 15 insertions(+) diff --git a/wear/src/main/java/com/theveloper/pixelplay/presentation/WearNavigation.kt b/wear/src/main/java/com/theveloper/pixelplay/presentation/WearNavigation.kt index a918647dd..fbd749de5 100644 --- a/wear/src/main/java/com/theveloper/pixelplay/presentation/WearNavigation.kt +++ b/wear/src/main/java/com/theveloper/pixelplay/presentation/WearNavigation.kt @@ -77,6 +77,16 @@ fun WearNavigation() { } } } + // Starting playback from deep in Downloads/Playlists is otherwise a lot of swipes-back to + // reach the transport controls — jump straight there instead, clearing everything in + // between so a swipe-back from Player lands on Player's own dismiss behavior, not back + // through the browse stack. + val navigateToPlayer: () -> Unit = { + navController.navigate(WearScreens.PLAYER) { + popUpTo(WearScreens.PLAYER) { inclusive = true } + launchSingleTop = true + } + } SwipeDismissableNavHost( navController = navController, @@ -152,6 +162,7 @@ fun WearNavigation() { launchSingleTop = true } }, + onPlaybackStarted = navigateToPlayer, ) } @@ -179,6 +190,7 @@ fun WearNavigation() { LocalPlaylistDetailScreen( playlistId = playlistId, title = title, + onPlaybackStarted = navigateToPlayer, ) } diff --git a/wear/src/main/java/com/theveloper/pixelplay/presentation/screens/DownloadsScreen.kt b/wear/src/main/java/com/theveloper/pixelplay/presentation/screens/DownloadsScreen.kt index b2aa48d7b..3d09ea9f8 100644 --- a/wear/src/main/java/com/theveloper/pixelplay/presentation/screens/DownloadsScreen.kt +++ b/wear/src/main/java/com/theveloper/pixelplay/presentation/screens/DownloadsScreen.kt @@ -83,6 +83,7 @@ import kotlinx.coroutines.flow.collect @Composable fun DownloadsScreen( onPlaylistsClick: () -> Unit = {}, + onPlaybackStarted: () -> Unit = {}, viewModel: WearDownloadsViewModel = hiltViewModel(), playerViewModel: WearPlayerViewModel = hiltViewModel(), ) { @@ -584,10 +585,12 @@ fun DownloadsScreen( onPlayOnWatch = { viewModel.playLocalSong(menuSong.songId) selectedLocalSongForMenu = null + onPlaybackStarted() }, onPlayOnPhone = { viewModel.playSongOnPhone(menuSong.songId) selectedLocalSongForMenu = null + onPlaybackStarted() }, onDeleteFromWatch = { selectedLocalSongForMenu = null From 72d4657c2f9d14780475b9b3c00a1cf8e270ced8 Mon Sep 17 00:00:00 2001 From: PonceGL Date: Sun, 9 Aug 2026 12:27:11 -0600 Subject: [PATCH 33/40] chore(i18n): backfill Spanish translations for the watch-transfer feature MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- app/src/main/res/values-es/strings_library.xml | 4 ++++ app/src/main/res/values-es/strings_screens.xml | 13 +++++++++++++ wear/src/main/res/values-es/strings_wear.xml | 8 ++++++++ 3 files changed, 25 insertions(+) diff --git a/app/src/main/res/values-es/strings_library.xml b/app/src/main/res/values-es/strings_library.xml index f32199d42..e5d248d72 100644 --- a/app/src/main/res/values-es/strings_library.xml +++ b/app/src/main/res/values-es/strings_library.xml @@ -265,9 +265,12 @@ Cancelar transferencia + %1$d canciones fallidas + %1$d de %2$d canciones %1$s / %2$s Muestra el progreso en tiempo real de las transferencias de música del teléfono al reloj Transferencias al reloj + Enviando: %1$s Enviando al reloj Cancelado Transferencia cancelada @@ -281,6 +284,7 @@ Preparando transferencia al reloj Preparando transferencia… Enviando %1$d canciones al reloj + Enviando \"%1$s\" al reloj Enviando al reloj Iniciando transferencia… Iniciando diff --git a/app/src/main/res/values-es/strings_screens.xml b/app/src/main/res/values-es/strings_screens.xml index 59751905c..b1337c874 100644 --- a/app/src/main/res/values-es/strings_screens.xml +++ b/app/src/main/res/values-es/strings_screens.xml @@ -126,6 +126,19 @@ Quitar canciones Reordenar Reordenar canciones + Enviar al reloj + Actualizar en el reloj + Enviar lista al reloj + ¿Enviar \"%1$s\" a tu reloj? + ¿Actualizar \"%1$s\" en tu reloj? + %1$d de %2$d canciones por enviar + %1$d canciones + %1$s · unos %2$s + Enviar + Actualizar + Ningún reloj conectado + Enviando \"%1$s\" a tu reloj + No se pudo iniciar la transferencia: %1$s Transiciones globales diff --git a/wear/src/main/res/values-es/strings_wear.xml b/wear/src/main/res/values-es/strings_wear.xml index f0b981686..777675c1b 100644 --- a/wear/src/main/res/values-es/strings_wear.xml +++ b/wear/src/main/res/values-es/strings_wear.xml @@ -14,6 +14,14 @@ Escaneando almacenamiento del reloj… Reintentar escaneo No se encontraron canciones locales + Listas de reproducción + Aún no se ha enviado ninguna lista desde tu teléfono + Recibiendo… + Listas de reproducción + %1$d de %2$d disponibles + Reproducir todo + Esta lista no tiene canciones + Esperando transferencia… Reproduciendo Actual Más opciones From 495fc19bc7b947a23c0b9b52252ca6c04cf5fe44 Mon Sep 17 00:00:00 2001 From: PonceGL Date: Mon, 10 Aug 2026 17:53:27 -0600 Subject: [PATCH 34/40] perf(wear): cut cold-start decode cost and tune buffering for the watch's RAM MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../pixelplay/data/WearLoadControlProfile.kt | 41 ++++++++++++++++ .../pixelplay/data/WearPlaybackService.kt | 34 +++++++++++++ .../data/WearLoadControlProfileTest.kt | 49 +++++++++++++++++++ 3 files changed, 124 insertions(+) create mode 100644 wear/src/main/java/com/theveloper/pixelplay/data/WearLoadControlProfile.kt create mode 100644 wear/src/test/java/com/theveloper/pixelplay/data/WearLoadControlProfileTest.kt diff --git a/wear/src/main/java/com/theveloper/pixelplay/data/WearLoadControlProfile.kt b/wear/src/main/java/com/theveloper/pixelplay/data/WearLoadControlProfile.kt new file mode 100644 index 000000000..111fbfd9f --- /dev/null +++ b/wear/src/main/java/com/theveloper/pixelplay/data/WearLoadControlProfile.kt @@ -0,0 +1,41 @@ +package com.theveloper.pixelplay.data + +/** ExoPlayer [androidx.media3.exoplayer.DefaultLoadControl] buffer durations (ms). */ +internal data class WearLoadControlBufferProfile( + val minBufferMs: Int, + val maxBufferMs: Int, + val bufferForPlaybackMs: Int, + val bufferForPlaybackAfterRebufferMs: Int, +) + +/** + * Picks the [androidx.media3.exoplayer.DefaultLoadControl] buffer profile for + * [WearPlaybackService]'s player. + * + * Mirrors the phone's `DualPlayerEngine.buildAdaptiveLoadControl()` RAM tiering — the same + * reasoning applies here, amplified: a Wear OS SoC has far less RAM and CPU headroom than even + * a low-end phone, and it's sharing both with whatever fitness/health app the user has running + * at the same time (confirmed on-device: the app's own process was recycled during a system-wide + * low-memory episode while a workout tracker ran alongside local playback). ExoPlayer's default + * buffer window wasn't sized for that, and measured on-device it produced sustained + * PLAYING/BUFFERING oscillation. [android.app.ActivityManager.isLowRamDevice] is the same signal + * the phone already uses to pick its conservative tier, so this reuses it rather than inventing a + * new watch-specific threshold with no evidence behind it. + */ +internal fun wearLoadControlBufferProfileFor(isLowRamDevice: Boolean): WearLoadControlBufferProfile { + return if (isLowRamDevice) { + WearLoadControlBufferProfile( + minBufferMs = 15_000, + maxBufferMs = 30_000, + bufferForPlaybackMs = 2_500, + bufferForPlaybackAfterRebufferMs = 5_000, + ) + } else { + WearLoadControlBufferProfile( + minBufferMs = 30_000, + maxBufferMs = 60_000, + bufferForPlaybackMs = 2_500, + bufferForPlaybackAfterRebufferMs = 5_000, + ) + } +} diff --git a/wear/src/main/java/com/theveloper/pixelplay/data/WearPlaybackService.kt b/wear/src/main/java/com/theveloper/pixelplay/data/WearPlaybackService.kt index b273ddf6b..72c8d787b 100644 --- a/wear/src/main/java/com/theveloper/pixelplay/data/WearPlaybackService.kt +++ b/wear/src/main/java/com/theveloper/pixelplay/data/WearPlaybackService.kt @@ -1,11 +1,16 @@ package com.theveloper.pixelplay.data +import android.app.ActivityManager import android.app.PendingIntent import android.content.Intent import androidx.media3.common.AudioAttributes import androidx.media3.common.C import androidx.media3.common.MediaItem +import androidx.media3.exoplayer.DefaultLoadControl import androidx.media3.exoplayer.ExoPlayer +import androidx.media3.exoplayer.source.DefaultMediaSourceFactory +import androidx.media3.extractor.mp4.Mp4Extractor +import androidx.media3.extractor.text.SubtitleParser import androidx.media3.session.MediaSession import androidx.media3.session.MediaSessionService import com.google.common.util.concurrent.Futures @@ -32,7 +37,23 @@ class WearPlaybackService : MediaSessionService() { override fun onCreate() { super.onCreate() + val isLowRamDevice = getSystemService(ActivityManager::class.java)?.isLowRamDevice == true + val bufferProfile = wearLoadControlBufferProfileFor(isLowRamDevice) + val loadControl = DefaultLoadControl.Builder() + .setBufferDurationsMs( + bufferProfile.minBufferMs, + bufferProfile.maxBufferMs, + bufferProfile.bufferForPlaybackMs, + bufferProfile.bufferForPlaybackAfterRebufferMs, + ) + // Buffered *duration*, not buffered *bytes*, decides when to (re)start playback — + // matches the phone's DualPlayerEngine and is what makes the profile above meaningful + // across formats/bitrates instead of being overridden by ExoPlayer's byte threshold. + .setPrioritizeTimeOverSizeThresholds(true) + .build() + val exoPlayer = ExoPlayer.Builder(this) + .setLoadControl(loadControl) .setAudioAttributes( AudioAttributes.Builder() .setUsage(C.USAGE_MEDIA) @@ -44,6 +65,19 @@ class WearPlaybackService : MediaSessionService() { // Keep the CPU running while the watch dozes with the screen off, otherwise audio // decoding stalls a few seconds after the display turns off. .setWakeMode(C.WAKE_MODE_LOCAL) + // The default DefaultMediaSourceFactory registers ~15 extractor types (Matroska, + // FLV, AVI, MPEG-TS…) that this service never plays — every file here comes from + // [WatchAudioTranscoder] on the phone, which always writes plain (non-fragmented) + // MP4/AAC-LC. 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. + // Scoping the factory to the one extractor we actually need removes that cost entirely. + .setMediaSourceFactory( + // Every other Mp4Extractor constructor/factory in this media3 version is + // deprecated in favor of newFactory(SubtitleParser.Factory); our files are + // audio-only, so subtitle parsing is simply unsupported. + DefaultMediaSourceFactory(this, Mp4Extractor.newFactory(SubtitleParser.Factory.UNSUPPORTED)) + ) .build() player = exoPlayer diff --git a/wear/src/test/java/com/theveloper/pixelplay/data/WearLoadControlProfileTest.kt b/wear/src/test/java/com/theveloper/pixelplay/data/WearLoadControlProfileTest.kt new file mode 100644 index 000000000..e75e7a63e --- /dev/null +++ b/wear/src/test/java/com/theveloper/pixelplay/data/WearLoadControlProfileTest.kt @@ -0,0 +1,49 @@ +package com.theveloper.pixelplay.data + +import com.google.common.truth.Truth.assertThat +import org.junit.jupiter.api.Test + +class WearLoadControlProfileTest { + + @Test + fun normalDevice_usesFullPrefetchProfile() { + val profile = wearLoadControlBufferProfileFor(isLowRamDevice = false) + + assertThat(profile.minBufferMs).isEqualTo(30_000) + assertThat(profile.maxBufferMs).isEqualTo(60_000) + assertThat(profile.bufferForPlaybackMs).isEqualTo(2_500) + assertThat(profile.bufferForPlaybackAfterRebufferMs).isEqualTo(5_000) + } + + @Test + fun lowRamDevice_cutsPrefetchWindow() { + val normal = wearLoadControlBufferProfileFor(isLowRamDevice = false) + val lowRam = wearLoadControlBufferProfileFor(isLowRamDevice = true) + + assertThat(lowRam.maxBufferMs).isLessThan(normal.maxBufferMs) + assertThat(lowRam.minBufferMs).isLessThan(normal.minBufferMs) + } + + @Test + fun lowRamDevice_keepsStartLatencyIdenticalToNormal() { + // Capping the prefetch window must not regress how quickly playback actually starts. + val normal = wearLoadControlBufferProfileFor(isLowRamDevice = false) + val lowRam = wearLoadControlBufferProfileFor(isLowRamDevice = true) + + assertThat(lowRam.bufferForPlaybackMs).isEqualTo(normal.bufferForPlaybackMs) + assertThat(lowRam.bufferForPlaybackAfterRebufferMs) + .isEqualTo(normal.bufferForPlaybackAfterRebufferMs) + } + + @Test + fun bothProfiles_satisfyDefaultLoadControlConstraints() { + for (isLowRam in listOf(false, true)) { + val profile = wearLoadControlBufferProfileFor(isLowRam) + + // DefaultLoadControl.Builder.build() asserts these; violating them crashes at runtime. + assertThat(profile.minBufferMs).isAtLeast(profile.bufferForPlaybackMs) + assertThat(profile.minBufferMs).isAtLeast(profile.bufferForPlaybackAfterRebufferMs) + assertThat(profile.maxBufferMs).isAtLeast(profile.minBufferMs) + } + } +} From c243e5bb2c7d8b5bee9f0813521a6297001a8ca6 Mon Sep 17 00:00:00 2001 From: PonceGL Date: Mon, 10 Aug 2026 18:00:08 -0600 Subject: [PATCH 35/40] feat(wear): request audio offload for local playback, with a HAL-reset fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../pixelplay/data/WearAudioOffloadPolicy.kt | 34 ++++ .../pixelplay/data/WearPlaybackService.kt | 158 ++++++++++++++++-- .../data/WearAudioOffloadPolicyTest.kt | 87 ++++++++++ 3 files changed, 261 insertions(+), 18 deletions(-) create mode 100644 wear/src/main/java/com/theveloper/pixelplay/data/WearAudioOffloadPolicy.kt create mode 100644 wear/src/test/java/com/theveloper/pixelplay/data/WearAudioOffloadPolicyTest.kt diff --git a/wear/src/main/java/com/theveloper/pixelplay/data/WearAudioOffloadPolicy.kt b/wear/src/main/java/com/theveloper/pixelplay/data/WearAudioOffloadPolicy.kt new file mode 100644 index 000000000..ed0e8c92f --- /dev/null +++ b/wear/src/main/java/com/theveloper/pixelplay/data/WearAudioOffloadPolicy.kt @@ -0,0 +1,34 @@ +package com.theveloper.pixelplay.data + +/** + * Decides whether a stall shortly after audio-offload playback started should be read as an + * offload HAL reset and trigger falling back to normal (non-offload) playback for the rest of + * the session. + * + * Mirrors the phone's `DualPlayerEngine.shouldDisableAudioOffloadOnEarlyBuffering` — same + * pattern, simplified for [WearPlaybackService]'s single-player service (the phone's version + * also guards against its dual-player crossfade transitions, which don't exist here). Wear OS + * has no equivalent yet to the phone's per-OEM offload denylist + * (`shouldDisableAudioOffloadByDefaultForDevice` in `:app`) — there is no field evidence of which + * watch chipsets misbehave with offload, so [WearPlaybackService] always requests it and relies + * entirely on this runtime safety net instead of guessing at a denylist with no evidence behind + * it. + * + * The buffering is NOT treated as a HAL reset when it's explained by a recent user seek + * ([isPostSeekBuffering]) or a track change ([isPostMediaItemTransition]) — in those cases + * buffering is expected, and falling back would needlessly rebuild the player (an audible + * glitch) for no reason. + */ +internal fun wearShouldFallBackFromAudioOffload( + audioOffloadEnabled: Boolean, + lastPlayingAtMs: Long, + timeSincePlayingMs: Long, + isPostSeekBuffering: Boolean, + isPostMediaItemTransition: Boolean, +): Boolean { + return audioOffloadEnabled && + lastPlayingAtMs > 0L && + timeSincePlayingMs < 500L && + !isPostSeekBuffering && + !isPostMediaItemTransition +} diff --git a/wear/src/main/java/com/theveloper/pixelplay/data/WearPlaybackService.kt b/wear/src/main/java/com/theveloper/pixelplay/data/WearPlaybackService.kt index 72c8d787b..866b168df 100644 --- a/wear/src/main/java/com/theveloper/pixelplay/data/WearPlaybackService.kt +++ b/wear/src/main/java/com/theveloper/pixelplay/data/WearPlaybackService.kt @@ -3,9 +3,12 @@ package com.theveloper.pixelplay.data import android.app.ActivityManager import android.app.PendingIntent import android.content.Intent +import android.os.SystemClock import androidx.media3.common.AudioAttributes import androidx.media3.common.C import androidx.media3.common.MediaItem +import androidx.media3.common.Player +import androidx.media3.common.TrackSelectionParameters import androidx.media3.exoplayer.DefaultLoadControl import androidx.media3.exoplayer.ExoPlayer import androidx.media3.exoplayer.source.DefaultMediaSourceFactory @@ -34,9 +37,46 @@ class WearPlaybackService : MediaSessionService() { private var player: ExoPlayer? = null private var mediaSession: MediaSession? = null + // --- Audio offload state ------------------------------------------------------------- + // AUDIO_OFFLOAD_MODE_ENABLED (as opposed to _REQUIRED) is a *soft* request: if the watch's + // audio HAL doesn't support offloading this format, ExoPlayer silently falls back to the + // normal decode path on its own — no capability probing needed on our side for that case. + // What ExoPlayer *doesn't* handle on its own is a HAL that accepts the offloaded track but + // then resets/stalls shortly after — that failure mode is exactly what motivated the phone's + // DualPlayerEngine to build a runtime fallback (see AudioOffloadPolicyTest in :app), so this + // service mirrors that safety net rather than assuming Wear OS audio HALs are better-behaved. + private var audioOffloadEnabled = true + private var lastPlayingAtMs = 0L + private var isPostSeekBuffering = false + private var isPostMediaItemTransition = false + override fun onCreate() { super.onCreate() + player = buildExoPlayer() + mediaSession = buildMediaSession(player!!) + Timber.tag(TAG).d("WearPlaybackService created") + } + + override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaSession? = mediaSession + + override fun onTaskRemoved(rootIntent: Intent?) { + // If the user swipes the app away while nothing is playing, there's nothing to keep alive. + val activePlayer = player + if (activePlayer == null || !activePlayer.playWhenReady || activePlayer.mediaItemCount == 0) { + stopSelf() + } + } + override fun onDestroy() { + mediaSession?.release() + mediaSession = null + player?.release() + player = null + Timber.tag(TAG).d("WearPlaybackService destroyed") + super.onDestroy() + } + + private fun buildExoPlayer(): ExoPlayer { val isLowRamDevice = getSystemService(ActivityManager::class.java)?.isLowRamDevice == true val bufferProfile = wearLoadControlBufferProfileFor(isLowRamDevice) val loadControl = DefaultLoadControl.Builder() @@ -79,34 +119,72 @@ class WearPlaybackService : MediaSessionService() { DefaultMediaSourceFactory(this, Mp4Extractor.newFactory(SubtitleParser.Factory.UNSUPPORTED)) ) .build() - player = exoPlayer + exoPlayer.trackSelectionParameters = trackSelectionParametersFor(audioOffloadEnabled) + exoPlayer.addListener(AudioOffloadFallbackListener()) + return exoPlayer + } - mediaSession = MediaSession.Builder(this, exoPlayer) + private fun trackSelectionParametersFor(offloadEnabled: Boolean): TrackSelectionParameters { + return TrackSelectionParameters.DEFAULT.buildUpon() + .setAudioOffloadPreferences( + TrackSelectionParameters.AudioOffloadPreferences.Builder() + .setAudioOffloadMode( + if (offloadEnabled) { + TrackSelectionParameters.AudioOffloadPreferences.AUDIO_OFFLOAD_MODE_ENABLED + } else { + TrackSelectionParameters.AudioOffloadPreferences.AUDIO_OFFLOAD_MODE_DISABLED + } + ) + .setIsGaplessSupportRequired(false) + .setIsSpeedChangeSupportRequired(false) + .build() + ) + .build() + } + + private fun buildMediaSession(exoPlayer: ExoPlayer): MediaSession { + return MediaSession.Builder(this, exoPlayer) .setId(MEDIA_SESSION_ID) .setSessionActivity(buildOpenAppIntent()) .setCallback(MediaItemUriRestoringCallback()) .build() - - Timber.tag(TAG).d("WearPlaybackService created") } - override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaSession? = mediaSession + /** + * Rebuilds the player with offload disabled after [AudioOffloadFallbackListener] reads a + * stall as a HAL reset, preserving playback state across the swap. + * + * [MediaSession.setPlayer] lets the existing session (and any connected `MediaController`, + * including the phone acting as a remote) keep its binder connection across the swap instead + * of tearing down and reconnecting — the rebuild is invisible to callers beyond a brief + * re-buffer. + */ + private fun fallBackFromAudioOffload(reason: String) { + if (!audioOffloadEnabled) return + val oldPlayer = player ?: return + audioOffloadEnabled = false + Timber.tag(TAG).w("Falling back from audio offload: %s", reason) - override fun onTaskRemoved(rootIntent: Intent?) { - // If the user swipes the app away while nothing is playing, there's nothing to keep alive. - val activePlayer = player - if (activePlayer == null || !activePlayer.playWhenReady || activePlayer.mediaItemCount == 0) { - stopSelf() + val mediaItems = ArrayList(oldPlayer.mediaItemCount) + for (i in 0 until oldPlayer.mediaItemCount) mediaItems.add(oldPlayer.getMediaItemAt(i)) + val currentIndex = oldPlayer.currentMediaItemIndex.coerceAtLeast(0) + val positionMs = oldPlayer.currentPosition.coerceAtLeast(0L) + val playWhenReady = oldPlayer.playWhenReady + val repeatMode = oldPlayer.repeatMode + val shuffleModeEnabled = oldPlayer.shuffleModeEnabled + + val newPlayer = buildExoPlayer() + if (mediaItems.isNotEmpty()) { + newPlayer.setMediaItems(mediaItems, currentIndex, positionMs) + newPlayer.repeatMode = repeatMode + newPlayer.shuffleModeEnabled = shuffleModeEnabled + newPlayer.prepare() + newPlayer.playWhenReady = playWhenReady } - } - override fun onDestroy() { - mediaSession?.release() - mediaSession = null - player?.release() - player = null - Timber.tag(TAG).d("WearPlaybackService destroyed") - super.onDestroy() + player = newPlayer + mediaSession?.setPlayer(newPlayer) + oldPlayer.release() } private fun buildOpenAppIntent(): PendingIntent { @@ -121,6 +199,50 @@ class WearPlaybackService : MediaSessionService() { ) } + /** + * Watches for the early-buffering pattern [wearShouldFallBackFromAudioOffload] recognizes as + * an offload HAL reset, and triggers [fallBackFromAudioOffload] when it does. + */ + private inner class AudioOffloadFallbackListener : Player.Listener { + + override fun onIsPlayingChanged(isPlaying: Boolean) { + if (isPlaying) { + lastPlayingAtMs = SystemClock.elapsedRealtime() + isPostSeekBuffering = false + isPostMediaItemTransition = false + } + } + + override fun onPositionDiscontinuity( + oldPosition: Player.PositionInfo, + newPosition: Player.PositionInfo, + reason: Int, + ) { + if (reason == Player.DISCONTINUITY_REASON_SEEK) { + isPostSeekBuffering = true + } + } + + override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) { + isPostMediaItemTransition = true + } + + override fun onPlaybackStateChanged(playbackState: Int) { + if (playbackState != Player.STATE_BUFFERING) return + val now = SystemClock.elapsedRealtime() + val shouldFallBack = wearShouldFallBackFromAudioOffload( + audioOffloadEnabled = audioOffloadEnabled, + lastPlayingAtMs = lastPlayingAtMs, + timeSincePlayingMs = now - lastPlayingAtMs, + isPostSeekBuffering = isPostSeekBuffering, + isPostMediaItemTransition = isPostMediaItemTransition, + ) + if (shouldFallBack) { + fallBackFromAudioOffload("early re-buffer ${now - lastPlayingAtMs}ms after playing") + } + } + } + /** * A `MediaController` strips [MediaItem.localConfiguration] (the playable URI) when it hands * items across the binder to this service. The repository stashes the original URI in diff --git a/wear/src/test/java/com/theveloper/pixelplay/data/WearAudioOffloadPolicyTest.kt b/wear/src/test/java/com/theveloper/pixelplay/data/WearAudioOffloadPolicyTest.kt new file mode 100644 index 000000000..98c7fdc54 --- /dev/null +++ b/wear/src/test/java/com/theveloper/pixelplay/data/WearAudioOffloadPolicyTest.kt @@ -0,0 +1,87 @@ +package com.theveloper.pixelplay.data + +import com.google.common.truth.Truth.assertThat +import org.junit.jupiter.api.Test + +class WearAudioOffloadPolicyTest { + + @Test + fun earlyBuffering_fallsBackForGenuineHalReset() { + val shouldFallBack = wearShouldFallBackFromAudioOffload( + audioOffloadEnabled = true, + lastPlayingAtMs = 1_000L, + timeSincePlayingMs = 120L, + isPostSeekBuffering = false, + isPostMediaItemTransition = false, + ) + + assertThat(shouldFallBack).isTrue() + } + + @Test + fun earlyBuffering_doesNotFallBackWhenOffloadIsAlreadyDisabled() { + val shouldFallBack = wearShouldFallBackFromAudioOffload( + audioOffloadEnabled = false, + lastPlayingAtMs = 1_000L, + timeSincePlayingMs = 120L, + isPostSeekBuffering = false, + isPostMediaItemTransition = false, + ) + + assertThat(shouldFallBack).isFalse() + } + + @Test + fun earlyBuffering_doesNotFallBackRightAfterASeek() { + val shouldFallBack = wearShouldFallBackFromAudioOffload( + audioOffloadEnabled = true, + lastPlayingAtMs = 1_000L, + timeSincePlayingMs = 120L, + isPostSeekBuffering = true, + isPostMediaItemTransition = false, + ) + + assertThat(shouldFallBack).isFalse() + } + + @Test + fun earlyBuffering_doesNotFallBackRightAfterATrackChange() { + val shouldFallBack = wearShouldFallBackFromAudioOffload( + audioOffloadEnabled = true, + lastPlayingAtMs = 1_000L, + timeSincePlayingMs = 120L, + isPostSeekBuffering = false, + isPostMediaItemTransition = true, + ) + + assertThat(shouldFallBack).isFalse() + } + + @Test + fun earlyBuffering_doesNotFallBackAfterLongSteadyPlayback() { + val shouldFallBack = wearShouldFallBackFromAudioOffload( + audioOffloadEnabled = true, + lastPlayingAtMs = 1_000L, + timeSincePlayingMs = 5_000L, + isPostSeekBuffering = false, + isPostMediaItemTransition = false, + ) + + assertThat(shouldFallBack).isFalse() + } + + @Test + fun earlyBuffering_doesNotFallBackBeforeAnyPlaybackEverStarted() { + // lastPlayingAtMs == 0L means playback never reached PLAYING yet — the very first + // buffer-up on cold start is not an offload HAL reset. + val shouldFallBack = wearShouldFallBackFromAudioOffload( + audioOffloadEnabled = true, + lastPlayingAtMs = 0L, + timeSincePlayingMs = 120L, + isPostSeekBuffering = false, + isPostMediaItemTransition = false, + ) + + assertThat(shouldFallBack).isFalse() + } +} From 50a38786dffc84878eaa77c6fd8a4bb6287b0954 Mon Sep 17 00:00:00 2001 From: PonceGL Date: Mon, 10 Aug 2026 20:50:19 -0600 Subject: [PATCH 36/40] perf(wear): audit :wear for lifecycle-unaware recomposition during playback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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?). --- .../presentation/WearMainActivity.kt | 8 +-- .../presentation/components/PlayingEqIcon.kt | 6 +- .../presentation/screens/DownloadsScreen.kt | 18 +++--- .../presentation/screens/LibraryListScreen.kt | 4 +- .../presentation/screens/MoreScreen.kt | 16 +++--- .../presentation/screens/OutputScreen.kt | 16 +++--- .../presentation/screens/PlayerScreen.kt | 57 +++++++++++++------ .../presentation/screens/QueueScreen.kt | 16 +++--- .../presentation/screens/SongListScreen.kt | 10 ++-- .../presentation/screens/TimerScreen.kt | 8 +-- .../presentation/screens/VolumeScreen.kt | 8 +-- 11 files changed, 96 insertions(+), 71 deletions(-) diff --git a/wear/src/main/java/com/theveloper/pixelplay/presentation/WearMainActivity.kt b/wear/src/main/java/com/theveloper/pixelplay/presentation/WearMainActivity.kt index 8f60b8dce..c633916ca 100644 --- a/wear/src/main/java/com/theveloper/pixelplay/presentation/WearMainActivity.kt +++ b/wear/src/main/java/com/theveloper/pixelplay/presentation/WearMainActivity.kt @@ -3,8 +3,8 @@ package com.theveloper.pixelplay.presentation import android.os.Bundle import androidx.activity.compose.setContent import androidx.compose.runtime.getValue -import androidx.compose.runtime.collectAsState import androidx.fragment.app.FragmentActivity +import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.wear.ambient.AmbientLifecycleObserver import com.theveloper.pixelplay.data.WearLifecycleState @@ -39,9 +39,9 @@ class WearMainActivity : FragmentActivity() { setContent { val playerViewModel: WearPlayerViewModel = hiltViewModel() - val albumArt by playerViewModel.albumArt.collectAsState() - val paletteSeedArgb by playerViewModel.paletteSeedArgb.collectAsState() - val themePalette by playerViewModel.themePalette.collectAsState() + val albumArt by playerViewModel.albumArt.collectAsStateWithLifecycle() + val paletteSeedArgb by playerViewModel.paletteSeedArgb.collectAsStateWithLifecycle() + val themePalette by playerViewModel.themePalette.collectAsStateWithLifecycle() WearPixelPlayTheme( albumArt = albumArt, diff --git a/wear/src/main/java/com/theveloper/pixelplay/presentation/components/PlayingEqIcon.kt b/wear/src/main/java/com/theveloper/pixelplay/presentation/components/PlayingEqIcon.kt index e43e81b86..96f00e2ee 100644 --- a/wear/src/main/java/com/theveloper/pixelplay/presentation/components/PlayingEqIcon.kt +++ b/wear/src/main/java/com/theveloper/pixelplay/presentation/components/PlayingEqIcon.kt @@ -8,7 +8,6 @@ import androidx.compose.animation.core.tween import androidx.compose.foundation.Canvas import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Modifier @@ -16,6 +15,7 @@ import androidx.compose.ui.geometry.CornerRadius import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.Color +import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.theveloper.pixelplay.data.WearLifecycleState import kotlinx.coroutines.isActive import kotlin.math.PI @@ -36,8 +36,8 @@ fun PlayingEqIcon( val fullRotation = (2f * PI).toFloat() val phaseAnim = remember { Animatable(0f) } val wanderAnim = remember { Animatable(0f) } - val isInteractive by WearLifecycleState.isInteractive.collectAsState( - initial = WearLifecycleState.isInteractiveNow, + val isInteractive by WearLifecycleState.isInteractive.collectAsStateWithLifecycle( + initialValue = WearLifecycleState.isInteractiveNow, ) val animate = isPlaying && isInteractive diff --git a/wear/src/main/java/com/theveloper/pixelplay/presentation/screens/DownloadsScreen.kt b/wear/src/main/java/com/theveloper/pixelplay/presentation/screens/DownloadsScreen.kt index 3d09ea9f8..2ad9135d7 100644 --- a/wear/src/main/java/com/theveloper/pixelplay/presentation/screens/DownloadsScreen.kt +++ b/wear/src/main/java/com/theveloper/pixelplay/presentation/screens/DownloadsScreen.kt @@ -29,7 +29,6 @@ import androidx.compose.material.icons.rounded.Refresh import androidx.compose.material.icons.rounded.Security import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -48,6 +47,7 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.zIndex +import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.core.content.ContextCompat import androidx.wear.compose.material.Chip @@ -87,14 +87,14 @@ fun DownloadsScreen( viewModel: WearDownloadsViewModel = hiltViewModel(), playerViewModel: WearPlayerViewModel = hiltViewModel(), ) { - val localSongs by viewModel.localSongs.collectAsState() - val activeTransfers by viewModel.activeTransfers.collectAsState() - val deviceSongs by viewModel.deviceSongs.collectAsState() - val isDeviceLibraryLoading by viewModel.isDeviceLibraryLoading.collectAsState() - val deviceLibraryError by viewModel.deviceLibraryError.collectAsState() - val pendingPhonePlaybackSongId by viewModel.pendingPhonePlaybackSongId.collectAsState() - val playerState by playerViewModel.playerState.collectAsState() - val isPhoneConnected by playerViewModel.isPhoneConnected.collectAsState() + val localSongs by viewModel.localSongs.collectAsStateWithLifecycle() + val activeTransfers by viewModel.activeTransfers.collectAsStateWithLifecycle() + val deviceSongs by viewModel.deviceSongs.collectAsStateWithLifecycle() + val isDeviceLibraryLoading by viewModel.isDeviceLibraryLoading.collectAsStateWithLifecycle() + val deviceLibraryError by viewModel.deviceLibraryError.collectAsStateWithLifecycle() + val pendingPhonePlaybackSongId by viewModel.pendingPhonePlaybackSongId.collectAsStateWithLifecycle() + val playerState by playerViewModel.playerState.collectAsStateWithLifecycle() + val isPhoneConnected by playerViewModel.isPhoneConnected.collectAsStateWithLifecycle() val palette = LocalWearPalette.current val watchLibraryTitleFont = rememberWatchLibraryTitleFont() val columnState = rememberResponsiveColumnState() diff --git a/wear/src/main/java/com/theveloper/pixelplay/presentation/screens/LibraryListScreen.kt b/wear/src/main/java/com/theveloper/pixelplay/presentation/screens/LibraryListScreen.kt index 1dbedfb69..24e85d4a9 100644 --- a/wear/src/main/java/com/theveloper/pixelplay/presentation/screens/LibraryListScreen.kt +++ b/wear/src/main/java/com/theveloper/pixelplay/presentation/screens/LibraryListScreen.kt @@ -10,7 +10,6 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -21,6 +20,7 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.zIndex +import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.wear.compose.material.Chip import androidx.wear.compose.material.ChipDefaults @@ -59,7 +59,7 @@ fun LibraryListScreen( onItemClick: (item: WearLibraryItem, subBrowseType: String, itemTitle: String) -> Unit, viewModel: WearBrowseViewModel = hiltViewModel(), ) { - val uiState by viewModel.uiState.collectAsState() + val uiState by viewModel.uiState.collectAsStateWithLifecycle() val palette = LocalWearPalette.current val subscreenTitleFont = rememberBrowseSubscreenTitleFont() diff --git a/wear/src/main/java/com/theveloper/pixelplay/presentation/screens/MoreScreen.kt b/wear/src/main/java/com/theveloper/pixelplay/presentation/screens/MoreScreen.kt index 2ba0eb1a4..d38b921fb 100644 --- a/wear/src/main/java/com/theveloper/pixelplay/presentation/screens/MoreScreen.kt +++ b/wear/src/main/java/com/theveloper/pixelplay/presentation/screens/MoreScreen.kt @@ -27,7 +27,6 @@ import androidx.compose.material.icons.rounded.Shuffle import androidx.compose.material.icons.rounded.SkipNext import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -39,6 +38,7 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.zIndex +import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.wear.compose.material.Chip import androidx.wear.compose.material.ChipDefaults @@ -72,13 +72,13 @@ fun MoreScreen( val palette = LocalWearPalette.current val columnState = rememberResponsiveColumnState() - val playerState by playerViewModel.playerState.collectAsState() - val isPhoneConnected by playerViewModel.isPhoneConnected.collectAsState() - val isWatchOutputSelected by playerViewModel.isWatchOutputSelected.collectAsState() - val canCurrentSongBeFavorited by playerViewModel.canCurrentSongBeFavorited.collectAsState() - val queueState by browseViewModel.uiState.collectAsState() - val downloadedSongIds by downloadsViewModel.downloadedSongIds.collectAsState() - val activeTransfers by downloadsViewModel.activeTransfers.collectAsState() + val playerState by playerViewModel.playerState.collectAsStateWithLifecycle() + val isPhoneConnected by playerViewModel.isPhoneConnected.collectAsStateWithLifecycle() + val isWatchOutputSelected by playerViewModel.isWatchOutputSelected.collectAsStateWithLifecycle() + val canCurrentSongBeFavorited by playerViewModel.canCurrentSongBeFavorited.collectAsStateWithLifecycle() + val queueState by browseViewModel.uiState.collectAsStateWithLifecycle() + val downloadedSongIds by downloadsViewModel.downloadedSongIds.collectAsStateWithLifecycle() + val activeTransfers by downloadsViewModel.activeTransfers.collectAsStateWithLifecycle() LaunchedEffect(isPhoneConnected, isWatchOutputSelected) { if (isPhoneConnected && !isWatchOutputSelected) { diff --git a/wear/src/main/java/com/theveloper/pixelplay/presentation/screens/OutputScreen.kt b/wear/src/main/java/com/theveloper/pixelplay/presentation/screens/OutputScreen.kt index ec907c442..a3a19d749 100644 --- a/wear/src/main/java/com/theveloper/pixelplay/presentation/screens/OutputScreen.kt +++ b/wear/src/main/java/com/theveloper/pixelplay/presentation/screens/OutputScreen.kt @@ -14,7 +14,6 @@ import androidx.compose.material.icons.rounded.Watch import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import android.content.Context import androidx.compose.ui.Alignment @@ -27,6 +26,7 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.zIndex +import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.wear.compose.material.Chip import androidx.wear.compose.material.ChipDefaults @@ -55,13 +55,13 @@ import kotlinx.coroutines.delay fun OutputScreen( viewModel: WearPlayerViewModel = hiltViewModel(), ) { - val outputTarget by viewModel.outputTarget.collectAsState() - val isPhoneConnected by viewModel.isPhoneConnected.collectAsState() - val canCurrentSongPlayOnWatch by viewModel.canCurrentSongPlayOnWatch.collectAsState() - val playerState by viewModel.playerState.collectAsState() - val phoneVolumeState by viewModel.phoneVolumeState.collectAsState() - val watchAudioRoutes by viewModel.watchAudioRoutes.collectAsState() - val watchVolumeState by viewModel.watchVolumeState.collectAsState() + val outputTarget by viewModel.outputTarget.collectAsStateWithLifecycle() + val isPhoneConnected by viewModel.isPhoneConnected.collectAsStateWithLifecycle() + val canCurrentSongPlayOnWatch by viewModel.canCurrentSongPlayOnWatch.collectAsStateWithLifecycle() + val playerState by viewModel.playerState.collectAsStateWithLifecycle() + val phoneVolumeState by viewModel.phoneVolumeState.collectAsStateWithLifecycle() + val watchAudioRoutes by viewModel.watchAudioRoutes.collectAsStateWithLifecycle() + val watchVolumeState by viewModel.watchVolumeState.collectAsStateWithLifecycle() val context = LocalContext.current val palette = LocalWearPalette.current val columnState = rememberResponsiveColumnState() diff --git a/wear/src/main/java/com/theveloper/pixelplay/presentation/screens/PlayerScreen.kt b/wear/src/main/java/com/theveloper/pixelplay/presentation/screens/PlayerScreen.kt index 057842960..48a950bb8 100644 --- a/wear/src/main/java/com/theveloper/pixelplay/presentation/screens/PlayerScreen.kt +++ b/wear/src/main/java/com/theveloper/pixelplay/presentation/screens/PlayerScreen.kt @@ -48,7 +48,6 @@ import androidx.compose.material.icons.rounded.SkipPrevious import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.SideEffect -import androidx.compose.runtime.collectAsState import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf @@ -98,6 +97,7 @@ import androidx.compose.ui.unit.lerp import androidx.compose.ui.unit.sp import androidx.compose.ui.zIndex import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.wear.compose.foundation.ExperimentalWearFoundationApi import androidx.wear.compose.foundation.pager.HorizontalPager import androidx.wear.compose.foundation.pager.rememberPagerState @@ -149,12 +149,12 @@ fun PlayerScreen( onQueueClick: () -> Unit = {}, viewModel: WearPlayerViewModel = hiltViewModel(), ) { - val state by viewModel.playerState.collectAsState() - val isPhoneConnected by viewModel.isPhoneConnected.collectAsState() - val isWatchOutputSelected by viewModel.isWatchOutputSelected.collectAsState() - val activeOutputRouteType by viewModel.activeOutputRouteType.collectAsState() - val activeVolumeState by viewModel.activeVolumeState.collectAsState() - val albumArt by viewModel.albumArt.collectAsState() + val state by viewModel.playerState.collectAsStateWithLifecycle() + val isPhoneConnected by viewModel.isPhoneConnected.collectAsStateWithLifecycle() + val isWatchOutputSelected by viewModel.isWatchOutputSelected.collectAsStateWithLifecycle() + val activeOutputRouteType by viewModel.activeOutputRouteType.collectAsStateWithLifecycle() + val activeVolumeState by viewModel.activeVolumeState.collectAsStateWithLifecycle() + val albumArt by viewModel.albumArt.collectAsStateWithLifecycle() PlayerContent( state = state, @@ -194,7 +194,7 @@ private fun PlayerContent( onQueueClick: () -> Unit, ) { val palette = LocalWearPalette.current - val isAmbient by WearLifecycleState.isAmbient.collectAsState() + val isAmbient by WearLifecycleState.isAmbient.collectAsStateWithLifecycle() // Memoize: radialGradient allocates Shader inputs on every call. PlayerContent // recomposes whenever the play-button ring animation ticks, so without this // we'd churn the GC for nothing. @@ -1251,21 +1251,32 @@ private fun MainPlayerPage( private fun rememberLivePositionMs(state: WearPlayerState): androidx.compose.runtime.State { val safeDuration = state.totalDurationMs.coerceAtLeast(0L) val safeAnchorPosition = state.currentPositionMs.coerceIn(0L, safeDuration) + // WearLifecycleState's own contract: "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" — this loop wasn't wired to it. Ticking every 250ms recomposes this composable (and + // the progress ring/animation reading its value) whether or not the screen is actually being + // refreshed at that rate, competing with audio decode for CPU. `livePositionFromAnchor` + // computes from elapsed real time regardless, so freezing the display between ticks loses + // nothing: the moment isInteractive flips back on, the position snaps to the correct value. + val isInteractive by WearLifecycleState.isInteractive.collectAsStateWithLifecycle( + initialValue = WearLifecycleState.isInteractiveNow, + ) val positionKey = remember( state.songId, safeAnchorPosition, safeDuration, state.isPlaying, state.positionUpdatedElapsedRealtimeMs, + isInteractive, ) { - "${state.songId}|$safeAnchorPosition|$safeDuration|${state.isPlaying}|${state.positionUpdatedElapsedRealtimeMs}" + "${state.songId}|$safeAnchorPosition|$safeDuration|${state.isPlaying}|${state.positionUpdatedElapsedRealtimeMs}|$isInteractive" } return produceState( initialValue = state.livePositionFromAnchor(safeAnchorPosition, safeDuration), key1 = positionKey, ) { value = state.livePositionFromAnchor(safeAnchorPosition, safeDuration) - if (!state.isPlaying || safeDuration <= 0L) { + if (!state.isPlaying || safeDuration <= 0L || !isInteractive) { return@produceState } @@ -1485,6 +1496,13 @@ private fun rememberActiveLyricLineIndex( ): androidx.compose.runtime.State { val safeDuration = state.totalDurationMs.coerceAtLeast(0L) val safeAnchorPosition = state.currentPositionMs.coerceIn(0L, safeDuration) + // Same reasoning as rememberLivePositionMs: this page sits right next to the main player + // page in the HorizontalPager (beyondViewportPageCount = 1), so it can stay composed — + // and this loop ticking — while the user is looking at the main page instead. Gate it on + // WearLifecycleState.isInteractive for the same reason its own contract asks for. + val isInteractive by WearLifecycleState.isInteractive.collectAsStateWithLifecycle( + initialValue = WearLifecycleState.isInteractiveNow, + ) val positionKey = remember( state.songId, safeAnchorPosition, @@ -1492,8 +1510,9 @@ private fun rememberActiveLyricLineIndex( state.isPlaying, state.positionUpdatedElapsedRealtimeMs, lines, + isInteractive, ) { - "${state.songId}|$safeAnchorPosition|$safeDuration|${state.isPlaying}|${state.positionUpdatedElapsedRealtimeMs}|${lines.size}|${lines.firstOrNull()?.timeMs}|${lines.lastOrNull()?.timeMs}" + "${state.songId}|$safeAnchorPosition|$safeDuration|${state.isPlaying}|${state.positionUpdatedElapsedRealtimeMs}|${lines.size}|${lines.firstOrNull()?.timeMs}|${lines.lastOrNull()?.timeMs}|$isInteractive" } return produceState( @@ -1507,9 +1526,15 @@ private fun rememberActiveLyricLineIndex( return@produceState } + val livePositionMs = state.livePositionFromAnchor(safeAnchorPosition, safeDuration) + value = lines.activeLyricLineIndex(livePositionMs) + if (!isInteractive) { + return@produceState + } + while (true) { - val livePositionMs = state.livePositionFromAnchor(safeAnchorPosition, safeDuration) - val currentIndex = lines.activeLyricLineIndex(livePositionMs) + val currentLivePositionMs = state.livePositionFromAnchor(safeAnchorPosition, safeDuration) + val currentIndex = lines.activeLyricLineIndex(currentLivePositionMs) value = currentIndex if (!state.isPlaying || safeDuration <= 0L) { @@ -1518,7 +1543,7 @@ private fun rememberActiveLyricLineIndex( val nextLineTimeMs = lines.getOrNull(currentIndex + 1)?.timeMs?.toLong() ?: return@produceState - val delayUntilNextLine = (nextLineTimeMs - livePositionMs) + val delayUntilNextLine = (nextLineTimeMs - currentLivePositionMs) .coerceIn(80L, 60_000L) delay(delayUntilNextLine) } @@ -1746,8 +1771,8 @@ private fun CenterPlayButton( label = "playStarCurve", ) val rotation = remember { Animatable(0f) } - val isInteractive by WearLifecycleState.isInteractive.collectAsState( - initial = WearLifecycleState.isInteractiveNow, + val isInteractive by WearLifecycleState.isInteractive.collectAsStateWithLifecycle( + initialValue = WearLifecycleState.isInteractiveNow, ) LaunchedEffect(isPlaying, isInteractive) { if (!isPlaying || !isInteractive) { diff --git a/wear/src/main/java/com/theveloper/pixelplay/presentation/screens/QueueScreen.kt b/wear/src/main/java/com/theveloper/pixelplay/presentation/screens/QueueScreen.kt index 5350f4828..e80375d61 100644 --- a/wear/src/main/java/com/theveloper/pixelplay/presentation/screens/QueueScreen.kt +++ b/wear/src/main/java/com/theveloper/pixelplay/presentation/screens/QueueScreen.kt @@ -19,7 +19,6 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -32,6 +31,7 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.zIndex +import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.wear.compose.material.Chip import androidx.wear.compose.material.ChipDefaults @@ -70,13 +70,13 @@ fun QueueScreen( browseViewModel: WearBrowseViewModel = hiltViewModel(), ) { val palette = LocalWearPalette.current - val playerState by viewModel.playerState.collectAsState() - val localQueueState by viewModel.localQueueState.collectAsState() - val isLocalPlaybackActive by viewModel.isLocalPlaybackActive.collectAsState() - val uiState by browseViewModel.uiState.collectAsState() - val isPhoneConnected by viewModel.isPhoneConnected.collectAsState() - val isWatchOutputSelected by viewModel.isWatchOutputSelected.collectAsState() - val timerState by viewModel.sleepTimerUiState.collectAsState() + val playerState by viewModel.playerState.collectAsStateWithLifecycle() + val localQueueState by viewModel.localQueueState.collectAsStateWithLifecycle() + val isLocalPlaybackActive by viewModel.isLocalPlaybackActive.collectAsStateWithLifecycle() + val uiState by browseViewModel.uiState.collectAsStateWithLifecycle() + val isPhoneConnected by viewModel.isPhoneConnected.collectAsStateWithLifecycle() + val isWatchOutputSelected by viewModel.isWatchOutputSelected.collectAsStateWithLifecycle() + val timerState by viewModel.sleepTimerUiState.collectAsStateWithLifecycle() val showingLocalQueue = isWatchOutputSelected val remoteControlsEnabled = isPhoneConnected && !isWatchOutputSelected diff --git a/wear/src/main/java/com/theveloper/pixelplay/presentation/screens/SongListScreen.kt b/wear/src/main/java/com/theveloper/pixelplay/presentation/screens/SongListScreen.kt index 68e685c1b..c604d3aa4 100644 --- a/wear/src/main/java/com/theveloper/pixelplay/presentation/screens/SongListScreen.kt +++ b/wear/src/main/java/com/theveloper/pixelplay/presentation/screens/SongListScreen.kt @@ -14,7 +14,6 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -29,6 +28,7 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.zIndex +import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.wear.compose.material.Chip import androidx.wear.compose.material.ChipDefaults @@ -80,10 +80,10 @@ fun SongListScreen( downloadsViewModel: WearDownloadsViewModel = hiltViewModel(), playerViewModel: WearPlayerViewModel = hiltViewModel(), ) { - val uiState by viewModel.uiState.collectAsState() - val downloadedIds by downloadsViewModel.downloadedSongIds.collectAsState() - val activeTransfers by downloadsViewModel.activeTransfers.collectAsState() - val playerState by playerViewModel.playerState.collectAsState() + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + val downloadedIds by downloadsViewModel.downloadedSongIds.collectAsStateWithLifecycle() + val activeTransfers by downloadsViewModel.activeTransfers.collectAsStateWithLifecycle() + val playerState by playerViewModel.playerState.collectAsStateWithLifecycle() val palette = LocalWearPalette.current val subscreenTitleFont = rememberBrowseSubscreenTitleFont() var selectedSongForMenu by remember { mutableStateOf(null) } diff --git a/wear/src/main/java/com/theveloper/pixelplay/presentation/screens/TimerScreen.kt b/wear/src/main/java/com/theveloper/pixelplay/presentation/screens/TimerScreen.kt index adcfe5592..4f4182e33 100644 --- a/wear/src/main/java/com/theveloper/pixelplay/presentation/screens/TimerScreen.kt +++ b/wear/src/main/java/com/theveloper/pixelplay/presentation/screens/TimerScreen.kt @@ -11,7 +11,6 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.runtime.Composable -import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -23,6 +22,7 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.compose.ui.zIndex +import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.wear.compose.material.Chip import androidx.wear.compose.material.ChipDefaults @@ -52,9 +52,9 @@ fun TimerScreen( ) { val context = LocalContext.current val palette = LocalWearPalette.current - val timerState by viewModel.sleepTimerUiState.collectAsState() - val isPhoneConnected by viewModel.isPhoneConnected.collectAsState() - val isWatchOutputSelected by viewModel.isWatchOutputSelected.collectAsState() + val timerState by viewModel.sleepTimerUiState.collectAsStateWithLifecycle() + val isPhoneConnected by viewModel.isPhoneConnected.collectAsStateWithLifecycle() + val isWatchOutputSelected by viewModel.isWatchOutputSelected.collectAsStateWithLifecycle() val enabled = isPhoneConnected && !isWatchOutputSelected val columnState = rememberResponsiveColumnState() diff --git a/wear/src/main/java/com/theveloper/pixelplay/presentation/screens/VolumeScreen.kt b/wear/src/main/java/com/theveloper/pixelplay/presentation/screens/VolumeScreen.kt index b84f6388f..bdcdc1c19 100644 --- a/wear/src/main/java/com/theveloper/pixelplay/presentation/screens/VolumeScreen.kt +++ b/wear/src/main/java/com/theveloper/pixelplay/presentation/screens/VolumeScreen.kt @@ -28,7 +28,6 @@ import androidx.compose.material.icons.rounded.Remove import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Alignment @@ -41,6 +40,7 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.ui.zIndex +import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.wear.compose.foundation.ExperimentalWearFoundationApi import androidx.wear.compose.foundation.requestFocusOnHierarchyActive @@ -65,9 +65,9 @@ fun VolumeScreen( viewModel: WearPlayerViewModel = hiltViewModel(), ) { val palette = LocalWearPalette.current - val volumeState by viewModel.activeVolumeState.collectAsState() - val volumePercent by viewModel.activeVolumePercent.collectAsState() - val activeDeviceName by viewModel.activeVolumeDeviceName.collectAsState() + val volumeState by viewModel.activeVolumeState.collectAsStateWithLifecycle() + val volumePercent by viewModel.activeVolumePercent.collectAsStateWithLifecycle() + val activeDeviceName by viewModel.activeVolumeDeviceName.collectAsStateWithLifecycle() // Enable MediaRouter discovery while this screen is visible so the // route-callback path in WearVolumeRepository pushes updates reactively. From e69019b55197a179b599f72ecab2e26b27c534dd Mon Sep 17 00:00:00 2001 From: PonceGL Date: Mon, 10 Aug 2026 20:58:42 -0600 Subject: [PATCH 37/40] feat(wear): persist and restore local playback across process death MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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-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. --- wear/build.gradle.kts | 4 + .../data/WearLocalPlayerRepository.kt | 85 ++++++++++++++ .../data/WearPlaybackStatePersistence.kt | 100 +++++++++++++++++ .../com/theveloper/pixelplay/di/WearModule.kt | 8 ++ .../viewmodel/WearPlayerViewModel.kt | 12 ++ .../data/WearPlaybackStatePersistenceTest.kt | 105 ++++++++++++++++++ .../WearPlaybackStateRestorabilityTest.kt | 85 ++++++++++++++ .../WearTransferRepositoryPlaylistSyncTest.kt | 2 +- .../WearLocalPlaylistViewModelTest.kt | 3 +- 9 files changed, 402 insertions(+), 2 deletions(-) create mode 100644 wear/src/main/java/com/theveloper/pixelplay/data/WearPlaybackStatePersistence.kt create mode 100644 wear/src/test/java/com/theveloper/pixelplay/data/WearPlaybackStatePersistenceTest.kt create mode 100644 wear/src/test/java/com/theveloper/pixelplay/data/WearPlaybackStateRestorabilityTest.kt diff --git a/wear/build.gradle.kts b/wear/build.gradle.kts index 12c3d0aa1..8d8d1f500 100644 --- a/wear/build.gradle.kts +++ b/wear/build.gradle.kts @@ -119,6 +119,10 @@ dependencies { // Serialization implementation(libs.kotlinx.serialization.json) + // DataStore (persisting local playback state across process death — see + // WearPlaybackStatePersistence) + implementation(libs.androidx.datastore.preferences) + // Image loading implementation(libs.coil.compose) diff --git a/wear/src/main/java/com/theveloper/pixelplay/data/WearLocalPlayerRepository.kt b/wear/src/main/java/com/theveloper/pixelplay/data/WearLocalPlayerRepository.kt index 70ae97e6a..d4b4f4b49 100644 --- a/wear/src/main/java/com/theveloper/pixelplay/data/WearLocalPlayerRepository.kt +++ b/wear/src/main/java/com/theveloper/pixelplay/data/WearLocalPlayerRepository.kt @@ -81,6 +81,7 @@ data class WearQueueSong( class WearLocalPlayerRepository @Inject constructor( private val application: Application, private val localSongDao: LocalSongDao, + private val playbackStatePersistence: WearPlaybackStatePersistence, ) { private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main) private val json = Json { ignoreUnknownKeys = true } @@ -116,6 +117,7 @@ class WearLocalPlayerRepository @Inject constructor( companion object { private const val TAG = "WearLocalPlayer" private const val POSITION_UPDATE_INTERVAL_MS = 1000L + private const val PERSIST_INTERVAL_TICKS = 10 } init { @@ -142,16 +144,21 @@ class WearLocalPlayerRepository @Inject constructor( updateState() if (playbackState == Player.STATE_ENDED) { stopPositionUpdates() + // Nothing left to resume — clear rather than leave a stale "restore" prompt + // pointing at a queue that already finished. + clearPersistedPlaybackState() } } override fun onIsPlayingChanged(isPlaying: Boolean) { updateState() + persistCurrentPlaybackState() if (isPlaying) startPositionUpdates() else stopPositionUpdates() } override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) { updateState() + persistCurrentPlaybackState() } } @@ -472,6 +479,7 @@ class WearLocalPlayerRepository @Inject constructor( */ fun release() { stopPositionUpdates() + clearPersistedPlaybackState() mediaController?.let { controller -> controller.removeListener(playerListener) runCatching { @@ -536,6 +544,7 @@ class WearLocalPlayerRepository @Inject constructor( private fun startPositionUpdates() { positionUpdateJob?.cancel() positionUpdateJob = scope.launch { + var ticksSinceLastPersist = 0 while (isActive) { // Skip the StateFlow churn when the user can't see the UI: the // ExoPlayer keeps tracking position internally, we just don't @@ -545,11 +554,87 @@ class WearLocalPlayerRepository @Inject constructor( if (WearLifecycleState.isInteractiveNow) { updateState() } + // Coarser than the 1s UI tick: a DataStore write every second would be real, + // pointless disk I/O on a device this battery-constrained. Losing up to + // PERSIST_INTERVAL_TICKS seconds of position on a crash is an acceptable + // trade — onIsPlayingChanged/onMediaItemTransition already persist immediately + // on the events that matter most (a pause or a track change right before a + // crash won't be lost). + ticksSinceLastPersist++ + if (ticksSinceLastPersist >= PERSIST_INTERVAL_TICKS) { + ticksSinceLastPersist = 0 + persistCurrentPlaybackState() + } delay(POSITION_UPDATE_INTERVAL_MS) } } } + private fun persistCurrentPlaybackState() { + val player = mediaController ?: return + if (currentQueueSongIds.isEmpty()) return + val snapshot = PersistedLocalPlaybackState( + queueSongIds = currentQueueSongIds, + currentIndex = player.currentMediaItemIndex, + positionMs = player.currentPosition, + updatedAtMillis = System.currentTimeMillis(), + ) + scope.launch { + runCatching { playbackStatePersistence.save(snapshot) } + .onFailure { error -> Timber.tag(TAG).w(error, "Failed to persist local playback state") } + } + } + + private fun clearPersistedPlaybackState() { + scope.launch { + runCatching { playbackStatePersistence.clear() } + .onFailure { error -> Timber.tag(TAG).w(error, "Failed to clear persisted local playback state") } + } + } + + /** + * Restores a persisted queue, paused, if one exists and is still fresh enough + * ([isPersistedLocalPlaybackStateRestorable]) — the recovery path for a process that died + * mid-playback (see this class's KDoc). Paused rather than auto-playing: starting audio + * without a fresh user gesture on app open would be surprising, especially for headphones + * that may no longer even be in the user's ears. + * + * Safe to call unconditionally on startup: a no-op if nothing is local-playback-active + * to restore, and it never overwrites an already-active queue. + */ + suspend fun restorePersistedPlaybackIfAvailable(): Boolean { + if (_isLocalPlaybackActive.value) return false + val persisted = playbackStatePersistence.read() ?: return false + if (!isPersistedLocalPlaybackStateRestorable(persisted, System.currentTimeMillis())) { + playbackStatePersistence.clear() + return false + } + + val songsById = persisted.queueSongIds + .mapNotNull { songId -> localSongDao.getSongById(songId) } + .associateBy { it.songId } + // Songs may have been deleted from the watch since the snapshot was taken (storage + // pressure, the user removing a download) — only resume the ones that are still there, + // in their original relative order. + val playableSongs = persisted.queueSongIds.mapNotNull { songsById[it] } + if (playableSongs.isEmpty()) { + playbackStatePersistence.clear() + return false + } + + val originalIndexSongId = persisted.queueSongIds.getOrNull(persisted.currentIndex) + val restoredIndex = playableSongs.indexOfFirst { it.songId == originalIndexSongId } + .let { if (it >= 0) it else 0 } + + playLocalSongs( + songs = playableSongs, + startIndex = restoredIndex, + startPositionMs = persisted.positionMs, + autoPlay = false, + ) + return true + } + private fun stopPositionUpdates() { positionUpdateJob?.cancel() positionUpdateJob = null diff --git a/wear/src/main/java/com/theveloper/pixelplay/data/WearPlaybackStatePersistence.kt b/wear/src/main/java/com/theveloper/pixelplay/data/WearPlaybackStatePersistence.kt new file mode 100644 index 000000000..c81509839 --- /dev/null +++ b/wear/src/main/java/com/theveloper/pixelplay/data/WearPlaybackStatePersistence.kt @@ -0,0 +1,100 @@ +package com.theveloper.pixelplay.data + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.preferencesDataStore +import javax.inject.Inject +import javax.inject.Singleton +import kotlinx.coroutines.flow.first +import kotlinx.serialization.Serializable +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import timber.log.Timber + +/** Watch-side DataStore, separate from the phone's — the two processes never share one file. */ +val Context.wearDataStore: DataStore by preferencesDataStore(name = "wear_settings") + +/** + * The watch's local-playback queue and position, in just enough detail to restore it, paused, + * after the hosting process dies mid-playback. + * + * Confirmed on-device this session, not a theoretical concern: 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 to begin with. + */ +@Serializable +data class PersistedLocalPlaybackState( + val queueSongIds: List, + val currentIndex: Int, + val positionMs: Long, + val updatedAtMillis: Long, +) + +/** + * Persists at most one in-flight local-playback queue — mirrors + * `PlaylistBatchTransferPersistence` in `:app` (same DataStore-backed, single-slot, + * JSON-via-kotlinx.serialization shape), adapted to the watch's own DataStore since `:wear` and + * `:app` are separate processes with no shared storage. + */ +@Singleton +class WearPlaybackStatePersistence @Inject constructor( + private val dataStore: DataStore, +) { + private val json = Json { ignoreUnknownKeys = true } + + suspend fun save(state: PersistedLocalPlaybackState) { + dataStore.edit { preferences -> + preferences[Keys.LOCAL_PLAYBACK_STATE] = json.encodeToString(state) + } + } + + suspend fun clear() { + dataStore.edit { preferences -> preferences.remove(Keys.LOCAL_PLAYBACK_STATE) } + } + + suspend fun read(): PersistedLocalPlaybackState? { + val stored = dataStore.data.first()[Keys.LOCAL_PLAYBACK_STATE] ?: return null + return try { + json.decodeFromString(stored) + } catch (e: Exception) { + Timber.tag(TAG).w(e, "Failed to decode persisted local playback state, discarding it") + null + } + } + + private object Keys { + val LOCAL_PLAYBACK_STATE = stringPreferencesKey("wear_local_playback_state_v1") + } + + private companion object { + const val TAG = "WearPlaybackPersist" + } +} + +/** + * Whether a persisted queue is still worth restoring. + * + * Requires at least one song id (an empty queue is nothing to resume) and caps how stale the + * snapshot can be: recovering from a crash a few minutes or hours ago is the point of this + * (§R-06-adjacent — the phone-side batch-transfer persistence uses the same "was genuinely + * in-flight" reasoning); silently resurrecting whatever was playing days ago the next time the + * app happens to open would be surprising rather than helpful. There's no on-device data to + * calibrate the exact cutoff, so this picks a conservative, generously-long window instead of a + * precisely-tuned one. + */ +internal fun isPersistedLocalPlaybackStateRestorable( + state: PersistedLocalPlaybackState, + nowMillis: Long, + maxAgeMillis: Long = 6 * 60 * 60 * 1000L, +): Boolean { + if (state.queueSongIds.isEmpty()) return false + if (state.currentIndex !in state.queueSongIds.indices) return false + val ageMillis = nowMillis - state.updatedAtMillis + return ageMillis in 0..maxAgeMillis +} diff --git a/wear/src/main/java/com/theveloper/pixelplay/di/WearModule.kt b/wear/src/main/java/com/theveloper/pixelplay/di/WearModule.kt index 7f78b39d1..6f3c6cd44 100644 --- a/wear/src/main/java/com/theveloper/pixelplay/di/WearModule.kt +++ b/wear/src/main/java/com/theveloper/pixelplay/di/WearModule.kt @@ -1,6 +1,8 @@ package com.theveloper.pixelplay.di import android.app.Application +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences import androidx.room.Room import com.google.android.gms.wearable.ChannelClient import com.google.android.gms.wearable.DataClient @@ -10,6 +12,7 @@ import com.google.android.gms.wearable.Wearable import com.theveloper.pixelplay.data.local.LocalPlaylistDao import com.theveloper.pixelplay.data.local.LocalSongDao import com.theveloper.pixelplay.data.local.WearMusicDatabase +import com.theveloper.pixelplay.data.wearDataStore import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -60,4 +63,9 @@ object WearModule { @Singleton fun provideLocalPlaylistDao(database: WearMusicDatabase): LocalPlaylistDao = database.localPlaylistDao() + + @Provides + @Singleton + fun provideDataStore(application: Application): DataStore = + application.wearDataStore } diff --git a/wear/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/WearPlayerViewModel.kt b/wear/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/WearPlayerViewModel.kt index 3a6038e81..1d37720b0 100644 --- a/wear/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/WearPlayerViewModel.kt +++ b/wear/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/WearPlayerViewModel.kt @@ -200,6 +200,18 @@ class WearPlayerViewModel @Inject constructor( }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false) init { + viewModelScope.launch { + // Recovers a queue/position that survived a process death mid-playback (see + // WearLocalPlayerRepository's KDoc) — a no-op whenever nothing was persisted, which + // is the overwhelming majority of app opens. `outputTarget` itself isn't persisted + // (WearStateRepository always starts at PHONE), so a successful restore is the + // signal that the user actually was on watch-local playback; it wouldn't otherwise + // be visible in `playerState` until switched here. + val restored = localPlayerRepository.restorePersistedPlaybackIfAvailable() + if (restored) { + stateRepository.setOutputTarget(WearOutputTarget.WATCH) + } + } viewModelScope.launch { outputTarget.collect { refreshActiveVolumeState() diff --git a/wear/src/test/java/com/theveloper/pixelplay/data/WearPlaybackStatePersistenceTest.kt b/wear/src/test/java/com/theveloper/pixelplay/data/WearPlaybackStatePersistenceTest.kt new file mode 100644 index 000000000..5a0f2dbe8 --- /dev/null +++ b/wear/src/test/java/com/theveloper/pixelplay/data/WearPlaybackStatePersistenceTest.kt @@ -0,0 +1,105 @@ +package com.theveloper.pixelplay.data + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import com.google.common.truth.Truth.assertThat +import java.nio.file.Files +import java.nio.file.Path +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test + +class WearPlaybackStatePersistenceTest { + + // Same reasoning as PlaylistBatchTransferPersistenceTest in :app: DataStore's internal + // write-actor needs a scope that outlives any single test method's own runTest {} block. + private lateinit var dataStoreScope: CoroutineScope + private lateinit var tempDir: Path + private lateinit var dataStore: DataStore + private lateinit var persistence: WearPlaybackStatePersistence + + @BeforeEach + fun setUp() { + tempDir = Files.createTempDirectory("wear-playback-state-persistence-test") + dataStoreScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + dataStore = PreferenceDataStoreFactory.create( + scope = dataStoreScope, + produceFile = { tempDir.resolve("settings.preferences_pb").toFile() }, + ) + persistence = WearPlaybackStatePersistence(dataStore) + } + + @AfterEach + fun tearDown() { + dataStoreScope.cancel() + tempDir.toFile().deleteRecursively() + } + + private fun state( + queueSongIds: List = listOf("s1", "s2"), + currentIndex: Int = 0, + positionMs: Long = 12_345L, + updatedAtMillis: Long = 1_000L, + ) = PersistedLocalPlaybackState( + queueSongIds = queueSongIds, + currentIndex = currentIndex, + positionMs = positionMs, + updatedAtMillis = updatedAtMillis, + ) + + @Test + fun `nothing persisted returns null`() = runTest { + assertThat(persistence.read()).isNull() + } + + @Test + fun `save then read round-trips the state`() = runTest { + val saved = state() + persistence.save(saved) + + assertThat(persistence.read()).isEqualTo(saved) + } + + @Test + fun `saving again overwrites the previous state`() = runTest { + persistence.save(state(currentIndex = 0, positionMs = 1_000L)) + persistence.save(state(currentIndex = 1, positionMs = 5_000L)) + + val read = persistence.read() + assertThat(read?.currentIndex).isEqualTo(1) + assertThat(read?.positionMs).isEqualTo(5_000L) + } + + @Test + fun `clearing removes the stored state`() = runTest { + persistence.save(state()) + + persistence.clear() + + assertThat(persistence.read()).isNull() + } + + @Test + fun `clearing when nothing is stored does not throw`() = runTest { + persistence.clear() + + assertThat(persistence.read()).isNull() + } + + @Test + fun `malformed stored data is treated as nothing persisted, not a crash`() = runTest { + dataStore.edit { preferences -> + preferences[stringPreferencesKey("wear_local_playback_state_v1")] = "{not valid json" + } + + assertThat(persistence.read()).isNull() + } +} diff --git a/wear/src/test/java/com/theveloper/pixelplay/data/WearPlaybackStateRestorabilityTest.kt b/wear/src/test/java/com/theveloper/pixelplay/data/WearPlaybackStateRestorabilityTest.kt new file mode 100644 index 000000000..6ad25319a --- /dev/null +++ b/wear/src/test/java/com/theveloper/pixelplay/data/WearPlaybackStateRestorabilityTest.kt @@ -0,0 +1,85 @@ +package com.theveloper.pixelplay.data + +import com.google.common.truth.Truth.assertThat +import org.junit.jupiter.api.Test + +class WearPlaybackStateRestorabilityTest { + + private val oneHourMs = 60 * 60 * 1000L + + private fun state( + queueSongIds: List = listOf("s1", "s2"), + currentIndex: Int = 0, + updatedAtMillis: Long = 0L, + ) = PersistedLocalPlaybackState( + queueSongIds = queueSongIds, + currentIndex = currentIndex, + positionMs = 1_000L, + updatedAtMillis = updatedAtMillis, + ) + + @Test + fun `a recent state with a valid index is restorable`() { + val restorable = isPersistedLocalPlaybackStateRestorable( + state = state(updatedAtMillis = 0L), + nowMillis = oneHourMs, + ) + + assertThat(restorable).isTrue() + } + + @Test + fun `an empty queue is never restorable`() { + val restorable = isPersistedLocalPlaybackStateRestorable( + state = state(queueSongIds = emptyList(), currentIndex = 0, updatedAtMillis = 0L), + nowMillis = 0L, + ) + + assertThat(restorable).isFalse() + } + + @Test + fun `an out-of-range index is not restorable`() { + val restorable = isPersistedLocalPlaybackStateRestorable( + state = state(queueSongIds = listOf("s1"), currentIndex = 5, updatedAtMillis = 0L), + nowMillis = 0L, + ) + + assertThat(restorable).isFalse() + } + + @Test + fun `a state older than the max age is not restorable`() { + val maxAge = 6 * oneHourMs + val restorable = isPersistedLocalPlaybackStateRestorable( + state = state(updatedAtMillis = 0L), + nowMillis = maxAge + 1L, + maxAgeMillis = maxAge, + ) + + assertThat(restorable).isFalse() + } + + @Test + fun `a state exactly at the max age boundary is still restorable`() { + val maxAge = 6 * oneHourMs + val restorable = isPersistedLocalPlaybackStateRestorable( + state = state(updatedAtMillis = 0L), + nowMillis = maxAge, + maxAgeMillis = maxAge, + ) + + assertThat(restorable).isTrue() + } + + @Test + fun `a state with a future timestamp is not restorable`() { + // Defensive: clock skew or a corrupted timestamp shouldn't be treated as "very fresh". + val restorable = isPersistedLocalPlaybackStateRestorable( + state = state(updatedAtMillis = 10_000L), + nowMillis = 0L, + ) + + assertThat(restorable).isFalse() + } +} diff --git a/wear/src/test/java/com/theveloper/pixelplay/data/WearTransferRepositoryPlaylistSyncTest.kt b/wear/src/test/java/com/theveloper/pixelplay/data/WearTransferRepositoryPlaylistSyncTest.kt index bcc37bb53..503639715 100644 --- a/wear/src/test/java/com/theveloper/pixelplay/data/WearTransferRepositoryPlaylistSyncTest.kt +++ b/wear/src/test/java/com/theveloper/pixelplay/data/WearTransferRepositoryPlaylistSyncTest.kt @@ -60,7 +60,7 @@ class WearTransferRepositoryPlaylistSyncTest { coEvery { localPlaylistDao.upsertPlaylist(any(), any()) } just Runs val stateRepository = WearStateRepository() - val localPlayerRepository = WearLocalPlayerRepository(application, localSongDao) + val localPlayerRepository = WearLocalPlayerRepository(application, localSongDao, mockk()) val playbackController = WearPlaybackController(application, stateRepository) repository = WearTransferRepository( diff --git a/wear/src/test/java/com/theveloper/pixelplay/presentation/viewmodel/WearLocalPlaylistViewModelTest.kt b/wear/src/test/java/com/theveloper/pixelplay/presentation/viewmodel/WearLocalPlaylistViewModelTest.kt index 9977864b0..9398441dd 100644 --- a/wear/src/test/java/com/theveloper/pixelplay/presentation/viewmodel/WearLocalPlaylistViewModelTest.kt +++ b/wear/src/test/java/com/theveloper/pixelplay/presentation/viewmodel/WearLocalPlaylistViewModelTest.kt @@ -10,6 +10,7 @@ import com.theveloper.pixelplay.MainCoroutineExtension import com.theveloper.pixelplay.data.WearLocalPlayerRepository import com.theveloper.pixelplay.data.WearOutputTarget import com.theveloper.pixelplay.data.WearPlaybackController +import com.theveloper.pixelplay.data.WearPlaybackStatePersistence import com.theveloper.pixelplay.data.WearStateRepository import com.theveloper.pixelplay.data.WearTransferRepository import com.theveloper.pixelplay.data.local.LocalPlaylistDao @@ -98,7 +99,7 @@ class WearLocalPlaylistViewModelTest { coEvery { localSongDao.deleteById(any()) } just Runs stateRepository = WearStateRepository() - val localPlayerRepository = WearLocalPlayerRepository(application, localSongDao) + val localPlayerRepository = WearLocalPlayerRepository(application, localSongDao, mockk()) val playbackController = WearPlaybackController(application, stateRepository) transferRepository = WearTransferRepository( application = application, From 51d86d1a2128b0a1c34e8fe1210e927010062342 Mon Sep 17 00:00:00 2001 From: PonceGL Date: Tue, 11 Aug 2026 12:16:56 -0600 Subject: [PATCH 38/40] fix(wear): retry + ack playlist sync so it can't be silently lost MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../wear/PhoneWatchTransferStateStore.kt | 15 +++ .../wear/PlaylistWatchTransferCoordinator.kt | 83 ++++++++++++++-- .../data/service/wear/WearCommandReceiver.kt | 13 +++ .../PlaylistWatchTransferCoordinatorTest.kt | 94 ++++++++++++++++++- .../pixelplay/shared/WearDataPaths.kt | 7 ++ .../pixelplay/shared/WearPlaylistSync.kt | 7 ++ .../pixelplay/shared/WearPlaylistSyncAck.kt | 18 ++++ .../pixelplay/data/WearDataListenerService.kt | 2 +- .../pixelplay/data/WearTransferRepository.kt | 24 ++++- .../WearTransferRepositoryPlaylistSyncTest.kt | 91 ++++++++++++++++-- 10 files changed, 332 insertions(+), 22 deletions(-) create mode 100644 shared/src/main/java/com/theveloper/pixelplay/shared/WearPlaylistSyncAck.kt diff --git a/app/src/main/java/com/theveloper/pixelplay/data/service/wear/PhoneWatchTransferStateStore.kt b/app/src/main/java/com/theveloper/pixelplay/data/service/wear/PhoneWatchTransferStateStore.kt index e9a69f9c5..17da3616b 100644 --- a/app/src/main/java/com/theveloper/pixelplay/data/service/wear/PhoneWatchTransferStateStore.kt +++ b/app/src/main/java/com/theveloper/pixelplay/data/service/wear/PhoneWatchTransferStateStore.kt @@ -1,5 +1,6 @@ package com.theveloper.pixelplay.data.service.wear +import com.theveloper.pixelplay.shared.WearPlaylistSyncAck import com.theveloper.pixelplay.shared.WearTransferProgress import javax.inject.Inject import javax.inject.Singleton @@ -8,8 +9,11 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch @@ -73,8 +77,19 @@ class PhoneWatchTransferStateStore @Inject constructor() { private val _watchSongIds = MutableStateFlow>(emptySet()) val watchSongIds: StateFlow> = _watchSongIds.asStateFlow() + // Replay a handful rather than 0: the ack can in principle arrive and be emitted before + // PlaylistWatchTransferCoordinator starts collecting for it (right after messageClient's own + // send call returns), and a plain event stream with no replay would silently drop it in that + // case instead of just delivering it a moment "late" to a fresh collector. + private val _playlistSyncAcks = MutableSharedFlow(replay = 8) + val playlistSyncAcks: SharedFlow = _playlistSyncAcks.asSharedFlow() + private val cleanupJobs = ConcurrentHashMap() + fun onPlaylistSyncAckReceived(ack: WearPlaylistSyncAck) { + _playlistSyncAcks.tryEmit(ack) + } + fun markRequested( requestId: String, songId: String, diff --git a/app/src/main/java/com/theveloper/pixelplay/data/service/wear/PlaylistWatchTransferCoordinator.kt b/app/src/main/java/com/theveloper/pixelplay/data/service/wear/PlaylistWatchTransferCoordinator.kt index 58b796eed..e460d3f2f 100644 --- a/app/src/main/java/com/theveloper/pixelplay/data/service/wear/PlaylistWatchTransferCoordinator.kt +++ b/app/src/main/java/com/theveloper/pixelplay/data/service/wear/PlaylistWatchTransferCoordinator.kt @@ -10,6 +10,7 @@ import com.theveloper.pixelplay.di.AppScope import com.theveloper.pixelplay.shared.WearCapabilities import com.theveloper.pixelplay.shared.WearDataPaths import com.theveloper.pixelplay.shared.WearPlaylistSync +import com.theveloper.pixelplay.shared.WearPlaylistSyncAck import com.theveloper.pixelplay.shared.WearTransferProgress import java.util.UUID import java.util.concurrent.ConcurrentHashMap @@ -185,6 +186,19 @@ class PlaylistWatchTransferCoordinator @Inject constructor( } } + /** + * `MessageClient.sendMessage()` succeeding only means the message was handed off locally, not + * that the watch received it — real hardware testing showed a sync sent while the watch was + * mid-reconnect (its Wi-Fi/ADB link drops intermittently under this app's own load) is + * silently lost: the watch ends up with every song's audio on disk but no playlist row to + * show them under, because nothing here ever knew the sync didn't land. Each node now gets a + * fresh [WearPlaylistSync.requestId] and this waits for the matching [WearPlaylistSyncAck] + * (see [WearTransferRepository][com.theveloper.pixelplay.data.WearTransferRepository] + * `.onPlaylistSyncReceived` on the watch side), retrying once — same shape as + * [transferSongToAllNodesWithRetry] — before giving up and logging it. Giving up doesn't fail + * the batch: songs still transfer either way, and the next explicit re-sync (or "update on + * watch") is idempotent and gets another chance. + */ private suspend fun sendPlaylistSyncToNodes( nodes: List, playlistId: String, @@ -192,19 +206,65 @@ class PlaylistWatchTransferCoordinator @Inject constructor( songIds: List, songTitles: List, ) { - val syncPayload = json.encodeToString(WearPlaylistSync(playlistId, playlistName, songIds, songTitles)) - .toByteArray(Charsets.UTF_8) nodes.forEach { node -> - try { - messageClient.sendMessage(node.id, WearDataPaths.PLAYLIST_SYNC, syncPayload).await() - } catch (error: CancellationException) { - throw error - } catch (error: Exception) { - Timber.tag(TAG).w(error, "Failed to send playlist sync to node=%s", node.id) - } + sendPlaylistSyncToNodeWithRetry(node, playlistId, playlistName, songIds, songTitles) + } + } + + private suspend fun sendPlaylistSyncToNodeWithRetry( + node: Node, + playlistId: String, + playlistName: String, + songIds: List, + songTitles: List, + ) { + if (sendPlaylistSyncToNodeAndAwaitAck(node, playlistId, playlistName, songIds, songTitles)) return + + Timber.tag(TAG).w( + "Retrying playlist sync after missing ack: playlistId=%s node=%s", + playlistId, + node.id, + ) + delay(RETRY_BACKOFF_MS) + val ackedOnRetry = sendPlaylistSyncToNodeAndAwaitAck(node, playlistId, playlistName, songIds, songTitles) + if (!ackedOnRetry) { + Timber.tag(TAG).w( + "Playlist sync unconfirmed after retry: playlistId=%s node=%s — songs will still " + + "transfer, but the watch may not show this playlist until the next sync", + playlistId, + node.id, + ) } } + /** Returns whether [node] acked this attempt within [PLAYLIST_SYNC_ACK_TIMEOUT_MS]. */ + private suspend fun sendPlaylistSyncToNodeAndAwaitAck( + node: Node, + playlistId: String, + playlistName: String, + songIds: List, + songTitles: List, + ): Boolean { + val requestId = UUID.randomUUID().toString() + val syncPayload = json.encodeToString( + WearPlaylistSync(playlistId, playlistName, songIds, songTitles, requestId) + ).toByteArray(Charsets.UTF_8) + + try { + messageClient.sendMessage(node.id, WearDataPaths.PLAYLIST_SYNC, syncPayload).await() + } catch (error: CancellationException) { + throw error + } catch (error: Exception) { + Timber.tag(TAG).w(error, "Failed to send playlist sync to node=%s", node.id) + return false + } + + val ack = withTimeoutOrNull(PLAYLIST_SYNC_ACK_TIMEOUT_MS) { + transferStateStore.playlistSyncAcks.first { it.requestId == requestId } + } + return ack != null + } + /** * Titles for [songIds], same order, "" for any id the library doesn't resolve — purely * cosmetic (lets the watch show a real name instead of a raw id for a song still awaiting @@ -403,6 +463,11 @@ class PlaylistWatchTransferCoordinator @Inject constructor( // round-trips, it's not load-bearing for correctness (the watch rejects duplicates itself). private const val WATCH_LIBRARY_RESOLVE_TIMEOUT_MS = 10_000L + // How long to wait for the watch's playlist-sync ack before retrying. Generous relative to + // a normal round-trip (which is near-instant) to tolerate a brief Wi-Fi/ADB reconnect blip + // without firing a spurious retry. + private const val PLAYLIST_SYNC_ACK_TIMEOUT_MS = 10_000L + private val TERMINAL_STATUSES = setOf( WearTransferProgress.STATUS_COMPLETED, WearTransferProgress.STATUS_FAILED, diff --git a/app/src/main/java/com/theveloper/pixelplay/data/service/wear/WearCommandReceiver.kt b/app/src/main/java/com/theveloper/pixelplay/data/service/wear/WearCommandReceiver.kt index 0b0ce27ee..f0bcaa1a0 100644 --- a/app/src/main/java/com/theveloper/pixelplay/data/service/wear/WearCommandReceiver.kt +++ b/app/src/main/java/com/theveloper/pixelplay/data/service/wear/WearCommandReceiver.kt @@ -26,6 +26,7 @@ import com.theveloper.pixelplay.shared.WearBrowseResponse import com.theveloper.pixelplay.shared.WearDataPaths import com.theveloper.pixelplay.shared.WearLibraryItem import com.theveloper.pixelplay.shared.WearPlaybackCommand +import com.theveloper.pixelplay.shared.WearPlaylistSyncAck import com.theveloper.pixelplay.shared.WearTransferMetadata import com.theveloper.pixelplay.shared.WearTransferProgress import com.theveloper.pixelplay.shared.WearTransferRequest @@ -97,6 +98,7 @@ class WearCommandReceiver : WearableListenerService() { WearDataPaths.BROWSE_REQUEST -> handleBrowseRequest(messageEvent) WearDataPaths.TRANSFER_REQUEST -> handleTransferRequest(messageEvent) WearDataPaths.TRANSFER_CANCEL -> handleTransferCancel(messageEvent) + WearDataPaths.PLAYLIST_SYNC_ACK -> handlePlaylistSyncAck(messageEvent) else -> Timber.tag(TAG).w("Unknown message path: ${messageEvent.path}") } } @@ -524,6 +526,17 @@ class WearCommandReceiver : WearableListenerService() { ) } + private fun handlePlaylistSyncAck(messageEvent: MessageEvent) { + val ackJson = String(messageEvent.data, Charsets.UTF_8) + val ack = try { + json.decodeFromString(ackJson) + } catch (e: Exception) { + Timber.tag(TAG).e(e, "Failed to parse playlist sync ack") + return + } + transferStateStore.onPlaylistSyncAckReceived(ack) + } + private fun handleTransferCancel(messageEvent: MessageEvent) { val requestJson = String(messageEvent.data, Charsets.UTF_8) val request = try { diff --git a/app/src/test/java/com/theveloper/pixelplay/data/service/wear/PlaylistWatchTransferCoordinatorTest.kt b/app/src/test/java/com/theveloper/pixelplay/data/service/wear/PlaylistWatchTransferCoordinatorTest.kt index f3d72a14a..30057abe1 100644 --- a/app/src/test/java/com/theveloper/pixelplay/data/service/wear/PlaylistWatchTransferCoordinatorTest.kt +++ b/app/src/test/java/com/theveloper/pixelplay/data/service/wear/PlaylistWatchTransferCoordinatorTest.kt @@ -12,6 +12,7 @@ import com.theveloper.pixelplay.data.model.Song import com.theveloper.pixelplay.data.repository.MusicRepository import com.theveloper.pixelplay.shared.WearDataPaths import com.theveloper.pixelplay.shared.WearPlaylistSync +import com.theveloper.pixelplay.shared.WearPlaylistSyncAck import com.theveloper.pixelplay.shared.WearTransferProgress import io.mockk.Runs import io.mockk.coEvery @@ -64,8 +65,14 @@ class PlaylistWatchTransferCoordinatorTest { every { watchAudioTranscoder.cleanup(any()) } just Runs // Tasks.forResult builds a real, already-completed Task — play-services-tasks has no - // Android framework dependency for this, so it resolves correctly off-device. - every { messageClient.sendMessage(any(), any(), any()) } returns Tasks.forResult(0) + // Android framework dependency for this, so it resolves correctly off-device. Playlist + // syncs additionally auto-ack (simulating a healthy watch) so every existing test here + // keeps its original one-send-per-node behavior; tests that care about the ack-timeout/ + // retry path override this locally. + every { messageClient.sendMessage(any(), any(), any()) } answers { + autoAckIfPlaylistSync(thirdArg()) + Tasks.forResult(0) + } every { musicRepository.getSongsByIds(any()) } answers { val requestedIds = firstArg>() @@ -86,6 +93,17 @@ class PlaylistWatchTransferCoordinatorTest { return song } + /** Decodes [bytes] as a [WearPlaylistSync] and, if it carries a requestId, immediately acks it. */ + private fun autoAckIfPlaylistSync(bytes: ByteArray) { + val sync = runCatching { + json.decodeFromString(String(bytes, Charsets.UTF_8)) + }.getOrNull() ?: return + if (sync.requestId.isEmpty()) return + transferStateStore.onPlaylistSyncAckReceived( + WearPlaylistSyncAck(playlistId = sync.playlistId, requestId = sync.requestId) + ) + } + private fun stubReachableNodes(vararg nodeIds: String) { val nodes = nodeIds.map { nodeId -> mockk { every { id } returns nodeId } }.toSet() val capabilityInfo = mockk { every { this@mockk.nodes } returns nodes } @@ -185,6 +203,7 @@ class PlaylistWatchTransferCoordinatorTest { every { messageClient.sendMessage(any(), WearDataPaths.PLAYLIST_SYNC, any()) } answers { val bytes = thirdArg() syncPayloads += json.decodeFromString(String(bytes, Charsets.UTF_8)) + autoAckIfPlaylistSync(bytes) Tasks.forResult(0) } val coordinator = buildCoordinator(this) @@ -411,6 +430,77 @@ class PlaylistWatchTransferCoordinatorTest { assertThat(batch?.completedSongCount).isEqualTo(0) } + // --- Playlist sync reliability: ack + retry --- + + @Test + fun `a playlist sync acked on the first attempt is sent only once`() = runTest { + stubReachableNodes("node-1") + stubTransfersResolveTo(WearTransferProgress.STATUS_COMPLETED) + song("s1") + val coordinator = buildCoordinator(this) + + coordinator.requestPlaylistTransfer("p1", "Playlist", listOf("s1")) + advanceUntilIdle() + + verify(exactly = 1) { messageClient.sendMessage(any(), WearDataPaths.PLAYLIST_SYNC, any()) } + } + + @Test + fun `a playlist sync that's never acked is retried once, then given up on`() = runTest { + stubReachableNodes("node-1") + stubTransfersResolveTo(WearTransferProgress.STATUS_COMPLETED) + song("s1") + // Overrides the auto-acking default stub — this node never acks, simulating the watch + // being mid-reconnect when both attempts go out. + every { messageClient.sendMessage(any(), WearDataPaths.PLAYLIST_SYNC, any()) } returns Tasks.forResult(0) + val coordinator = buildCoordinator(this) + + coordinator.requestPlaylistTransfer("p1", "Playlist", listOf("s1")) + advanceUntilIdle() + + // One initial attempt plus exactly one retry — not retried indefinitely. + verify(exactly = 2) { messageClient.sendMessage(any(), WearDataPaths.PLAYLIST_SYNC, any()) } + } + + @Test + fun `a playlist sync acked only on the retry stops after that retry`() = runTest { + stubReachableNodes("node-1") + stubTransfersResolveTo(WearTransferProgress.STATUS_COMPLETED) + song("s1") + var attempt = 0 + every { messageClient.sendMessage(any(), WearDataPaths.PLAYLIST_SYNC, any()) } answers { + attempt += 1 + val bytes = thirdArg() + if (attempt >= 2) autoAckIfPlaylistSync(bytes) + Tasks.forResult(0) + } + val coordinator = buildCoordinator(this) + + coordinator.requestPlaylistTransfer("p1", "Playlist", listOf("s1")) + advanceUntilIdle() + + assertThat(attempt).isEqualTo(2) + } + + @Test + fun `songs still transfer even when the playlist sync is never acked`() = runTest { + stubReachableNodes("node-1") + stubTransfersResolveTo(WearTransferProgress.STATUS_COMPLETED) + song("s1") + every { messageClient.sendMessage(any(), WearDataPaths.PLAYLIST_SYNC, any()) } returns Tasks.forResult(0) + val coordinator = buildCoordinator(this) + + val batchId = coordinator.requestPlaylistTransfer("p1", "Playlist", listOf("s1")) + advanceUntilIdle() + + // An unconfirmed playlist sync is a warning, not a batch failure — the song itself still + // lands on the watch, it just might not show up under the playlist until the next sync. + assertThat(transferredSongIdsInOrder).containsExactly("s1") + val batch = transferStateStore.batchTransfers.value[batchId] + assertThat(batch?.status).isEqualTo(WearTransferProgress.STATUS_COMPLETED) + assertThat(batch?.completedSongCount).isEqualTo(1) + } + // --- Persistence: resuming a batch interrupted by process death (PR7) --- @Test diff --git a/shared/src/main/java/com/theveloper/pixelplay/shared/WearDataPaths.kt b/shared/src/main/java/com/theveloper/pixelplay/shared/WearDataPaths.kt index fd6c6d97f..b45678a3c 100644 --- a/shared/src/main/java/com/theveloper/pixelplay/shared/WearDataPaths.kt +++ b/shared/src/main/java/com/theveloper/pixelplay/shared/WearDataPaths.kt @@ -67,4 +67,11 @@ object WearDataPaths { /** Message path for playlist sync (phone -> watch): creates or updates a local playlist's membership/order. */ const val PLAYLIST_SYNC = "/playlist_sync" + + /** + * Message path for playlist sync acknowledgement (watch -> phone): confirms a [PLAYLIST_SYNC] + * message was actually applied, since `MessageClient.sendMessage()` succeeding on the phone + * only means local hand-off, not delivery. + */ + const val PLAYLIST_SYNC_ACK = "/playlist_sync_ack" } diff --git a/shared/src/main/java/com/theveloper/pixelplay/shared/WearPlaylistSync.kt b/shared/src/main/java/com/theveloper/pixelplay/shared/WearPlaylistSync.kt index 6836d1088..4d816b986 100644 --- a/shared/src/main/java/com/theveloper/pixelplay/shared/WearPlaylistSync.kt +++ b/shared/src/main/java/com/theveloper/pixelplay/shared/WearPlaylistSync.kt @@ -16,6 +16,12 @@ import kotlinx.serialization.Serializable * omitting it still deserializes cleanly on a newer watch — it's purely cosmetic (lets a song * still awaiting transfer show its real name instead of its raw ID) and never load-bearing for * the transfer itself. + * + * [requestId] identifies this specific send attempt so the watch's [WearPlaylistSyncAck] can be + * correlated back to it — `MessageClient.sendMessage()` doesn't guarantee delivery, so the phone + * resends (a new [requestId] each time) until it sees a matching ack. Defaults to "" for the same + * backward-compatibility reason as [songTitles]: an old phone build omitting it just means the + * watch never acks, and the phone falls back to its old fire-and-forget behavior for that sync. */ @Serializable data class WearPlaylistSync( @@ -23,4 +29,5 @@ data class WearPlaylistSync( val name: String, val songIds: List, val songTitles: List = emptyList(), + val requestId: String = "", ) diff --git a/shared/src/main/java/com/theveloper/pixelplay/shared/WearPlaylistSyncAck.kt b/shared/src/main/java/com/theveloper/pixelplay/shared/WearPlaylistSyncAck.kt new file mode 100644 index 000000000..133810d82 --- /dev/null +++ b/shared/src/main/java/com/theveloper/pixelplay/shared/WearPlaylistSyncAck.kt @@ -0,0 +1,18 @@ +package com.theveloper.pixelplay.shared + +import kotlinx.serialization.Serializable + +/** + * Sent by the watch back to the phone once a [WearPlaylistSync] has been durably applied to the + * local playlist table. `MessageClient.sendMessage()` returning success on the phone only means + * the message was handed off locally, not that the watch received it — real hardware testing + * showed a sync sent while the watch was mid-reconnect (Wi-Fi/ADB drops intermittently under this + * app's own load) is silently lost, leaving the watch with every song's audio but no playlist row + * to show them under. The phone waits for this ack (see `PlaylistWatchTransferCoordinator`) and + * resends if it doesn't arrive in time. + */ +@Serializable +data class WearPlaylistSyncAck( + val playlistId: String, + val requestId: String, +) diff --git a/wear/src/main/java/com/theveloper/pixelplay/data/WearDataListenerService.kt b/wear/src/main/java/com/theveloper/pixelplay/data/WearDataListenerService.kt index ec841510d..d4bb874b6 100644 --- a/wear/src/main/java/com/theveloper/pixelplay/data/WearDataListenerService.kt +++ b/wear/src/main/java/com/theveloper/pixelplay/data/WearDataListenerService.kt @@ -259,7 +259,7 @@ class WearDataListenerService : WearableListenerService() { try { val syncJson = String(messageEvent.data, Charsets.UTF_8) val sync = json.decodeFromString(syncJson) - transferRepository.onPlaylistSyncReceived(sync) + transferRepository.onPlaylistSyncReceived(sync, sourceNodeId = messageEvent.sourceNodeId) } catch (e: CancellationException) { throw e } catch (e: Exception) { diff --git a/wear/src/main/java/com/theveloper/pixelplay/data/WearTransferRepository.kt b/wear/src/main/java/com/theveloper/pixelplay/data/WearTransferRepository.kt index e28ae974f..f64e1028d 100644 --- a/wear/src/main/java/com/theveloper/pixelplay/data/WearTransferRepository.kt +++ b/wear/src/main/java/com/theveloper/pixelplay/data/WearTransferRepository.kt @@ -14,6 +14,7 @@ import com.theveloper.pixelplay.data.local.LocalSongEntity import com.theveloper.pixelplay.shared.WearDataPaths import com.theveloper.pixelplay.shared.WearLibraryState import com.theveloper.pixelplay.shared.WearPlaylistSync +import com.theveloper.pixelplay.shared.WearPlaylistSyncAck import com.theveloper.pixelplay.shared.WearTransferMetadata import com.theveloper.pixelplay.shared.WearTransferProgress import com.theveloper.pixelplay.shared.WearTransferRequest @@ -888,8 +889,12 @@ class WearTransferRepository @Inject constructor( * start playing whatever's already local right away. Idempotent: re-syncing the same * [WearPlaylistSync.playlistId] (e.g. after the user edits the playlist on the phone) replaces * membership/order in one transaction rather than merging with the stale cross-refs. + * + * [sourceNodeId] is where the ack goes back to. Acking is best-effort and never blocks or + * fails this function — if [WearPlaylistSync.requestId] is empty (an old phone build) there's + * nothing to correlate an ack to, so none is sent. */ - suspend fun onPlaylistSyncReceived(sync: WearPlaylistSync) { + suspend fun onPlaylistSyncReceived(sync: WearPlaylistSync, sourceNodeId: String) { val now = System.currentTimeMillis() val existing = localPlaylistDao.getPlaylistById(sync.playlistId) val entity = LocalPlaylistEntity( @@ -915,6 +920,23 @@ class WearTransferRepository @Inject constructor( sync.name, sync.songIds.size, ) + + if (sync.requestId.isNotEmpty()) { + sendPlaylistSyncAck(sourceNodeId, sync.playlistId, sync.requestId) + } + } + + private suspend fun sendPlaylistSyncAck(nodeId: String, playlistId: String, requestId: String) { + val ack = WearPlaylistSyncAck(playlistId = playlistId, requestId = requestId) + try { + val ackBytes = json.encodeToString(ack).toByteArray(Charsets.UTF_8) + messageClient.sendMessage(nodeId, WearDataPaths.PLAYLIST_SYNC_ACK, ackBytes).await() + } catch (e: Exception) { + // Not retried here: if this is lost too, the phone's own await-ack timeout fires and + // it resends the whole sync, which is idempotent — so the watch just gets another shot + // at acking rather than needing its own retry logic for the ack itself. + Timber.tag(TAG).w(e, "Failed to send playlist sync ack: playlistId=%s", playlistId) + } } /** diff --git a/wear/src/test/java/com/theveloper/pixelplay/data/WearTransferRepositoryPlaylistSyncTest.kt b/wear/src/test/java/com/theveloper/pixelplay/data/WearTransferRepositoryPlaylistSyncTest.kt index 503639715..8f0f431f6 100644 --- a/wear/src/test/java/com/theveloper/pixelplay/data/WearTransferRepositoryPlaylistSyncTest.kt +++ b/wear/src/test/java/com/theveloper/pixelplay/data/WearTransferRepositoryPlaylistSyncTest.kt @@ -1,6 +1,7 @@ package com.theveloper.pixelplay.data import android.app.Application +import com.google.android.gms.tasks.Tasks import com.google.android.gms.wearable.ChannelClient import com.google.android.gms.wearable.MessageClient import com.google.android.gms.wearable.NodeClient @@ -10,7 +11,9 @@ import com.theveloper.pixelplay.data.local.LocalPlaylistDao import com.theveloper.pixelplay.data.local.LocalPlaylistEntity import com.theveloper.pixelplay.data.local.LocalPlaylistSongCrossRef import com.theveloper.pixelplay.data.local.LocalSongDao +import com.theveloper.pixelplay.shared.WearDataPaths import com.theveloper.pixelplay.shared.WearPlaylistSync +import com.theveloper.pixelplay.shared.WearPlaylistSyncAck import io.mockk.Runs import io.mockk.coEvery import io.mockk.coVerify @@ -18,8 +21,11 @@ import io.mockk.every import io.mockk.just import io.mockk.mockk import io.mockk.slot +import io.mockk.verify import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.runTest +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.json.Json import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.RegisterExtension @@ -82,7 +88,10 @@ class WearTransferRepositoryPlaylistSyncTest { val entitySlot = slot() coEvery { localPlaylistDao.upsertPlaylist(capture(entitySlot), any()) } just Runs - repository.onPlaylistSyncReceived(WearPlaylistSync(playlistId = "p1", name = "Road trip", songIds = listOf("s1"))) + repository.onPlaylistSyncReceived( + WearPlaylistSync(playlistId = "p1", name = "Road trip", songIds = listOf("s1")), + sourceNodeId = "node-1", + ) assertThat(entitySlot.captured.createdAt).isEqualTo(entitySlot.captured.updatedAt) } @@ -99,7 +108,10 @@ class WearTransferRepositoryPlaylistSyncTest { val entitySlot = slot() coEvery { localPlaylistDao.upsertPlaylist(capture(entitySlot), any()) } just Runs - repository.onPlaylistSyncReceived(WearPlaylistSync(playlistId = "p1", name = "Road trip", songIds = listOf("s1", "s2"))) + repository.onPlaylistSyncReceived( + WearPlaylistSync(playlistId = "p1", name = "Road trip", songIds = listOf("s1", "s2")), + sourceNodeId = "node-1", + ) assertThat(entitySlot.captured.createdAt).isEqualTo(originalCreatedAt) assertThat(entitySlot.captured.updatedAt).isGreaterThan(originalCreatedAt) @@ -111,8 +123,14 @@ class WearTransferRepositoryPlaylistSyncTest { val crossRefsSlot = slot>() coEvery { localPlaylistDao.upsertPlaylist(any(), capture(crossRefsSlot)) } just Runs - repository.onPlaylistSyncReceived(WearPlaylistSync(playlistId = "p1", name = "Road trip", songIds = listOf("a", "b"))) - repository.onPlaylistSyncReceived(WearPlaylistSync(playlistId = "p1", name = "Road trip", songIds = listOf("c"))) + repository.onPlaylistSyncReceived( + WearPlaylistSync(playlistId = "p1", name = "Road trip", songIds = listOf("a", "b")), + sourceNodeId = "node-1", + ) + repository.onPlaylistSyncReceived( + WearPlaylistSync(playlistId = "p1", name = "Road trip", songIds = listOf("c")), + sourceNodeId = "node-1", + ) // The repository always regenerates the full cross-ref list from the incoming sync's // songIds alone — it never reads current membership back in — so the last call's payload @@ -129,7 +147,8 @@ class WearTransferRepositoryPlaylistSyncTest { coEvery { localPlaylistDao.upsertPlaylist(any(), capture(crossRefsSlot)) } just Runs repository.onPlaylistSyncReceived( - WearPlaylistSync(playlistId = "p1", name = "Road trip", songIds = listOf("s3", "s1", "s2")) + WearPlaylistSync(playlistId = "p1", name = "Road trip", songIds = listOf("s3", "s1", "s2")), + sourceNodeId = "node-1", ) assertThat(crossRefsSlot.captured).containsExactly( @@ -143,7 +162,10 @@ class WearTransferRepositoryPlaylistSyncTest { fun `empty song list still upserts an empty cross-ref list, not a no-op`() = runTest { coEvery { localPlaylistDao.getPlaylistById("p1") } returns null - repository.onPlaylistSyncReceived(WearPlaylistSync(playlistId = "p1", name = "Empty playlist", songIds = emptyList())) + repository.onPlaylistSyncReceived( + WearPlaylistSync(playlistId = "p1", name = "Empty playlist", songIds = emptyList()), + sourceNodeId = "node-1", + ) coVerify(exactly = 1) { localPlaylistDao.upsertPlaylist(any(), emptyList()) } } @@ -154,7 +176,10 @@ class WearTransferRepositoryPlaylistSyncTest { val entitySlot = slot() coEvery { localPlaylistDao.upsertPlaylist(capture(entitySlot), any()) } just Runs - repository.onPlaylistSyncReceived(WearPlaylistSync(playlistId = "p1", name = "Summer mix", songIds = listOf("s1"))) + repository.onPlaylistSyncReceived( + WearPlaylistSync(playlistId = "p1", name = "Summer mix", songIds = listOf("s1")), + sourceNodeId = "node-1", + ) assertThat(entitySlot.captured.playlistId).isEqualTo("p1") assertThat(entitySlot.captured.name).isEqualTo("Summer mix") @@ -172,7 +197,8 @@ class WearTransferRepositoryPlaylistSyncTest { name = "Road trip", songIds = listOf("s1", "s2"), songTitles = listOf("First song", "Second song"), - ) + ), + sourceNodeId = "node-1", ) assertThat(crossRefsSlot.captured).containsExactly( @@ -189,9 +215,56 @@ class WearTransferRepositoryPlaylistSyncTest { // songTitles omitted entirely — WearPlaylistSync.songTitles defaults to emptyList(). repository.onPlaylistSyncReceived( - WearPlaylistSync(playlistId = "p1", name = "Road trip", songIds = listOf("s1")) + WearPlaylistSync(playlistId = "p1", name = "Road trip", songIds = listOf("s1")), + sourceNodeId = "node-1", ) assertThat(crossRefsSlot.captured.single().pendingTitle).isEmpty() } + + // --- Ack (playlist-sync reliability fix) --- + + @Test + fun `a sync with a requestId acks back to the source node once applied`() = runTest { + coEvery { localPlaylistDao.getPlaylistById("p1") } returns null + val pathSlot = slot() + val bytesSlot = slot() + every { messageClient.sendMessage("node-9", capture(pathSlot), capture(bytesSlot)) } returns + Tasks.forResult(0) + + repository.onPlaylistSyncReceived( + WearPlaylistSync(playlistId = "p1", name = "Road trip", songIds = listOf("s1"), requestId = "req-1"), + sourceNodeId = "node-9", + ) + + assertThat(pathSlot.captured).isEqualTo(WearDataPaths.PLAYLIST_SYNC_ACK) + val ack = Json.decodeFromString(String(bytesSlot.captured, Charsets.UTF_8)) + assertThat(ack.playlistId).isEqualTo("p1") + assertThat(ack.requestId).isEqualTo("req-1") + } + + @Test + fun `a sync with no requestId (old phone build) sends no ack`() = runTest { + coEvery { localPlaylistDao.getPlaylistById("p1") } returns null + + repository.onPlaylistSyncReceived( + WearPlaylistSync(playlistId = "p1", name = "Road trip", songIds = listOf("s1")), + sourceNodeId = "node-9", + ) + + verify(exactly = 0) { messageClient.sendMessage(any(), any(), any()) } + } + + @Test + fun `a failure sending the ack does not propagate out of onPlaylistSyncReceived`() = runTest { + coEvery { localPlaylistDao.getPlaylistById("p1") } returns null + every { messageClient.sendMessage(any(), any(), any()) } returns + Tasks.forException(RuntimeException("no route to node")) + + // Should not throw — a lost ack just means the phone times out and resends the sync. + repository.onPlaylistSyncReceived( + WearPlaylistSync(playlistId = "p1", name = "Road trip", songIds = listOf("s1"), requestId = "req-1"), + sourceNodeId = "node-9", + ) + } } From 597d1d782620d141d1ac4482f3db1f176e79d3a4 Mon Sep 17 00:00:00 2001 From: PonceGL Date: Tue, 11 Aug 2026 13:20:38 -0600 Subject: [PATCH 39/40] fix(wear): recover from mid-song offload stalls, not just early ones MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../pixelplay/data/WearPlaybackService.kt | 96 +++++++++++++++++-- .../data/WearPlaybackStallWatchdog.kt | 30 ++++++ .../data/WearPlaybackStallWatchdogTest.kt | 76 +++++++++++++++ 3 files changed, 193 insertions(+), 9 deletions(-) create mode 100644 wear/src/main/java/com/theveloper/pixelplay/data/WearPlaybackStallWatchdog.kt create mode 100644 wear/src/test/java/com/theveloper/pixelplay/data/WearPlaybackStallWatchdogTest.kt diff --git a/wear/src/main/java/com/theveloper/pixelplay/data/WearPlaybackService.kt b/wear/src/main/java/com/theveloper/pixelplay/data/WearPlaybackService.kt index 866b168df..b0d045e34 100644 --- a/wear/src/main/java/com/theveloper/pixelplay/data/WearPlaybackService.kt +++ b/wear/src/main/java/com/theveloper/pixelplay/data/WearPlaybackService.kt @@ -18,6 +18,14 @@ import androidx.media3.session.MediaSession import androidx.media3.session.MediaSessionService import com.google.common.util.concurrent.Futures import com.google.common.util.concurrent.ListenableFuture +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch import timber.log.Timber /** @@ -36,13 +44,14 @@ class WearPlaybackService : MediaSessionService() { private var player: ExoPlayer? = null private var mediaSession: MediaSession? = null + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) // --- Audio offload state ------------------------------------------------------------- // AUDIO_OFFLOAD_MODE_ENABLED (as opposed to _REQUIRED) is a *soft* request: if the watch's // audio HAL doesn't support offloading this format, ExoPlayer silently falls back to the // normal decode path on its own — no capability probing needed on our side for that case. // What ExoPlayer *doesn't* handle on its own is a HAL that accepts the offloaded track but - // then resets/stalls shortly after — that failure mode is exactly what motivated the phone's + // then resets/stalls — that failure mode is exactly what motivated the phone's // DualPlayerEngine to build a runtime fallback (see AudioOffloadPolicyTest in :app), so this // service mirrors that safety net rather than assuming Wear OS audio HALs are better-behaved. private var audioOffloadEnabled = true @@ -50,10 +59,18 @@ class WearPlaybackService : MediaSessionService() { private var isPostSeekBuffering = false private var isPostMediaItemTransition = false + // --- Mid-song stall watchdog ---------------------------------------------------------- + // See WearPlaybackStallWatchdog.kt: catches a stall AudioOffloadFallbackListener can't, one + // that happens well after playback started and never surfaces as STATE_BUFFERING. + private var stallWatchdogJob: Job? = null + private var lastWatchdogPositionMs = -1L + private var consecutiveStalledTicks = 0 + override fun onCreate() { super.onCreate() player = buildExoPlayer() mediaSession = buildMediaSession(player!!) + startStallWatchdog() Timber.tag(TAG).d("WearPlaybackService created") } @@ -68,6 +85,8 @@ class WearPlaybackService : MediaSessionService() { } override fun onDestroy() { + stallWatchdogJob?.cancel() + scope.cancel() mediaSession?.release() mediaSession = null player?.release() @@ -151,19 +170,38 @@ class WearPlaybackService : MediaSessionService() { } /** - * Rebuilds the player with offload disabled after [AudioOffloadFallbackListener] reads a - * stall as a HAL reset, preserving playback state across the swap. - * - * [MediaSession.setPlayer] lets the existing session (and any connected `MediaController`, - * including the phone acting as a remote) keep its binder connection across the swap instead - * of tearing down and reconnecting — the rebuild is invisible to callers beyond a brief - * re-buffer. + * Rebuilds the player after [AudioOffloadFallbackListener] reads an early re-buffer as a HAL + * reset, disabling offload for the rest of the session. */ private fun fallBackFromAudioOffload(reason: String) { if (!audioOffloadEnabled) return - val oldPlayer = player ?: return audioOffloadEnabled = false Timber.tag(TAG).w("Falling back from audio offload: %s", reason) + rebuildPlayerPreservingState() + } + + /** + * Rebuilds the player after the stall watchdog sees position frozen for + * [STALL_TICKS_THRESHOLD] ticks in a row — the *mid-song* wedge [fallBackFromAudioOffload] + * can't see (see WearPlaybackStallWatchdog.kt). Unlike that early check, this doesn't gate on + * [audioOffloadEnabled]: if offload is still on, disabling it too is the best available guess + * at the cause, but the rebuild itself — a fresh ExoPlayer/AudioTrack instance — is the actual + * fix regardless, so it still runs even if offload was already off from an earlier fallback. + */ + private fun recoverFromStalledPlayback(reason: String) { + Timber.tag(TAG).w("Recovering from stalled playback: %s", reason) + audioOffloadEnabled = false + rebuildPlayerPreservingState() + } + + /** + * Preserves queue/position/play-state across a player rebuild. [MediaSession.setPlayer] lets + * the existing session (and any connected `MediaController`, including the phone acting as a + * remote) keep its binder connection across the swap instead of tearing down and + * reconnecting — the rebuild is invisible to callers beyond a brief re-buffer. + */ + private fun rebuildPlayerPreservingState() { + val oldPlayer = player ?: return val mediaItems = ArrayList(oldPlayer.mediaItemCount) for (i in 0 until oldPlayer.mediaItemCount) mediaItems.add(oldPlayer.getMediaItemAt(i)) @@ -185,6 +223,40 @@ class WearPlaybackService : MediaSessionService() { player = newPlayer mediaSession?.setPlayer(newPlayer) oldPlayer.release() + + // The new player instance starts wherever setMediaItems/positionMs put it — don't let a + // stale reading from the old (just-released) player count as "no progress" against it. + lastWatchdogPositionMs = -1L + consecutiveStalledTicks = 0 + } + + /** + * Ticks once a second, comparing the player's own reported position against the last tick's — + * a stall that doesn't change [Player.getPlaybackState] (see WearPlaybackStallWatchdog.kt) + * has no listener callback to hook, so this is the only way to catch it. + */ + private fun startStallWatchdog() { + stallWatchdogJob?.cancel() + stallWatchdogJob = scope.launch { + while (isActive) { + delay(STALL_TICK_INTERVAL_MS) + val current = player ?: continue + val isPlaying = current.isPlaying + val position = current.currentPosition + val positionAdvanced = position != lastWatchdogPositionMs + lastWatchdogPositionMs = position + consecutiveStalledTicks = wearPlaybackStalledTickCount( + isPlaying = isPlaying, + positionAdvancedSinceLastTick = positionAdvanced, + previousConsecutiveStalledTicks = consecutiveStalledTicks, + ) + if (consecutiveStalledTicks >= STALL_TICKS_THRESHOLD) { + recoverFromStalledPlayback( + "no position advance for ${STALL_TICK_INTERVAL_MS * STALL_TICKS_THRESHOLD}ms" + ) + } + } + } } private fun buildOpenAppIntent(): PendingIntent { @@ -270,5 +342,11 @@ class WearPlaybackService : MediaSessionService() { companion object { private const val TAG = "WearPlaybackService" private const val MEDIA_SESSION_ID = "wear-local-playback" + + // 3 consecutive 1s ticks with zero position movement while isPlaying=true — long enough + // that a legitimate single slow tick can't false-positive, short enough that a real wedge + // doesn't sit silent for long before recovering. + private const val STALL_TICK_INTERVAL_MS = 1_000L + private const val STALL_TICKS_THRESHOLD = 3 } } diff --git a/wear/src/main/java/com/theveloper/pixelplay/data/WearPlaybackStallWatchdog.kt b/wear/src/main/java/com/theveloper/pixelplay/data/WearPlaybackStallWatchdog.kt new file mode 100644 index 000000000..6fcb27b5c --- /dev/null +++ b/wear/src/main/java/com/theveloper/pixelplay/data/WearPlaybackStallWatchdog.kt @@ -0,0 +1,30 @@ +package com.theveloper.pixelplay.data + +/** + * Detects a playback stall that [wearShouldFallBackFromAudioOffload] can't see. + * + * That check only reacts to a stall within ~500ms of *starting* playback, surfaced as + * `STATE_BUFFERING` — the pattern the phone's `DualPlayerEngine` originally guarded against. Real + * hardware testing on the watch (a Samsung Galaxy Watch) showed a different failure: the offload + * HAL wedges *mid-song*, well past that early window, and the player's own reported state never + * changes — it stays `STATE_READY` / `isPlaying=true` (the AudioTrack has simply stopped draining + * what ExoPlayer feeds it). That's consistent with what was observed: `play()` does nothing once + * this happens (from the player's point of view, `playWhenReady` is already `true` — there's + * nothing to resume), while `seekToNext()` still works (it tears down and rebuilds the sink for + * the new item, sidestepping the wedged one — and then wedges again on that new item too, since + * whatever the underlying condition is hasn't changed). + * + * Since there's no state transition to hook, this is driven by a timer polling the player's own + * reported position instead: called once per tick while ticking at a fixed interval (see + * [WearPlaybackService]), it turns "did the position actually move since last tick" into a + * consecutive-stall counter, so a real freeze (not just a brief legitimate pause in advancing) is + * required before anything reacts. + */ +internal fun wearPlaybackStalledTickCount( + isPlaying: Boolean, + positionAdvancedSinceLastTick: Boolean, + previousConsecutiveStalledTicks: Int, +): Int { + if (!isPlaying || positionAdvancedSinceLastTick) return 0 + return previousConsecutiveStalledTicks + 1 +} diff --git a/wear/src/test/java/com/theveloper/pixelplay/data/WearPlaybackStallWatchdogTest.kt b/wear/src/test/java/com/theveloper/pixelplay/data/WearPlaybackStallWatchdogTest.kt new file mode 100644 index 000000000..5bc9fdcec --- /dev/null +++ b/wear/src/test/java/com/theveloper/pixelplay/data/WearPlaybackStallWatchdogTest.kt @@ -0,0 +1,76 @@ +package com.theveloper.pixelplay.data + +import com.google.common.truth.Truth.assertThat +import org.junit.jupiter.api.Test + +class WearPlaybackStallWatchdogTest { + + @Test + fun `not playing resets the counter to zero`() { + val result = wearPlaybackStalledTickCount( + isPlaying = false, + positionAdvancedSinceLastTick = false, + previousConsecutiveStalledTicks = 2, + ) + + assertThat(result).isEqualTo(0) + } + + @Test + fun `playing with position advancing resets the counter to zero`() { + val result = wearPlaybackStalledTickCount( + isPlaying = true, + positionAdvancedSinceLastTick = true, + previousConsecutiveStalledTicks = 2, + ) + + assertThat(result).isEqualTo(0) + } + + @Test + fun `playing with a frozen position increments the counter`() { + val result = wearPlaybackStalledTickCount( + isPlaying = true, + positionAdvancedSinceLastTick = false, + previousConsecutiveStalledTicks = 1, + ) + + assertThat(result).isEqualTo(2) + } + + @Test + fun `a frozen position starting from zero counts as one stalled tick`() { + val result = wearPlaybackStalledTickCount( + isPlaying = true, + positionAdvancedSinceLastTick = false, + previousConsecutiveStalledTicks = 0, + ) + + assertThat(result).isEqualTo(1) + } + + @Test + fun `a single advancing tick after several stalled ones fully resets, not decrements`() { + val result = wearPlaybackStalledTickCount( + isPlaying = true, + positionAdvancedSinceLastTick = true, + previousConsecutiveStalledTicks = 5, + ) + + assertThat(result).isEqualTo(0) + } + + @Test + fun `three consecutive stalled ticks reaches the production threshold`() { + var ticks = 0 + repeat(3) { + ticks = wearPlaybackStalledTickCount( + isPlaying = true, + positionAdvancedSinceLastTick = false, + previousConsecutiveStalledTicks = ticks, + ) + } + + assertThat(ticks).isEqualTo(3) + } +} From 4f6f25e042d2305d6c3a2db8bd9ff67b485afb95 Mon Sep 17 00:00:00 2001 From: PonceGL Date: Tue, 11 Aug 2026 14:17:28 -0600 Subject: [PATCH 40/40] fix(wear): keep the Wear capability resource from being shrunk in release MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 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". --- wear/src/main/res/raw/keep.xml | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 wear/src/main/res/raw/keep.xml diff --git a/wear/src/main/res/raw/keep.xml b/wear/src/main/res/raw/keep.xml new file mode 100644 index 000000000..704ebd9ad --- /dev/null +++ b/wear/src/main/res/raw/keep.xml @@ -0,0 +1,12 @@ + + +