From 8c7a0b624c4e22e7f1a23ed827b704c3f2d66f1c Mon Sep 17 00:00:00 2001 From: Hassan Date: Sun, 19 Jul 2026 04:12:00 +0300 Subject: [PATCH] fix: persist server addon names across restarts --- .../features/addons/AddonPlatform.android.kt | 13 +++++++ .../nuvio/app/features/addons/AddonModels.kt | 36 +++++++++++++++++++ .../app/features/addons/AddonPlatform.kt | 2 ++ .../app/features/addons/AddonRepository.kt | 20 +++++++++-- .../app/features/addons/AddonModelsTest.kt | 31 ++++++++++++++++ .../PlatformLocalAccountDataCleaner.ios.kt | 1 + .../app/features/addons/AddonPlatform.ios.kt | 13 +++++++ 7 files changed, 113 insertions(+), 3 deletions(-) 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 cbe2f376c..44be6c4fb 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 @@ -26,6 +26,7 @@ actual object AddonStorage { private const val preferencesName = "nuvio_addons" private const val addonUrlsKey = "installed_manifest_urls" private const val addonEnabledStatesKey = "installed_manifest_enabled_states" + private const val addonNameOverridesKey = "installed_manifest_name_overrides" private var preferences: SharedPreferences? = null @@ -66,6 +67,18 @@ actual object AddonStorage { ?.putString("${addonEnabledStatesKey}_$profileId", payload) ?.apply() } + + actual fun loadAddonNameOverridesPayload(profileId: Int): String = + preferences + ?.getString("${addonNameOverridesKey}_$profileId", null) + .orEmpty() + + actual fun saveAddonNameOverridesPayload(profileId: Int, payload: String) { + preferences + ?.edit() + ?.putString("${addonNameOverridesKey}_$profileId", payload) + ?.apply() + } } private fun parseEnabledStateLine(line: String): Pair? { diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/addons/AddonModels.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/addons/AddonModels.kt index e20e44868..7562f9563 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/addons/AddonModels.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/addons/AddonModels.kt @@ -1,10 +1,24 @@ package com.nuvio.app.features.addons import kotlinx.coroutines.runBlocking +import kotlinx.serialization.Serializable +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json import nuvio.composeapp.generated.resources.Res import nuvio.composeapp.generated.resources.generic_addon import org.jetbrains.compose.resources.getString +@Serializable +private data class StoredAddonNameOverride( + val url: String, + val name: String, +) + +private val addonNameOverridesJson = Json { + ignoreUnknownKeys = true +} + data class AddonManifest( val id: String, val name: String, @@ -85,6 +99,28 @@ internal fun List.toOverview(): AddonOverview = internal fun List.enabledAddons(): List = filter { it.enabled } +internal fun List.encodeNameOverrides(): String = + addonNameOverridesJson.encodeToString( + mapNotNull { addon -> + addon.userSetName + ?.takeIf { it.isNotBlank() } + ?.let { name -> StoredAddonNameOverride(url = addon.manifestUrl, name = name) } + }, + ) + +internal fun decodeAddonNameOverrides(payload: String): Map { + if (payload.isBlank()) return emptyMap() + return runCatching { + addonNameOverridesJson.decodeFromString>(payload) + }.getOrDefault(emptyList()) + .mapNotNull { override -> + val url = override.url.trim().takeIf { it.isNotEmpty() } ?: return@mapNotNull null + val name = override.name.takeIf { it.isNotBlank() } ?: return@mapNotNull null + url to name + } + .toMap() +} + sealed interface AddAddonResult { data class Success(val manifest: AddonManifest) : AddAddonResult data class Error(val message: String) : AddAddonResult 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 75e68beb4..ecf2e1140 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 @@ -5,6 +5,8 @@ internal expect object AddonStorage { fun saveInstalledAddonUrls(profileId: Int, urls: List) fun loadAddonEnabledStates(profileId: Int): Map fun saveAddonEnabledStates(profileId: Int, states: Map) + fun loadAddonNameOverridesPayload(profileId: Int): String + fun saveAddonNameOverridesPayload(profileId: Int, payload: String) } data class RawHttpResponse( 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 42d2875cc..69762fdef 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 @@ -65,6 +65,7 @@ object AddonRepository { val storedUrls = dedupeManifestUrls(AddonStorage.loadInstalledAddonUrls(currentProfileId)) val enabledByUrl = loadLocalEnabledStates() + val namesByUrl = loadLocalNameOverrides() log.d { "initialize() — local addon count: ${storedUrls.size}" } if (storedUrls.isEmpty()) return @@ -73,6 +74,7 @@ object AddonRepository { addons = storedUrls.map { manifestUrl -> existingByUrl[manifestUrl].toPendingAddon( manifestUrl = manifestUrl, + userSetName = namesByUrl[manifestUrl], enabled = enabledByUrl[manifestUrl], ) }, @@ -137,12 +139,14 @@ object AddonRepository { initialize() pulledFromServer = true val enabledByUrl = loadLocalEnabledStates() + val namesByUrl = loadLocalNameOverrides() val addons = localUrls.mapIndexed { index, addonUrl -> val manifestUrl = ensureManifestSuffix(addonUrl) AddonPushItem( url = manifestUrl, - name = _uiState.value.addons - .find { it.manifestUrl == manifestUrl }?.manifest?.name ?: "", + name = namesByUrl[manifestUrl] + ?: _uiState.value.addons.find { it.manifestUrl == manifestUrl }?.manifest?.name + ?: "", enabled = enabledByUrl[manifestUrl] ?: _uiState.value.addons.find { it.manifestUrl == manifestUrl }?.enabled ?: true, @@ -165,11 +169,13 @@ object AddonRepository { if (localUrls.isNotEmpty()) { log.w { "pullFromServer() — remote empty while local has ${localUrls.size} addons; preserving local addons" } val enabledByUrl = loadLocalEnabledStates() + val namesByUrl = loadLocalNameOverrides() val existingByUrl = _uiState.value.addons.associateBy(ManagedAddon::manifestUrl) _uiState.value = AddonsUiState( addons = localUrls.map { url -> existingByUrl[url].toPendingAddon( manifestUrl = url, + userSetName = namesByUrl[url], enabled = enabledByUrl[url], ) }, @@ -435,12 +441,20 @@ object AddonRepository { currentProfileId, addons.associate { it.manifestUrl to it.enabled }, ) + AddonStorage.saveAddonNameOverridesPayload( + currentProfileId, + addons.encodeNameOverrides(), + ) } private fun loadLocalEnabledStates(): Map = AddonStorage.loadAddonEnabledStates(currentProfileId) .mapKeys { (url, _) -> ensureManifestSuffix(url) } + private fun loadLocalNameOverrides(): Map = + decodeAddonNameOverrides(AddonStorage.loadAddonNameOverridesPayload(currentProfileId)) + .mapKeys { (url, _) -> ensureManifestSuffix(url) } + private fun cancelActiveRefreshes() { activeRefreshJobs.values.forEach(Job::cancel) activeRefreshJobs.clear() @@ -457,7 +471,7 @@ object AddonRepository { } } -private fun ManagedAddon?.toPendingAddon( +internal fun ManagedAddon?.toPendingAddon( manifestUrl: String, userSetName: String? = null, enabled: Boolean? = null, diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/addons/AddonModelsTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/addons/AddonModelsTest.kt index 25c40e49e..4956c6ca0 100644 --- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/addons/AddonModelsTest.kt +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/addons/AddonModelsTest.kt @@ -36,6 +36,37 @@ class AddonModelsTest { assertEquals(listOf(enabled), listOf(enabled, disabled).enabledAddons()) assertTrue(enabled.isActive) } + + @Test + fun `addon name overrides survive local storage round trip`() { + val manifestUrl = "https://example.test/configured/user\tvalue/manifest.json" + val customName = "My renamed addon\nwith a second line" + val payload = listOf( + ManagedAddon( + manifestUrl = manifestUrl, + userSetName = customName, + ), + ).encodeNameOverrides() + + assertEquals(mapOf(manifestUrl to customName), decodeAddonNameOverrides(payload)) + } + + @Test + fun `pending addon restores its locally stored server name`() { + val addon = null.toPendingAddon( + manifestUrl = "https://example.test/manifest.json", + userSetName = "Renamed on the website", + enabled = true, + ) + + assertEquals("Renamed on the website", addon.userSetName) + assertEquals("Renamed on the website", addon.displayTitle) + } + + @Test + fun `malformed addon name cache is ignored`() { + assertTrue(decodeAddonNameOverrides("not-json").isEmpty()) + } } private fun manifest(id: String = "addon") = AddonManifest( diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/core/storage/PlatformLocalAccountDataCleaner.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/core/storage/PlatformLocalAccountDataCleaner.ios.kt index af7080491..c9a67b996 100644 --- a/composeApp/src/iosMain/kotlin/com/nuvio/app/core/storage/PlatformLocalAccountDataCleaner.ios.kt +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/core/storage/PlatformLocalAccountDataCleaner.ios.kt @@ -11,6 +11,7 @@ internal actual object PlatformLocalAccountDataCleaner { private val profilePinCachePrefixes = listOf("profile_pin_cache_") private val profileIndexedPrefixes = listOf( "installed_manifest_urls_", + "installed_manifest_name_overrides_", "plugins_state_", "library_payload_", "watched_payload_", 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 ec291bd5e..bca7dd8b0 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 @@ -25,6 +25,7 @@ import platform.Foundation.NSUserDefaults actual object AddonStorage { private const val addonUrlsKey = "installed_manifest_urls" private const val addonEnabledStatesKey = "installed_manifest_enabled_states" + private const val addonNameOverridesKey = "installed_manifest_name_overrides" actual fun loadInstalledAddonUrls(profileId: Int): List = NSUserDefaults.standardUserDefaults @@ -59,6 +60,18 @@ actual object AddonStorage { forKey = "${addonEnabledStatesKey}_$profileId", ) } + + actual fun loadAddonNameOverridesPayload(profileId: Int): String = + NSUserDefaults.standardUserDefaults + .stringForKey("${addonNameOverridesKey}_$profileId") + .orEmpty() + + actual fun saveAddonNameOverridesPayload(profileId: Int, payload: String) { + NSUserDefaults.standardUserDefaults.setObject( + payload, + forKey = "${addonNameOverridesKey}_$profileId", + ) + } } private fun parseEnabledStateLine(line: String): Pair? {