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 efab0b1e..2c99ed0f 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,7 +19,6 @@ 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 @@ -42,20 +41,8 @@ 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 deleted file mode 100644 index 58396432..00000000 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/addons/AddonManifestCache.kt +++ /dev/null @@ -1,31 +0,0 @@ -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 d77565ce..90ddf249 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,12 +3,8 @@ 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 ead68eb3..79ce45da 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,7 +51,6 @@ 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) @@ -62,12 +61,21 @@ object AddonRepository { val storedUrls = dedupeManifestUrls(AddonStorage.loadInstalledAddonUrls(currentProfileId)) log.d { "initialize() — local addon count: ${storedUrls.size}" } - if (storedUrls.isEmpty()) { - manifestCacheByUrl = emptyMap() - return - } + if (storedUrls.isEmpty()) return - applyAddonsFromUrls(storedUrls) + 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) + } + } } fun onProfileChanged(profileId: Int) { @@ -77,15 +85,11 @@ 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 @@ -145,16 +149,38 @@ 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" } - applyAddonsFromUrls(localUrls) + val existingByUrl = _uiState.value.addons.associateBy(ManagedAddon::manifestUrl) + _uiState.value = AddonsUiState( + addons = localUrls.map { url -> + existingByUrl[url].toPendingAddon(url) + }, + ) persist() + localUrls.forEach { url -> + val existing = existingByUrl[url] + if (existing == null || (existing.manifest == null && !existing.isRefreshing)) { + refreshAddon(url) + } + } pulledFromServer = true initialized = true return } } - applyAddonsFromUrls(urls, namesByUrl) + val existingByUrl = _uiState.value.addons.associateBy(ManagedAddon::manifestUrl) + _uiState.value = AddonsUiState( + addons = urls.map { url -> + existingByUrl[url].toPendingAddon(url, namesByUrl[url]) + }, + ) 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" } @@ -185,19 +211,17 @@ object AddonRepository { return AddAddonResult.Error("That addon is already installed.") } - val fetched = try { + val manifest = try { withContext(Dispatchers.Default) { val payload = httpGetText(manifestUrl) - val manifest = AddonManifestParser.parse( + 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( @@ -209,7 +233,6 @@ object AddonRepository { ), ) } - updateManifestCache(manifestUrl, payload) persist() pushToServer() return AddAddonResult.Success(manifest) @@ -218,16 +241,11 @@ 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 == normalizedUrl }, + addons = current.addons.filterNot { it.manifestUrl == manifestUrl }, ) } - if (manifestCacheByUrl.containsKey(normalizedUrl)) { - manifestCacheByUrl = manifestCacheByUrl - normalizedUrl - persistManifestCache() - } persist() pushToServer() } @@ -239,34 +257,29 @@ object AddonRepository { } fun refreshAddon(manifestUrl: String) { - val normalizedUrl = ensureManifestSuffix(manifestUrl) - val existingJob = activeRefreshJobs[normalizedUrl] + val existingJob = activeRefreshJobs[manifestUrl] if (existingJob?.isActive == true) return - markRefreshing(normalizedUrl) + markRefreshing(manifestUrl) var refreshJob: Job? = null refreshJob = scope.launch { try { val result = runCatching { - val payload = httpGetText(normalizedUrl) - val manifest = AddonManifestParser.parse( - manifestUrl = normalizedUrl, + val payload = httpGetText(manifestUrl) + AddonManifestParser.parse( + manifestUrl = manifestUrl, payload = payload, ) - payload to manifest - } - result.onSuccess { (payload, _) -> - updateManifestCache(normalizedUrl, payload) } _uiState.update { current -> current.copy( addons = current.addons.map { addon -> - if (addon.manifestUrl != normalizedUrl) { + if (addon.manifestUrl != manifestUrl) { addon } else { result.fold( - onSuccess = { (_, manifest) -> + onSuccess = { manifest -> addon.copy( manifest = manifest, isRefreshing = false, @@ -276,7 +289,7 @@ object AddonRepository { onFailure = { error -> addon.copy( isRefreshing = false, - errorMessage = error.message ?: addon.errorMessage ?: "Unable to load manifest", + errorMessage = error.message ?: "Unable to load manifest", ) }, ) @@ -285,111 +298,12 @@ object AddonRepository { ) } } finally { - if (activeRefreshJobs[normalizedUrl] === refreshJob) { - activeRefreshJobs.remove(normalizedUrl) + if (activeRefreshJobs[manifestUrl] === refreshJob) { + activeRefreshJobs.remove(manifestUrl) } } } - 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), - ) + activeRefreshJobs[manifestUrl] = refreshJob } private fun pushToServer() { @@ -462,6 +376,30 @@ 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() @@ -493,5 +431,3 @@ 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 450dd525..1d628939 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,13 +15,10 @@ 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 @@ -38,18 +35,8 @@ 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