From 7197e5846da5300d5064e01c383f35b26f2628fe Mon Sep 17 00:00:00 2001 From: PonceGL Date: Sat, 8 Aug 2026 12:44:45 -0600 Subject: [PATCH 01/15] 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 638664de37c6aa70acd3f19af23809ada335124f Mon Sep 17 00:00:00 2001 From: PonceGL Date: Sat, 8 Aug 2026 12:44:54 -0600 Subject: [PATCH 02/15] 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 a253a78299806328ef6af6138f29914e3e8ef113 Mon Sep 17 00:00:00 2001 From: PonceGL Date: Sat, 8 Aug 2026 12:45:00 -0600 Subject: [PATCH 03/15] 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 acbe18374d3af6116c3e00ad09e04f02c19d5e17 Mon Sep 17 00:00:00 2001 From: PonceGL Date: Sat, 8 Aug 2026 12:45:11 -0600 Subject: [PATCH 04/15] 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 e5d29b4255cfe01dd7f7a1bec0da6f28b9332863 Mon Sep 17 00:00:00 2001 From: PonceGL Date: Sat, 8 Aug 2026 12:45:17 -0600 Subject: [PATCH 05/15] 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 4e50199cf822d61eea1e61bfefa058165993ce78 Mon Sep 17 00:00:00 2001 From: PonceGL Date: Sat, 8 Aug 2026 12:45:30 -0600 Subject: [PATCH 06/15] 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 1387c9b10995bcba62156aa03eb8bbe711a6347c Mon Sep 17 00:00:00 2001 From: PonceGL Date: Sat, 8 Aug 2026 13:13:43 -0600 Subject: [PATCH 07/15] 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 926e977a790663383ea502ff1dc266e7f89902ab Mon Sep 17 00:00:00 2001 From: PonceGL Date: Sat, 8 Aug 2026 13:15:12 -0600 Subject: [PATCH 08/15] 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 bfbddb8154ac302b5c19cc1677696d298b016b51 Mon Sep 17 00:00:00 2001 From: PonceGL Date: Sat, 8 Aug 2026 13:15:19 -0600 Subject: [PATCH 09/15] 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 649c5472c0c9f4a9c02512e062ca0f9296da15dd Mon Sep 17 00:00:00 2001 From: PonceGL Date: Sat, 8 Aug 2026 13:15:25 -0600 Subject: [PATCH 10/15] 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 1848b90f5d77d122533c94b713af354fc9a08eac Mon Sep 17 00:00:00 2001 From: PonceGL Date: Sat, 8 Aug 2026 14:52:09 -0600 Subject: [PATCH 11/15] 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 c0ff2fd029661a4f7a5de40b47cfe6c7135f49fb Mon Sep 17 00:00:00 2001 From: PonceGL Date: Sat, 8 Aug 2026 14:52:14 -0600 Subject: [PATCH 12/15] 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 84350e7851a41d56eb2eb4f3574356c6326677c1 Mon Sep 17 00:00:00 2001 From: PonceGL Date: Sat, 8 Aug 2026 14:52:24 -0600 Subject: [PATCH 13/15] 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 20b9a0d7b8283b82c7ce545e3bfd1c211e73674f Mon Sep 17 00:00:00 2001 From: PonceGL Date: Sun, 9 Aug 2026 09:53:58 -0600 Subject: [PATCH 14/15] 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 56ff14362dccf6ead346979c51b7a43ec1182799 Mon Sep 17 00:00:00 2001 From: PonceGL Date: Sun, 9 Aug 2026 09:54:07 -0600 Subject: [PATCH 15/15] 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() } + } }