This commit is contained in:
i7xre 2026-07-23 18:57:34 -04:00 committed by GitHub
commit 1cf78fb86f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 258 additions and 52 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

@ -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,18 +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<String, Job>()
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 enabledByUrl = loadLocalEnabledStates(currentProfileId)
val namesByUrl = loadLocalNameOverrides(currentProfileId)
log.d { "initialize() — local addon count: ${storedUrls.size}" }
if (storedUrls.isEmpty()) return
@ -73,6 +99,7 @@ object AddonRepository {
addons = storedUrls.map { manifestUrl ->
existingByUrl[manifestUrl].toPendingAddon(
manifestUrl = manifestUrl,
userSetName = namesByUrl[manifestUrl],
enabled = enabledByUrl[manifestUrl],
)
},
@ -90,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<AddonRow>()
if (!isCurrent(operation)) return
val rowsByUrl = linkedMapOf<String, AddonRow>()
rows.forEach { row ->
@ -130,19 +154,21 @@ 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()
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(
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,
@ -150,31 +176,36 @@ 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 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(
manifestUrl = url,
userSetName = namesByUrl[url],
enabled = enabledByUrl[url],
)
},
)
persist()
persist(operation.profileId)
localUrls.forEach { url ->
val existing = existingByUrl[url]
val addon = _uiState.value.addons.firstOrNull { it.manifestUrl == url }
@ -189,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 }
@ -328,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
@ -343,6 +378,8 @@ object AddonRepository {
)
}
if (!isCurrent(operation)) return@launch
_uiState.update { current ->
current.copy(
addons = current.addons.map { addon ->
@ -378,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()
}
@ -425,20 +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(
profileId,
addons.encodeNameOverrides(),
)
}
private fun loadLocalEnabledStates(): Map<String, Boolean> =
AddonStorage.loadAddonEnabledStates(currentProfileId)
private fun loadLocalEnabledStates(profileId: Int): Map<String, Boolean> =
AddonStorage.loadAddonEnabledStates(profileId)
.mapKeys { (url, _) -> ensureManifestSuffix(url) }
private fun loadLocalNameOverrides(profileId: Int): Map<String, String> =
decodeAddonNameOverrides(AddonStorage.loadAddonNameOverridesPayload(profileId))
.mapKeys { (url, _) -> ensureManifestSuffix(url) }
private fun cancelActiveRefreshes() {
@ -446,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
@ -457,37 +525,40 @@ object AddonRepository {
}
}
private fun ManagedAddon?.toPendingAddon(
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<String>): List<String> =
urls.map(::ensureManifestSuffix).distinct()

View file

@ -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 {
@ -36,6 +37,75 @@ 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 `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())
}
}
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>? {