fix(simkl): refresh watch state after manual sync

This commit is contained in:
tapframe 2026-07-23 21:27:27 +05:30
parent 4f3eaee2cc
commit 9b278e6cb0
6 changed files with 181 additions and 14 deletions

View file

@ -57,6 +57,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.nuvio.app.core.ui.NuvioLoadingIndicator
import com.nuvio.app.core.ui.NuvioTokens
import com.nuvio.app.core.ui.nuvio
import com.nuvio.app.features.profiles.ProfileRepository
import com.nuvio.app.features.simkl.SimklAuthError
import com.nuvio.app.features.simkl.SimklAuthRepository
import com.nuvio.app.features.simkl.SimklAuthUiState
@ -64,12 +65,14 @@ import com.nuvio.app.features.simkl.SimklBrandAsset
import com.nuvio.app.features.simkl.SimklConnectionMode
import com.nuvio.app.features.simkl.SimklSyncRepository
import com.nuvio.app.features.simkl.simklBrandPainter
import com.nuvio.app.features.tracking.TrackingProviderId
import com.nuvio.app.features.tracking.TrackingRefreshIntent
import com.nuvio.app.features.trakt.TraktAuthRepository
import com.nuvio.app.features.trakt.TraktAuthUiState
import com.nuvio.app.features.trakt.TraktBrandAsset
import com.nuvio.app.features.trakt.TraktConnectionMode
import com.nuvio.app.features.trakt.traktBrandPainter
import com.nuvio.app.features.watchprogress.WatchProgressSourceCoordinator
import kotlinx.coroutines.launch
import nuvio.composeapp.generated.resources.Res
import nuvio.composeapp.generated.resources.action_cancel
@ -156,6 +159,17 @@ internal fun TrackingProviderCards(
}.collectAsStateWithLifecycle()
val scope = rememberCoroutineScope()
var showSyncInfo by rememberSaveable { mutableStateOf(false) }
val onSimklSyncRequested: () -> Unit = {
scope.launch {
WatchProgressSourceCoordinator.refreshProviderAndActiveSource(
profileId = ProfileRepository.activeProfileId,
providerId = TrackingProviderId.SIMKL,
refreshProvider = {
SimklSyncRepository.refresh(TrackingRefreshIntent.USER_INITIATED)
},
)
}
}
BoxWithConstraints(modifier = Modifier.fillMaxWidth()) {
val useTwoColumns = maxWidth >= 600.dp
@ -176,11 +190,7 @@ internal fun TrackingProviderCards(
uiState = simklUiState,
isSyncing = syncState.isLoading,
syncErrorMessage = syncState.errorMessage,
onSyncRequested = {
scope.launch {
SimklSyncRepository.refresh(TrackingRefreshIntent.USER_INITIATED)
}
},
onSyncRequested = onSimklSyncRequested,
onInfoRequested = { showSyncInfo = true },
modifier = Modifier
.weight(1f)
@ -200,11 +210,7 @@ internal fun TrackingProviderCards(
uiState = simklUiState,
isSyncing = syncState.isLoading,
syncErrorMessage = syncState.errorMessage,
onSyncRequested = {
scope.launch {
SimklSyncRepository.refresh(TrackingRefreshIntent.USER_INITIATED)
}
},
onSyncRequested = onSimklSyncRequested,
onInfoRequested = { showSyncInfo = true },
modifier = Modifier.fillMaxWidth(),
)

View file

@ -73,23 +73,23 @@ object SimklSyncRepository : TrackingProfileStore {
scope.launch { refresh(intent, origin) }
}
suspend fun refresh(intent: TrackingRefreshIntent) {
suspend fun refresh(intent: TrackingRefreshIntent): Boolean =
refresh(intent, SimklRefreshOrigin.MANUAL_SYNC)
}
internal suspend fun refresh(
intent: TrackingRefreshIntent,
origin: SimklRefreshOrigin,
) {
): Boolean {
ensureLoaded()
val requestId = refreshRequestSequence.incrementAndGet()
val requestedGeneration = profileGeneration
val requestedProfileId = ProfileRepository.activeProfileId
val before = _state.value
SimklWatchDiagnostics.logRefreshRequest(
requestId = requestId,
origin = origin,
intent = intent,
profileId = ProfileRepository.activeProfileId,
profileId = requestedProfileId,
profileGeneration = requestedGeneration,
authenticated = SimklAuthRepository.isAuthenticated.value,
snapshot = before.snapshot,
@ -132,6 +132,12 @@ object SimklSyncRepository : TrackingProfileStore {
before = before,
after = _state.value,
)
val completed = _state.value
return requestedGeneration == profileGeneration &&
requestedProfileId == ProfileRepository.activeProfileId &&
SimklAuthRepository.isAuthenticated.value &&
completed.hasLoaded &&
completed.errorMessage == null
}
private suspend fun refreshSnapshot(generation: Long) {

View file

@ -0,0 +1,14 @@
package com.nuvio.app.features.watchprogress
import com.nuvio.app.features.tracking.TrackingProviderId
internal suspend fun coordinateTrackingProviderRefresh(
providerId: TrackingProviderId,
refreshProvider: suspend () -> Boolean,
activeProviderId: () -> TrackingProviderId?,
refreshActiveReadModels: suspend () -> Boolean,
): Boolean {
if (!refreshProvider()) return false
if (activeProviderId() != providerId) return true
return refreshActiveReadModels()
}

View file

@ -309,6 +309,22 @@ object WatchProgressSourceCoordinator {
}
}
suspend fun refreshProviderAndActiveSource(
profileId: Int,
providerId: TrackingProviderId,
refreshProvider: suspend () -> Boolean,
): Boolean = coordinateTrackingProviderRefresh(
providerId = providerId,
refreshProvider = refreshProvider,
activeProviderId = {
ensureSourceStateLoaded()
currentContext(profileId).effectiveSource.providerId
},
refreshActiveReadModels = {
refreshActiveSource(profileId = profileId, force = false).succeeded
},
)
private fun ensureSourceStateLoadedForGeneration(expectedGeneration: Long) {
synchronized(startLock) {
ensureCoordinatorGeneration(expectedGeneration)

View file

@ -152,6 +152,44 @@ class SimklRefreshPolicyTest {
assertEquals(1, executions)
}
@Test
fun `manual sync followed by read model refresh executes once`() = runBlocking {
val gate = SimklRefreshGate()
var lastCheckedAtEpochMs: Long? = null
var executions = 0
val outcomes = listOf(
TrackingRefreshIntent.USER_INITIATED,
TrackingRefreshIntent.AUTOMATIC,
TrackingRefreshIntent.AUTOMATIC,
).map { intent ->
gate.runIfNeeded(
profileGeneration = 7L,
shouldRun = {
shouldRunSimklRefresh(
intent = intent,
lastCheckedAtEpochMs = lastCheckedAtEpochMs,
nowEpochMs = 1_000L,
hasError = false,
)
},
) {
executions += 1
lastCheckedAtEpochMs = 1_000L
}
}
assertEquals(
listOf(
SimklRefreshGateOutcome.EXECUTED,
SimklRefreshGateOutcome.FRESHNESS_SKIPPED,
SimklRefreshGateOutcome.FRESHNESS_SKIPPED,
),
outcomes,
)
assertEquals(1, executions)
}
@Test
fun `mutation invalidation still refreshes after startup check`() = runBlocking {
val gate = SimklRefreshGate()

View file

@ -0,0 +1,87 @@
package com.nuvio.app.features.watchprogress
import com.nuvio.app.features.tracking.TrackingProviderId
import kotlinx.coroutines.runBlocking
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class TrackingProviderRefreshCoordinatorTest {
@Test
fun `successful active provider refresh republishes read models in order`() = runBlocking {
val events = mutableListOf<String>()
val result = coordinateTrackingProviderRefresh(
providerId = TrackingProviderId.SIMKL,
refreshProvider = {
events += "provider"
true
},
activeProviderId = {
events += "source"
TrackingProviderId.SIMKL
},
refreshActiveReadModels = {
events += "read-models"
true
},
)
assertTrue(result)
assertEquals(listOf("provider", "source", "read-models"), events)
}
@Test
fun `inactive provider refresh does not refresh another source`() = runBlocking {
var readModelRefreshes = 0
val result = coordinateTrackingProviderRefresh(
providerId = TrackingProviderId.SIMKL,
refreshProvider = { true },
activeProviderId = { TrackingProviderId.TRAKT },
refreshActiveReadModels = {
readModelRefreshes += 1
true
},
)
assertTrue(result)
assertEquals(0, readModelRefreshes)
}
@Test
fun `failed provider refresh does not publish read models`() = runBlocking {
var sourceReads = 0
var readModelRefreshes = 0
val result = coordinateTrackingProviderRefresh(
providerId = TrackingProviderId.SIMKL,
refreshProvider = { false },
activeProviderId = {
sourceReads += 1
TrackingProviderId.SIMKL
},
refreshActiveReadModels = {
readModelRefreshes += 1
true
},
)
assertFalse(result)
assertEquals(0, sourceReads)
assertEquals(0, readModelRefreshes)
}
@Test
fun `read model refresh failure is surfaced`() = runBlocking {
val result = coordinateTrackingProviderRefresh(
providerId = TrackingProviderId.SIMKL,
refreshProvider = { true },
activeProviderId = { TrackingProviderId.SIMKL },
refreshActiveReadModels = { false },
)
assertFalse(result)
}
}