Skip to content
Merged
19 changes: 19 additions & 0 deletions app/src/main/java/com/theveloper/pixelplay/PixelPlayApplication.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -72,6 +73,9 @@ class PixelPlayApplication : Application(), ImageLoaderFactory, Configuration.Pr
@Inject
lateinit var advancedPerformanceDiagnosticsController: dagger.Lazy<AdvancedPerformanceDiagnosticsController>

@Inject
lateinit var playlistWatchTransferCoordinator: dagger.Lazy<PlaylistWatchTransferCoordinator>

private val startupScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)

// AÑADE EL COMPANION OBJECT
Expand Down Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
@@ -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<String>,
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<Preferences>` (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<Preferences>,
) {
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<PersistedPlaylistBatchIntent>(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"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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(
Expand All @@ -90,11 +116,22 @@ class PlaylistWatchTransferCoordinator @Inject constructor(
playlistName: String,
songIds: List<String>,
) {
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())
Expand Down Expand Up @@ -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)
}
}

Expand Down Expand Up @@ -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,
Expand Down
Loading