From 8c7a0b624c4e22e7f1a23ed827b704c3f2d66f1c Mon Sep 17 00:00:00 2001 From: Hassan Date: Sun, 19 Jul 2026 04:12:00 +0300 Subject: [PATCH 1/2] 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? { From 77b203b44ee3add448506587c47182651d878910 Mon Sep 17 00:00:00 2001 From: Hassan Date: Wed, 22 Jul 2026 01:18:35 +0300 Subject: [PATCH 2/2] fix(addons): isolate profile sync operations --- .../app/features/addons/AddonRepository.kt | 167 ++++++++++++------ .../app/features/addons/AddonModelsTest.kt | 39 ++++ 2 files changed, 151 insertions(+), 55 deletions(-) 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 69762fdef..3858c68ef 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 @@ -44,6 +44,27 @@ private data class AddonPushItem( @SerialName("sort_order") val sortOrder: Int = 0, ) +internal data class AddonProfileOperation( + val profileId: Int, + val generation: Long, +) + +internal class AddonProfileOperationTracker(initialProfileId: Int = 1) { + var profileId: Int = initialProfileId + private set + private var generation: Long = 0L + + fun activate(profileId: Int) { + generation += 1L + this.profileId = profileId + } + + fun snapshot(): AddonProfileOperation = AddonProfileOperation(profileId, generation) + + fun isCurrent(operation: AddonProfileOperation): Boolean = + operation.profileId == profileId && operation.generation == generation +} + object AddonRepository { private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) private val log = Logger.withTag("AddonRepository") @@ -53,19 +74,23 @@ object AddonRepository { private var initialized = false private var pulledFromServer = false - private var currentProfileId: Int = 1 + private val profileOperations = AddonProfileOperationTracker() + private val currentProfileId: Int + get() = profileOperations.profileId private val activeRefreshJobs = mutableMapOf() fun initialize() { val effectiveProfileId = resolveEffectiveProfileId(ProfileRepository.activeProfileId) + if (effectiveProfileId != currentProfileId) { + activateProfile(effectiveProfileId) + } if (initialized) return initialized = true - currentProfileId = effectiveProfileId log.d { "initialize() — loading local addons for profile $currentProfileId" } val storedUrls = dedupeManifestUrls(AddonStorage.loadInstalledAddonUrls(currentProfileId)) - val enabledByUrl = loadLocalEnabledStates() - val namesByUrl = loadLocalNameOverrides() + val enabledByUrl = loadLocalEnabledStates(currentProfileId) + val namesByUrl = loadLocalNameOverrides(currentProfileId) log.d { "initialize() — local addon count: ${storedUrls.size}" } if (storedUrls.isEmpty()) return @@ -92,32 +117,29 @@ object AddonRepository { fun onProfileChanged(profileId: Int) { val effectiveProfileId = resolveEffectiveProfileId(profileId) if (effectiveProfileId == currentProfileId && initialized) return - cancelActiveRefreshes() - currentProfileId = effectiveProfileId - initialized = false - pulledFromServer = false - _uiState.value = AddonsUiState() + activateProfile(effectiveProfileId) } fun clearLocalState() { cancelActiveRefreshes() - currentProfileId = 1 + profileOperations.activate(1) initialized = false pulledFromServer = false _uiState.value = AddonsUiState() } suspend fun pullFromServer(profileId: Int) { - currentProfileId = resolveEffectiveProfileId(profileId) - log.i { "pullFromServer() — profileId=$profileId, initialized=$initialized, pulledFromServer=$pulledFromServer" } + val operation = beginProfileOperation(profileId) ?: return + log.i { "pullFromServer() — profileId=${operation.profileId}, initialized=$initialized, pulledFromServer=$pulledFromServer" } runCatching { val rows = SupabaseProvider.client.postgrest .from("addons") .select { - filter { eq("profile_id", currentProfileId) } + filter { eq("profile_id", operation.profileId) } order("sort_order", Order.ASCENDING) } .decodeList() + if (!isCurrent(operation)) return val rowsByUrl = linkedMapOf() rows.forEach { row -> @@ -132,14 +154,14 @@ object AddonRepository { urls.forEachIndexed { i, u -> log.d { " server[$i]: $u" } } if (urls.isEmpty() && !pulledFromServer) { - val localUrls = dedupeManifestUrls(AddonStorage.loadInstalledAddonUrls(currentProfileId)) + val localUrls = dedupeManifestUrls(AddonStorage.loadInstalledAddonUrls(operation.profileId)) log.i { "pullFromServer() — server empty, local has ${localUrls.size} addons" } if (localUrls.isNotEmpty()) { - log.i { "pullFromServer() — migrating local addons to server for profile $currentProfileId" } + log.i { "pullFromServer() — migrating local addons to server for profile ${operation.profileId}" } initialize() - pulledFromServer = true - val enabledByUrl = loadLocalEnabledStates() - val namesByUrl = loadLocalNameOverrides() + if (!isCurrent(operation)) return + val enabledByUrl = loadLocalEnabledStates(operation.profileId) + val namesByUrl = loadLocalNameOverrides(operation.profileId) val addons = localUrls.mapIndexed { index, addonUrl -> val manifestUrl = ensureManifestSuffix(addonUrl) AddonPushItem( @@ -154,23 +176,26 @@ object AddonRepository { ) } val params = buildJsonObject { - put("p_profile_id", currentProfileId) + put("p_profile_id", operation.profileId) put("p_addons", json.encodeToJsonElement(addons)) putSyncOriginClientId() } SupabaseProvider.client.postgrest.rpc("sync_push_addons", params) + if (!isCurrent(operation)) return + pulledFromServer = true log.i { "pullFromServer() — migration push done (${addons.size} addons)" } return } } if (urls.isEmpty()) { - val localUrls = dedupeManifestUrls(AddonStorage.loadInstalledAddonUrls(currentProfileId)) + val localUrls = dedupeManifestUrls(AddonStorage.loadInstalledAddonUrls(operation.profileId)) if (localUrls.isNotEmpty()) { log.w { "pullFromServer() — remote empty while local has ${localUrls.size} addons; preserving local addons" } - val enabledByUrl = loadLocalEnabledStates() - val namesByUrl = loadLocalNameOverrides() + val enabledByUrl = loadLocalEnabledStates(operation.profileId) + val namesByUrl = loadLocalNameOverrides(operation.profileId) val existingByUrl = _uiState.value.addons.associateBy(ManagedAddon::manifestUrl) + if (!isCurrent(operation)) return _uiState.value = AddonsUiState( addons = localUrls.map { url -> existingByUrl[url].toPendingAddon( @@ -180,7 +205,7 @@ object AddonRepository { ) }, ) - persist() + persist(operation.profileId) localUrls.forEach { url -> val existing = existingByUrl[url] val addon = _uiState.value.addons.firstOrNull { it.manifestUrl == url } @@ -195,17 +220,19 @@ object AddonRepository { } val existingByUrl = _uiState.value.addons.associateBy(ManagedAddon::manifestUrl) + if (!isCurrent(operation)) return _uiState.value = AddonsUiState( addons = urls.map { url -> val row = rowsByUrl[url] existingByUrl[url].toPendingAddon( manifestUrl = url, - userSetName = row?.name?.takeIf { it.isNotBlank() }, + userSetName = row?.name?.trim()?.takeIf { it.isNotBlank() }, + replaceUserSetName = true, enabled = row?.enabled, ) }, ) - persist() + persist(operation.profileId) urls.forEach { url -> val existing = existingByUrl[url] val addon = _uiState.value.addons.firstOrNull { it.manifestUrl == url } @@ -334,6 +361,8 @@ object AddonRepository { } fun refreshAddon(manifestUrl: String) { + val operation = profileOperations.snapshot() + if (!isCurrent(operation)) return val existingJob = activeRefreshJobs[manifestUrl] if (existingJob?.isActive == true) return @@ -349,6 +378,8 @@ object AddonRepository { ) } + if (!isCurrent(operation)) return@launch + _uiState.update { current -> current.copy( addons = current.addons.map { addon -> @@ -384,25 +415,24 @@ object AddonRepository { } private fun pushToServer() { + if (isUsingPrimaryAddonsFromSecondaryProfile()) return + val operation = profileOperations.snapshot() + val addons = _uiState.value.addons + .distinctBy { it.manifestUrl } + .mapIndexed { index, addon -> + AddonPushItem( + url = addon.manifestUrl, + name = addon.userSetName?.takeIf { it.isNotBlank() } ?: addon.manifest?.name ?: "", + enabled = addon.enabled, + sortOrder = index, + ) + } scope.launch { runCatching { - if (isUsingPrimaryAddonsFromSecondaryProfile()) { - return@runCatching - } - val profileId = currentProfileId - val addons = _uiState.value.addons - .distinctBy { it.manifestUrl } - .mapIndexed { index, addon -> - AddonPushItem( - url = addon.manifestUrl, - name = addon.userSetName?.takeIf { it.isNotBlank() } ?: addon.manifest?.name ?: "", - enabled = addon.enabled, - sortOrder = index, - ) - } - log.d { "pushToServer() — profileId=$profileId, pushing ${addons.size} addons" } + if (!isCurrent(operation)) return@runCatching + log.d { "pushToServer() — profileId=${operation.profileId}, pushing ${addons.size} addons" } val params = buildJsonObject { - put("p_profile_id", profileId) + put("p_profile_id", operation.profileId) put("p_addons", json.encodeToJsonElement(addons)) putSyncOriginClientId() } @@ -431,28 +461,28 @@ object AddonRepository { } } - private fun persist() { + private fun persist(profileId: Int = currentProfileId) { val addons = _uiState.value.addons AddonStorage.saveInstalledAddonUrls( - currentProfileId, + profileId, dedupeManifestUrls(addons.map { it.manifestUrl }), ) AddonStorage.saveAddonEnabledStates( - currentProfileId, + profileId, addons.associate { it.manifestUrl to it.enabled }, ) AddonStorage.saveAddonNameOverridesPayload( - currentProfileId, + profileId, addons.encodeNameOverrides(), ) } - private fun loadLocalEnabledStates(): Map = - AddonStorage.loadAddonEnabledStates(currentProfileId) + private fun loadLocalEnabledStates(profileId: Int): Map = + AddonStorage.loadAddonEnabledStates(profileId) .mapKeys { (url, _) -> ensureManifestSuffix(url) } - private fun loadLocalNameOverrides(): Map = - decodeAddonNameOverrides(AddonStorage.loadAddonNameOverridesPayload(currentProfileId)) + private fun loadLocalNameOverrides(profileId: Int): Map = + decodeAddonNameOverrides(AddonStorage.loadAddonNameOverridesPayload(profileId)) .mapKeys { (url, _) -> ensureManifestSuffix(url) } private fun cancelActiveRefreshes() { @@ -460,6 +490,30 @@ object AddonRepository { activeRefreshJobs.clear() } + private fun activateProfile(profileId: Int) { + cancelActiveRefreshes() + profileOperations.activate(profileId) + initialized = false + pulledFromServer = false + _uiState.value = AddonsUiState() + } + + private fun beginProfileOperation(profileId: Int): AddonProfileOperation? { + val requestedProfileId = resolveEffectiveProfileId(profileId) + val activeProfileId = resolveEffectiveProfileId(ProfileRepository.activeProfileId) + if (requestedProfileId != activeProfileId) { + log.w { "Ignoring stale addon pull for profile $requestedProfileId; active profile is $activeProfileId" } + return null + } + if (requestedProfileId != currentProfileId) { + activateProfile(requestedProfileId) + } + return profileOperations.snapshot() + } + + private fun isCurrent(operation: AddonProfileOperation): Boolean = + profileOperations.isCurrent(operation) + private fun resolveEffectiveProfileId(profileId: Int): Int { val active = ProfileRepository.state.value.activeProfile return if (active != null && active.profileIndex != 1 && active.usesPrimaryAddons) 1 else profileId @@ -474,34 +528,37 @@ object AddonRepository { internal fun ManagedAddon?.toPendingAddon( manifestUrl: String, userSetName: String? = null, + replaceUserSetName: Boolean = false, enabled: Boolean? = null, -): ManagedAddon = - when { +): ManagedAddon { + val resolvedUserSetName = if (replaceUserSetName) userSetName else userSetName ?: this?.userSetName + return when { this == null -> ManagedAddon( manifestUrl = manifestUrl, isRefreshing = enabled ?: true, - userSetName = userSetName, + userSetName = resolvedUserSetName, enabled = enabled ?: true, ) manifest != null -> copy( manifestUrl = manifestUrl, isRefreshing = false, - userSetName = userSetName ?: this.userSetName, + userSetName = resolvedUserSetName, enabled = enabled ?: this.enabled, ) isRefreshing -> copy( manifestUrl = manifestUrl, - userSetName = userSetName ?: this.userSetName, + userSetName = resolvedUserSetName, enabled = enabled ?: this.enabled, ) else -> copy( manifestUrl = manifestUrl, isRefreshing = enabled ?: this.enabled, errorMessage = null, - userSetName = userSetName ?: this.userSetName, + userSetName = resolvedUserSetName, enabled = enabled ?: this.enabled, ) } +} private fun dedupeManifestUrls(urls: List): List = urls.map(::ensureManifestSuffix).distinct() 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 4956c6ca0..87cf99724 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 @@ -3,6 +3,7 @@ package com.nuvio.app.features.addons import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse +import kotlin.test.assertNull import kotlin.test.assertTrue class AddonModelsTest { @@ -63,6 +64,44 @@ class AddonModelsTest { assertEquals("Renamed on the website", addon.displayTitle) } + @Test + fun `blank server name clears a cached override`() { + val addon = ManagedAddon( + manifestUrl = "https://example.test/manifest.json", + manifest = manifest(), + userSetName = "Stale cached name", + ).toPendingAddon( + manifestUrl = "https://example.test/manifest.json", + userSetName = null, + replaceUserSetName = true, + enabled = true, + ) + + assertNull(addon.userSetName) + assertEquals("addon", addon.displayTitle) + } + + @Test + fun `profile operation becomes stale when profile changes`() { + val tracker = AddonProfileOperationTracker(initialProfileId = 1) + val profileOnePull = tracker.snapshot() + + tracker.activate(profileId = 2) + + assertFalse(tracker.isCurrent(profileOnePull)) + assertTrue(tracker.isCurrent(tracker.snapshot())) + } + + @Test + fun `new generation invalidates work even for the same effective profile`() { + val tracker = AddonProfileOperationTracker(initialProfileId = 1) + val oldPull = tracker.snapshot() + + tracker.activate(profileId = 1) + + assertFalse(tracker.isCurrent(oldPull)) + } + @Test fun `malformed addon name cache is ignored`() { assertTrue(decodeAddonNameOverrides("not-json").isEmpty())