fix: persist server addon names across restarts

This commit is contained in:
Hassan 2026-07-19 04:12:00 +03:00
parent 0ac38dce43
commit 8c7a0b624c
7 changed files with 113 additions and 3 deletions

View file

@ -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<String, Boolean>? {

View file

@ -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<ManagedAddon>.toOverview(): AddonOverview =
internal fun List<ManagedAddon>.enabledAddons(): List<ManagedAddon> =
filter { it.enabled }
internal fun List<ManagedAddon>.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<String, String> {
if (payload.isBlank()) return emptyMap()
return runCatching {
addonNameOverridesJson.decodeFromString<List<StoredAddonNameOverride>>(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

View file

@ -5,6 +5,8 @@ internal expect object AddonStorage {
fun saveInstalledAddonUrls(profileId: Int, urls: List<String>)
fun loadAddonEnabledStates(profileId: Int): Map<String, Boolean>
fun saveAddonEnabledStates(profileId: Int, states: Map<String, Boolean>)
fun loadAddonNameOverridesPayload(profileId: Int): String
fun saveAddonNameOverridesPayload(profileId: Int, payload: String)
}
data class RawHttpResponse(

View file

@ -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<String, Boolean> =
AddonStorage.loadAddonEnabledStates(currentProfileId)
.mapKeys { (url, _) -> ensureManifestSuffix(url) }
private fun loadLocalNameOverrides(): Map<String, String> =
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,

View file

@ -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(

View file

@ -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_",

View file

@ -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<String> =
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<String, Boolean>? {