fix(simkl): refresh settings from activity changes

This commit is contained in:
tapframe 2026-07-22 20:29:17 +05:30
parent e020b9700a
commit 9798482fff
6 changed files with 114 additions and 7 deletions

View file

@ -103,7 +103,7 @@ internal class SimklApiClient(
url = buildSimklApiUrl(request.path, request.query),
headers = simklRequestHeaders(
accessToken = token,
contentTypeJson = request.body.isNotEmpty(),
contentTypeJson = request.method == SimklHttpMethod.POST,
),
body = request.body,
)

View file

@ -33,6 +33,8 @@ data class SimklAuthUiState(
internal data class SimklStoredAuthState(
val username: String? = null,
val accountId: Long? = null,
val hasFetchedUserSettings: Boolean = false,
val settingsActivityWatermark: String? = null,
val tokenExpiresAtEpochMs: Long? = null,
val pendingAuthorizationState: String? = null,
val pendingAuthorizationStartedAtEpochMs: Long? = null,
@ -41,6 +43,24 @@ internal data class SimklStoredAuthState(
get() = !pendingAuthorizationState.isNullOrBlank()
}
internal enum class SimklSettingsRefreshAction {
NONE,
RECORD_WATERMARK,
FETCH,
}
internal fun simklSettingsRefreshAction(
state: SimklStoredAuthState,
activityWatermark: String?,
): SimklSettingsRefreshAction = when {
activityWatermark.isNullOrBlank() -> SimklSettingsRefreshAction.NONE
activityWatermark == state.settingsActivityWatermark -> SimklSettingsRefreshAction.NONE
state.settingsActivityWatermark == null && state.hasFetchedUserSettings -> {
SimklSettingsRefreshAction.RECORD_WATERMARK
}
else -> SimklSettingsRefreshAction.FETCH
}
internal sealed interface SimklAuthCallback {
data class AuthorizationCode(
val code: String,

View file

@ -187,7 +187,21 @@ object SimklAuthRepository : TrackingAuthProvider {
suspend fun refreshUserSettings(): String? {
authorizedAccessToken() ?: return null
return fetchAndStoreUserSettings()
return if (fetchAndStoreUserSettings()) storedState.username else null
}
internal suspend fun synchronizeUserSettings(activityWatermark: String?) {
authorizedAccessToken() ?: return
when (simklSettingsRefreshAction(storedState, activityWatermark)) {
SimklSettingsRefreshAction.NONE -> Unit
SimklSettingsRefreshAction.RECORD_WATERMARK -> {
storedState = storedState.copy(settingsActivityWatermark = activityWatermark)
persistMetadata()
}
SimklSettingsRefreshAction.FETCH -> {
fetchAndStoreUserSettings(activityWatermark)
}
}
}
private suspend fun completeAuthorization(callback: SimklAuthCallback.AuthorizationCode) =
@ -261,30 +275,31 @@ object SimklAuthRepository : TrackingAuthProvider {
SimklSyncRepository.refreshAsync(TrackingRefreshIntent.INVALIDATED)
}
private suspend fun fetchAndStoreUserSettings(): String? {
private suspend fun fetchAndStoreUserSettings(activityWatermark: String? = null): Boolean {
val response = try {
SimklApi.client.execute(
SimklApiRequest(
method = SimklHttpMethod.POST,
path = "/users/settings",
body = "{}",
),
)
} catch (error: CancellationException) {
throw error
} catch (error: Throwable) {
log.w { "Failed to fetch Simkl user settings: ${error.message}" }
return null
return false
}
val settings = runCatching { json.decodeFromString<SimklUserSettingsResponse>(response.body) }
.getOrNull() ?: return null
.getOrNull() ?: return false
storedState = storedState.copy(
username = settings.user?.name,
accountId = settings.account?.id,
hasFetchedUserSettings = true,
settingsActivityWatermark = activityWatermark ?: storedState.settingsActivityWatermark,
)
persistMetadata()
publish(error = null)
return storedState.username
return true
}
private fun loadFromDisk() {

View file

@ -130,6 +130,8 @@ object SimklSyncRepository : TrackingProfileStore {
return
}
if (generation != profileGeneration || profileId != ProfileRepository.activeProfileId) return
SimklAuthRepository.synchronizeUserSettings(result.activities?.settings?.all)
if (generation != profileGeneration || profileId != ProfileRepository.activeProfileId) return
SimklSyncStorage.savePayload(json.encodeToString(result))
_state.value = SimklSyncUiState(

View file

@ -69,6 +69,23 @@ class SimklApiClientTest {
assertTrue(request.headers.getValue("User-Agent").contains('/'))
}
@Test
fun `bodyless post sends json content type without inventing a payload`() = runBlocking {
val engine = RecordingEngine(response(200))
val harness = TestHarness(engine)
harness.client.execute(
SimklApiRequest(
method = SimklHttpMethod.POST,
path = "/users/settings",
),
)
val request = engine.requests.single()
assertEquals("", request.body)
assertEquals("application/json", request.headers["Content-Type"])
}
@Test
fun `single use unauthenticated posts keep metadata and never retry`() = runBlocking {
val engine = RecordingEngine(response(503), response(200))

View file

@ -0,0 +1,53 @@
package com.nuvio.app.features.simkl
import kotlin.test.Test
import kotlin.test.assertEquals
class SimklSettingsPolicyTest {
@Test
fun `missing activity watermark does not fetch settings`() {
assertEquals(
SimklSettingsRefreshAction.NONE,
simklSettingsRefreshAction(SimklStoredAuthState(), null),
)
}
@Test
fun `first activity watermark is recorded after sign in settings fetch`() {
assertEquals(
SimklSettingsRefreshAction.RECORD_WATERMARK,
simklSettingsRefreshAction(
state = SimklStoredAuthState(hasFetchedUserSettings = true),
activityWatermark = "2026-07-22T08:48:07Z",
),
)
}
@Test
fun `changed activity watermark fetches settings and matching watermark skips`() {
val state = SimklStoredAuthState(
hasFetchedUserSettings = true,
settingsActivityWatermark = "2026-07-22T08:48:07Z",
)
assertEquals(
SimklSettingsRefreshAction.NONE,
simklSettingsRefreshAction(state, "2026-07-22T08:48:07Z"),
)
assertEquals(
SimklSettingsRefreshAction.FETCH,
simklSettingsRefreshAction(state, "2026-07-22T09:12:30Z"),
)
}
@Test
fun `legacy auth state fetches once before recording a watermark`() {
assertEquals(
SimklSettingsRefreshAction.FETCH,
simklSettingsRefreshAction(
state = SimklStoredAuthState(username = "Nuvio User"),
activityWatermark = "2026-07-22T08:48:07Z",
),
)
}
}