From 018e1e50a88c554d47204b5d5b2ef9012fa7b473 Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Wed, 15 Apr 2026 20:42:48 +0530 Subject: [PATCH] feat: implement manifest caching for addons --- .../features/addons/AddonPlatform.android.kt | 13 ++ .../app/features/addons/AddonManifestCache.kt | 31 +++ .../app/features/addons/AddonPlatform.kt | 4 + .../app/features/addons/AddonRepository.kt | 216 ++++++++++++------ .../app/features/addons/AddonPlatform.ios.kt | 13 ++ 5 files changed, 201 insertions(+), 76 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/addons/AddonManifestCache.kt diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/addons/AddonPlatform.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/addons/AddonPlatform.android.kt index 2c99ed0f..efab0b1e 100644 --- a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/addons/AddonPlatform.android.kt +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/addons/AddonPlatform.android.kt @@ -19,6 +19,7 @@ import java.util.concurrent.TimeUnit actual object AddonStorage { private const val preferencesName = "nuvio_addons" private const val addonUrlsKey = "installed_manifest_urls" + private const val manifestCacheKey = "manifest_cache_payload" private var preferences: SharedPreferences? = null @@ -41,8 +42,20 @@ actual object AddonStorage { ?.putString("${addonUrlsKey}_$profileId", urls.joinToString(separator = "\n")) ?.apply() } + + actual fun loadManifestCachePayload(profileId: Int): String? = + preferences?.getString("${manifestCacheKey}_$profileId", null) + + actual fun saveManifestCachePayload(profileId: Int, payload: String) { + preferences + ?.edit() + ?.putString("${manifestCacheKey}_$profileId", payload) + ?.apply() + } } +internal actual fun addonEpochMs(): Long = System.currentTimeMillis() + private val addonHttpClient = OkHttpClient.Builder() .dns(IPv4FirstDns()) .connectTimeout(60, TimeUnit.SECONDS) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/addons/AddonManifestCache.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/addons/AddonManifestCache.kt new file mode 100644 index 00000000..58396432 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/addons/AddonManifestCache.kt @@ -0,0 +1,31 @@ +package com.nuvio.app.features.addons + +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json + +@Serializable +internal data class AddonManifestCachePayload( + val entries: List = emptyList(), +) + +@Serializable +internal data class AddonManifestCacheEntry( + val manifestUrl: String, + val payload: String, + val fetchedAtEpochMs: Long, +) + +internal object AddonManifestCacheCodec { + private val json = Json { ignoreUnknownKeys = true; encodeDefaults = true } + + fun decode(payload: String): List? = + runCatching { + json.decodeFromString(AddonManifestCachePayload.serializer(), payload).entries + }.getOrNull() + + fun encode(entries: Collection): String = + json.encodeToString( + AddonManifestCachePayload.serializer(), + AddonManifestCachePayload(entries = entries.toList()), + ) +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/addons/AddonPlatform.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/addons/AddonPlatform.kt index 90ddf249..d77565ce 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/addons/AddonPlatform.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/addons/AddonPlatform.kt @@ -3,8 +3,12 @@ package com.nuvio.app.features.addons internal expect object AddonStorage { fun loadInstalledAddonUrls(profileId: Int): List fun saveInstalledAddonUrls(profileId: Int, urls: List) + fun loadManifestCachePayload(profileId: Int): String? + fun saveManifestCachePayload(profileId: Int, payload: String) } +internal expect fun addonEpochMs(): Long + data class RawHttpResponse( val status: Int, val statusText: String, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/addons/AddonRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/addons/AddonRepository.kt index 79ce45da..ead68eb3 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/addons/AddonRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/addons/AddonRepository.kt @@ -51,6 +51,7 @@ object AddonRepository { private var pulledFromServer = false private var currentProfileId: Int = 1 private val activeRefreshJobs = mutableMapOf() + private var manifestCacheByUrl: Map = emptyMap() fun initialize() { val effectiveProfileId = resolveEffectiveProfileId(ProfileRepository.activeProfileId) @@ -61,21 +62,12 @@ object AddonRepository { val storedUrls = dedupeManifestUrls(AddonStorage.loadInstalledAddonUrls(currentProfileId)) log.d { "initialize() — local addon count: ${storedUrls.size}" } - if (storedUrls.isEmpty()) return - - val existingByUrl = _uiState.value.addons.associateBy(ManagedAddon::manifestUrl) - _uiState.value = AddonsUiState( - addons = storedUrls.map { manifestUrl -> - existingByUrl[manifestUrl].toPendingAddon(manifestUrl) - }, - ) - - storedUrls.forEach { manifestUrl -> - val existing = existingByUrl[manifestUrl] - if (existing == null || (existing.manifest == null && !existing.isRefreshing)) { - refreshAddon(manifestUrl) - } + if (storedUrls.isEmpty()) { + manifestCacheByUrl = emptyMap() + return } + + applyAddonsFromUrls(storedUrls) } fun onProfileChanged(profileId: Int) { @@ -85,11 +77,15 @@ object AddonRepository { currentProfileId = effectiveProfileId initialized = false pulledFromServer = false + manifestCacheByUrl = emptyMap() _uiState.value = AddonsUiState() } fun clearLocalState() { + val profileToClear = currentProfileId cancelActiveRefreshes() + manifestCacheByUrl = emptyMap() + AddonStorage.saveManifestCachePayload(profileToClear, AddonManifestCacheCodec.encode(emptyList())) currentProfileId = 1 initialized = false pulledFromServer = false @@ -149,38 +145,16 @@ object AddonRepository { val localUrls = dedupeManifestUrls(AddonStorage.loadInstalledAddonUrls(currentProfileId)) if (localUrls.isNotEmpty()) { log.w { "pullFromServer() — remote empty while local has ${localUrls.size} addons; preserving local addons" } - val existingByUrl = _uiState.value.addons.associateBy(ManagedAddon::manifestUrl) - _uiState.value = AddonsUiState( - addons = localUrls.map { url -> - existingByUrl[url].toPendingAddon(url) - }, - ) + applyAddonsFromUrls(localUrls) persist() - localUrls.forEach { url -> - val existing = existingByUrl[url] - if (existing == null || (existing.manifest == null && !existing.isRefreshing)) { - refreshAddon(url) - } - } pulledFromServer = true initialized = true return } } - val existingByUrl = _uiState.value.addons.associateBy(ManagedAddon::manifestUrl) - _uiState.value = AddonsUiState( - addons = urls.map { url -> - existingByUrl[url].toPendingAddon(url, namesByUrl[url]) - }, - ) + applyAddonsFromUrls(urls, namesByUrl) persist() - urls.forEach { url -> - val existing = existingByUrl[url] - if (existing == null || (existing.manifest == null && !existing.isRefreshing)) { - refreshAddon(url) - } - } pulledFromServer = true initialized = true log.i { "pullFromServer() — applied ${urls.size} addons to state" } @@ -211,17 +185,19 @@ object AddonRepository { return AddAddonResult.Error("That addon is already installed.") } - val manifest = try { + val fetched = try { withContext(Dispatchers.Default) { val payload = httpGetText(manifestUrl) - AddonManifestParser.parse( + val manifest = AddonManifestParser.parse( manifestUrl = manifestUrl, payload = payload, ) + payload to manifest } } catch (error: Throwable) { return AddAddonResult.Error(error.message ?: "Unable to load manifest") } + val (payload, manifest) = fetched _uiState.update { current -> current.copy( @@ -233,6 +209,7 @@ object AddonRepository { ), ) } + updateManifestCache(manifestUrl, payload) persist() pushToServer() return AddAddonResult.Success(manifest) @@ -241,11 +218,16 @@ object AddonRepository { fun removeAddon(manifestUrl: String) { if (isUsingPrimaryAddonsFromSecondaryProfile()) return log.i { "removeAddon() — $manifestUrl" } + val normalizedUrl = ensureManifestSuffix(manifestUrl) _uiState.update { current -> current.copy( - addons = current.addons.filterNot { it.manifestUrl == manifestUrl }, + addons = current.addons.filterNot { it.manifestUrl == normalizedUrl }, ) } + if (manifestCacheByUrl.containsKey(normalizedUrl)) { + manifestCacheByUrl = manifestCacheByUrl - normalizedUrl + persistManifestCache() + } persist() pushToServer() } @@ -257,29 +239,34 @@ object AddonRepository { } fun refreshAddon(manifestUrl: String) { - val existingJob = activeRefreshJobs[manifestUrl] + val normalizedUrl = ensureManifestSuffix(manifestUrl) + val existingJob = activeRefreshJobs[normalizedUrl] if (existingJob?.isActive == true) return - markRefreshing(manifestUrl) + markRefreshing(normalizedUrl) var refreshJob: Job? = null refreshJob = scope.launch { try { val result = runCatching { - val payload = httpGetText(manifestUrl) - AddonManifestParser.parse( - manifestUrl = manifestUrl, + val payload = httpGetText(normalizedUrl) + val manifest = AddonManifestParser.parse( + manifestUrl = normalizedUrl, payload = payload, ) + payload to manifest + } + result.onSuccess { (payload, _) -> + updateManifestCache(normalizedUrl, payload) } _uiState.update { current -> current.copy( addons = current.addons.map { addon -> - if (addon.manifestUrl != manifestUrl) { + if (addon.manifestUrl != normalizedUrl) { addon } else { result.fold( - onSuccess = { manifest -> + onSuccess = { (_, manifest) -> addon.copy( manifest = manifest, isRefreshing = false, @@ -289,7 +276,7 @@ object AddonRepository { onFailure = { error -> addon.copy( isRefreshing = false, - errorMessage = error.message ?: "Unable to load manifest", + errorMessage = error.message ?: addon.errorMessage ?: "Unable to load manifest", ) }, ) @@ -298,12 +285,111 @@ object AddonRepository { ) } } finally { - if (activeRefreshJobs[manifestUrl] === refreshJob) { - activeRefreshJobs.remove(manifestUrl) + if (activeRefreshJobs[normalizedUrl] === refreshJob) { + activeRefreshJobs.remove(normalizedUrl) } } } - activeRefreshJobs[manifestUrl] = refreshJob + activeRefreshJobs[normalizedUrl] = refreshJob + } + + private fun applyAddonsFromUrls( + urls: List, + namesByUrl: Map = emptyMap(), + ) { + val normalizedUrls = dedupeManifestUrls(urls) + val normalizedUrlSet = normalizedUrls.toSet() + val now = addonEpochMs() + val existingByUrl = _uiState.value.addons.associateBy(ManagedAddon::manifestUrl) + val loadedCache = loadManifestCacheByUrl() + val nextCache = loadedCache.filterKeys { key -> key in normalizedUrlSet }.toMutableMap() + + val addons = normalizedUrls.map { manifestUrl -> + val existing = existingByUrl[manifestUrl] + val cachedEntry = nextCache[manifestUrl] + val cachedManifest = cachedEntry + ?.takeIf { it.payload.isNotBlank() } + ?.let { entry -> + runCatching { + AddonManifestParser.parse( + manifestUrl = manifestUrl, + payload = entry.payload, + ) + }.getOrNull() + } + + if (cachedEntry != null && cachedManifest == null) { + nextCache.remove(manifestUrl) + } + + val manifest = existing?.manifest ?: cachedManifest + val shouldRefresh = when { + manifest == null -> true + existing?.manifest != null && !existing.isRefreshing -> false + cachedEntry == null -> true + cachedEntry.fetchedAtEpochMs <= 0L -> true + now - cachedEntry.fetchedAtEpochMs >= MANIFEST_CACHE_TTL_MS -> true + else -> false + } + + ManagedAddon( + manifestUrl = manifestUrl, + manifest = manifest, + userSetName = namesByUrl[manifestUrl] ?: existing?.userSetName, + isRefreshing = shouldRefresh, + errorMessage = if (manifest != null) null else existing?.errorMessage, + ) + } + + manifestCacheByUrl = nextCache.toMap() + persistManifestCache() + + _uiState.value = AddonsUiState(addons = addons) + addons.filter { it.isRefreshing }.forEach { addon -> + refreshAddon(addon.manifestUrl) + } + } + + private fun loadManifestCacheByUrl(): Map { + val payload = AddonStorage.loadManifestCachePayload(currentProfileId).orEmpty() + if (payload.isBlank()) { + manifestCacheByUrl = emptyMap() + return manifestCacheByUrl + } + + val decoded = AddonManifestCacheCodec.decode(payload) + ?.mapNotNull { entry -> + val normalizedUrl = ensureManifestSuffix(entry.manifestUrl) + if (entry.payload.isBlank()) { + null + } else { + entry.copy(manifestUrl = normalizedUrl) + } + } + ?.associateBy(AddonManifestCacheEntry::manifestUrl) + .orEmpty() + manifestCacheByUrl = decoded + return decoded + } + + private fun updateManifestCache(manifestUrl: String, payload: String) { + if (payload.isBlank()) return + val normalizedUrl = ensureManifestSuffix(manifestUrl) + manifestCacheByUrl = manifestCacheByUrl + ( + normalizedUrl to AddonManifestCacheEntry( + manifestUrl = normalizedUrl, + payload = payload, + fetchedAtEpochMs = addonEpochMs(), + ) + ) + persistManifestCache() + } + + private fun persistManifestCache() { + AddonStorage.saveManifestCachePayload( + profileId = currentProfileId, + payload = AddonManifestCacheCodec.encode(manifestCacheByUrl.values), + ) } private fun pushToServer() { @@ -376,30 +462,6 @@ object AddonRepository { } } -private fun ManagedAddon?.toPendingAddon(manifestUrl: String, userSetName: String? = null): ManagedAddon = - when { - this == null -> ManagedAddon( - manifestUrl = manifestUrl, - isRefreshing = true, - userSetName = userSetName, - ) - manifest != null -> copy( - manifestUrl = manifestUrl, - isRefreshing = false, - userSetName = userSetName ?: this.userSetName, - ) - isRefreshing -> copy( - manifestUrl = manifestUrl, - userSetName = userSetName ?: this.userSetName, - ) - else -> copy( - manifestUrl = manifestUrl, - isRefreshing = true, - errorMessage = null, - userSetName = userSetName ?: this.userSetName, - ) - } - private fun dedupeManifestUrls(urls: List): List = urls.map(::ensureManifestSuffix).distinct() @@ -431,3 +493,5 @@ private fun normalizeManifestUrl(rawUrl: String): String { return if (query.isEmpty()) manifestPath else "$manifestPath?$query" } + +private const val MANIFEST_CACHE_TTL_MS = 12L * 60L * 60L * 1000L diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/addons/AddonPlatform.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/addons/AddonPlatform.ios.kt index 1d628939..450dd525 100644 --- a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/addons/AddonPlatform.ios.kt +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/addons/AddonPlatform.ios.kt @@ -15,10 +15,13 @@ import io.ktor.http.ContentType import io.ktor.http.HttpHeaders import io.ktor.http.HttpMethod import io.ktor.http.isSuccess +import platform.Foundation.NSDate import platform.Foundation.NSUserDefaults +import platform.Foundation.timeIntervalSince1970 actual object AddonStorage { private const val addonUrlsKey = "installed_manifest_urls" + private const val manifestCacheKey = "manifest_cache_payload" actual fun loadInstalledAddonUrls(profileId: Int): List = NSUserDefaults.standardUserDefaults @@ -35,8 +38,18 @@ actual object AddonStorage { forKey = "${addonUrlsKey}_$profileId", ) } + + actual fun loadManifestCachePayload(profileId: Int): String? = + NSUserDefaults.standardUserDefaults.stringForKey("${manifestCacheKey}_$profileId") + + actual fun saveManifestCachePayload(profileId: Int, payload: String) { + NSUserDefaults.standardUserDefaults.setObject(payload, forKey = "${manifestCacheKey}_$profileId") + } } +internal actual fun addonEpochMs(): Long = + (NSDate().timeIntervalSince1970 * 1000.0).toLong() + private val addonHttpClient = HttpClient(Darwin) { install(HttpTimeout) { requestTimeoutMillis = 60_000