Compare commits

...

2 commits

Author SHA1 Message Date
Laskco
c241e47b17
Merge c690be6566 into f1e4f88bc3 2026-07-25 20:37:45 +00:00
YLaskco
c690be6566 fix(addons): make refresh synchronization reliable 2026-07-24 17:01:22 -04:00
11 changed files with 1281 additions and 262 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 addonNamesKey = "installed_manifest_names"
private var preferences: SharedPreferences? = null
@ -66,6 +67,24 @@ actual object AddonStorage {
?.putString("${addonEnabledStatesKey}_$profileId", payload)
?.apply()
}
actual fun loadAddonNames(profileId: Int): Map<String, String> =
preferences
?.getString("${addonNamesKey}_$profileId", null)
.orEmpty()
.lineSequence()
.mapNotNull(::parseAddonNameLine)
.toMap()
actual fun saveAddonNames(profileId: Int, names: Map<String, String>) {
val payload = names.entries.joinToString(separator = "\n") { (url, name) ->
"$url\t$name"
}
preferences
?.edit()
?.putString("${addonNamesKey}_$profileId", payload)
?.apply()
}
}
private fun parseEnabledStateLine(line: String): Pair<String, Boolean>? {
@ -78,6 +97,12 @@ private fun parseEnabledStateLine(line: String): Pair<String, Boolean>? {
return url to enabled
}
private fun parseAddonNameLine(line: String): Pair<String, String>? {
val url = line.substringBefore("\t").trim().takeIf { it.isNotEmpty() } ?: return null
val name = line.substringAfter("\t", "").trim().takeIf { it.isNotEmpty() } ?: return null
return url to name
}
private val addonHttpClient = OkHttpClient.Builder()
.dns(IPv4FirstDns())
.connectTimeout(60, TimeUnit.SECONDS)

View file

@ -56,9 +56,24 @@
<string name="addons_overview_addons">Addons</string>
<string name="addons_overview_catalogs">Catalogs</string>
<string name="addons_refresh">Refresh addon</string>
<string name="addons_refresh_all">Refresh Addons</string>
<string name="addons_refresh_all_description">Pull latest addon changes for the current profile.</string>
<string name="addons_refresh_all_running">Refreshing addons...</string>
<string name="addons_refresh_all_complete">Refreshed %1$d addons and %2$d manifests.</string>
<string name="addons_refresh_all_partial">Updated %1$d addons. %2$d manifests refreshed and %3$d failed.</string>
<string name="addons_refresh_all_partial_warning">Refresh incomplete: %1$s</string>
<string name="addons_refresh_all_conflict">The profile or local addon state changed during refresh. Your local state was kept; refresh again to pull the latest profile.</string>
<string name="addons_refresh_all_failed">Refresh failed: %1$s</string>
<string name="addons_refresh_error_default">Unable to refresh addons.</string>
<string name="addons_refresh_error_manifest_timeout">Manifest refresh timed out.</string>
<string name="addons_refresh_error_pending_upload">Pending local addon changes could not be uploaded.</string>
<string name="addons_refresh_error_sync">Unable to load addons from Nuvio Sync.</string>
<string name="addons_refresh_error_sync_timeout">Nuvio Sync did not respond before the refresh timed out.</string>
<string name="addons_refresh_warning_pending_upload">Local addon changes are saved on this device but could not be uploaded yet.</string>
<string name="addons_section_add_addon">Add Addon</string>
<string name="addons_section_installed">Installed Addons</string>
<string name="addons_section_overview">Overview</string>
<string name="addons_section_sync">Sync</string>
<string name="addons_summary_id_rules">%1$d id rules</string>
<string name="addons_version_format">Version %1$s</string>
<string name="cd_selected">Selected</string>

View file

@ -67,8 +67,30 @@ data class ManagedAddon(
data class AddonsUiState(
val addons: List<ManagedAddon> = emptyList(),
val refreshState: AddonRefreshState = AddonRefreshState.Idle,
val refreshRevision: Long = 0L,
)
sealed interface AddonRefreshState {
data object Idle : AddonRefreshState
data object Refreshing : AddonRefreshState
data class Complete(
val addonCount: Int,
val refreshedManifestCount: Int,
) : AddonRefreshState
data class Partial(
val addonCount: Int,
val refreshedManifestCount: Int,
val failedManifestCount: Int,
val warningMessage: String? = null,
) : AddonRefreshState
data object Conflict : AddonRefreshState
data class Failed(val message: String) : AddonRefreshState
}
data class AddonOverview(
val totalAddons: Int,
val activeAddons: Int,

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 loadAddonNames(profileId: Int): Map<String, String>
fun saveAddonNames(profileId: Int, names: Map<String, String>)
}
data class RawHttpResponse(

View file

@ -0,0 +1,61 @@
package com.nuvio.app.features.addons
internal data class RemoteAddonValue(
val manifestUrl: String,
val userSetName: String?,
val enabled: Boolean,
)
internal fun canApplyRemoteAddonSnapshot(
currentProfileId: Int,
snapshotProfileId: Int,
currentMutationRevision: Long,
expectedMutationRevision: Long,
hasPendingPush: Boolean,
): Boolean =
currentProfileId == snapshotProfileId &&
currentMutationRevision == expectedMutationRevision &&
!hasPendingPush
internal fun mergeRemoteAddonSnapshot(
existingAddons: List<ManagedAddon>,
remoteAddons: List<RemoteAddonValue>,
forceManifestRefresh: Boolean,
): List<ManagedAddon> {
val existingByUrl = existingAddons.associateBy(ManagedAddon::manifestUrl)
return remoteAddons.map { remote ->
val existing = existingByUrl[remote.manifestUrl]
val normalizedName = remote.userSetName?.takeIf(String::isNotBlank)
val merged = when {
existing == null -> ManagedAddon(
manifestUrl = remote.manifestUrl,
isRefreshing = remote.enabled,
userSetName = normalizedName,
enabled = remote.enabled,
)
existing.manifest != null -> existing.copy(
manifestUrl = remote.manifestUrl,
isRefreshing = false,
userSetName = normalizedName,
enabled = remote.enabled,
)
existing.isRefreshing -> existing.copy(
manifestUrl = remote.manifestUrl,
userSetName = normalizedName,
enabled = remote.enabled,
)
else -> existing.copy(
manifestUrl = remote.manifestUrl,
isRefreshing = remote.enabled,
errorMessage = null,
userSetName = normalizedName,
enabled = remote.enabled,
)
}
when {
!merged.enabled -> merged.copy(isRefreshing = false)
forceManifestRefresh -> merged.copy(isRefreshing = true, errorMessage = null)
else -> merged
}
}
}

View file

@ -105,6 +105,16 @@ internal fun AddonsSettingsPageContent(
SectionHeader(stringResource(Res.string.addons_section_overview))
OverviewCard(overview = overview)
SectionHeader(stringResource(Res.string.addons_section_sync))
RefreshAddonsCard(
refreshState = uiState.refreshState,
onRefreshClick = {
coroutineScope.launch {
AddonRepository.refreshAll()
}
},
)
SectionHeader(stringResource(Res.string.addons_section_add_addon))
AddAddonCard(
addonUrl = addonUrl,
@ -220,6 +230,54 @@ private fun SectionHeader(text: String) {
NuvioSectionLabel(text = text)
}
@Composable
private fun RefreshAddonsCard(
refreshState: AddonRefreshState,
onRefreshClick: () -> Unit,
) {
val isRefreshing = refreshState is AddonRefreshState.Refreshing
val statusText = when (refreshState) {
AddonRefreshState.Idle -> stringResource(Res.string.addons_refresh_all_description)
AddonRefreshState.Refreshing -> stringResource(Res.string.addons_refresh_all_running)
is AddonRefreshState.Complete -> stringResource(
Res.string.addons_refresh_all_complete,
refreshState.addonCount,
refreshState.refreshedManifestCount,
)
is AddonRefreshState.Partial -> refreshState.warningMessage?.let { warning ->
stringResource(Res.string.addons_refresh_all_partial_warning, warning)
} ?: stringResource(
Res.string.addons_refresh_all_partial,
refreshState.addonCount,
refreshState.refreshedManifestCount,
refreshState.failedManifestCount,
)
AddonRefreshState.Conflict -> stringResource(Res.string.addons_refresh_all_conflict)
is AddonRefreshState.Failed -> stringResource(
Res.string.addons_refresh_all_failed,
refreshState.message,
)
}
NuvioSurfaceCard {
Text(
text = statusText,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(modifier = Modifier.height(16.dp))
NuvioPrimaryButton(
text = if (isRefreshing) {
stringResource(Res.string.addons_refresh_all_running)
} else {
stringResource(Res.string.addons_refresh_all)
},
enabled = !isRefreshing,
onClick = onRefreshClick,
)
}
}
@Composable
private fun OverviewCard(overview: AddonOverview) {
NuvioSurfaceCard {

View file

@ -42,6 +42,17 @@ object HomeRepository {
private var collectionHeroRequestKey: String? = null
private var lastPublishedCatalogHeroEmpty: Boolean = true
private var lastErrorMessage: String? = null
private var lastHandledAddonRefreshRevision: Long = 0L
fun refreshAfterAddonRefresh(
addons: List<ManagedAddon>,
revision: Long,
): Boolean {
if (revision <= 0L || revision <= lastHandledAddonRefreshRevision) return false
lastHandledAddonRefreshRevision = revision
refresh(addons, force = true)
return true
}
fun refresh(addons: List<ManagedAddon>, force: Boolean = false) {
val activeAddons = addons.enabledAddons()

View file

@ -535,10 +535,18 @@ fun HomeScreen(
buildHomeCatalogRefreshSignature(enabledAddons)
}
LaunchedEffect(catalogRefreshKey) {
if (catalogRefreshKey.isEmpty()) return@LaunchedEffect
LaunchedEffect(catalogRefreshKey, addonsUiState.refreshRevision) {
if (catalogRefreshKey.isEmpty() && addonsUiState.refreshRevision == 0L) {
return@LaunchedEffect
}
HomeCatalogSettingsRepository.syncCatalogs(enabledAddons)
HomeRepository.refresh(enabledAddons)
val forced = HomeRepository.refreshAfterAddonRefresh(
addons = enabledAddons,
revision = addonsUiState.refreshRevision,
)
if (!forced) {
HomeRepository.refresh(enabledAddons)
}
}
LaunchedEffect(collections, enabledAddons) {

View file

@ -0,0 +1,197 @@
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.assertSame
import kotlin.test.assertTrue
class AddonRefreshRulesTest {
@Test
fun `remote snapshot replaces order names and enabled states atomically`() {
val preservedManifest = refreshManifest("existing")
val existing = listOf(
ManagedAddon(
manifestUrl = "https://one.example/manifest.json",
manifest = preservedManifest,
userSetName = "Old name",
enabled = false,
),
)
val merged = mergeRemoteAddonSnapshot(
existingAddons = existing,
remoteAddons = listOf(
RemoteAddonValue(
manifestUrl = "https://two.example/manifest.json",
userSetName = "Second",
enabled = false,
),
RemoteAddonValue(
manifestUrl = "https://one.example/manifest.json",
userSetName = "Renamed",
enabled = true,
),
),
forceManifestRefresh = false,
)
assertEquals(
listOf(
"https://two.example/manifest.json",
"https://one.example/manifest.json",
),
merged.map(ManagedAddon::manifestUrl),
)
assertEquals("Second", merged[0].userSetName)
assertFalse(merged[0].enabled)
assertEquals("Renamed", merged[1].userSetName)
assertTrue(merged[1].enabled)
assertSame(preservedManifest, merged[1].manifest)
}
@Test
fun `forced snapshot refreshes enabled manifests only`() {
val merged = mergeRemoteAddonSnapshot(
existingAddons = listOf(
ManagedAddon(
manifestUrl = "https://one.example/manifest.json",
manifest = refreshManifest("one"),
errorMessage = "old error",
),
),
remoteAddons = listOf(
RemoteAddonValue(
manifestUrl = "https://one.example/manifest.json",
userSetName = null,
enabled = true,
),
RemoteAddonValue(
manifestUrl = "https://two.example/manifest.json",
userSetName = null,
enabled = false,
),
),
forceManifestRefresh = true,
)
assertTrue(merged[0].isRefreshing)
assertNull(merged[0].errorMessage)
assertFalse(merged[1].isRefreshing)
}
@Test
fun `remote disable stops an in flight refresh`() {
val merged = mergeRemoteAddonSnapshot(
existingAddons = listOf(
ManagedAddon(
manifestUrl = "https://one.example/manifest.json",
isRefreshing = true,
),
),
remoteAddons = listOf(
RemoteAddonValue(
manifestUrl = "https://one.example/manifest.json",
userSetName = null,
enabled = false,
),
),
forceManifestRefresh = true,
)
assertFalse(merged.single().isRefreshing)
assertFalse(merged.single().enabled)
}
@Test
fun `remote snapshot clears a removed custom name`() {
val merged = mergeRemoteAddonSnapshot(
existingAddons = listOf(
ManagedAddon(
manifestUrl = "https://one.example/manifest.json",
manifest = refreshManifest("one"),
userSetName = "Old custom name",
),
),
remoteAddons = listOf(
RemoteAddonValue(
manifestUrl = "https://one.example/manifest.json",
userSetName = null,
enabled = true,
),
),
forceManifestRefresh = false,
)
assertNull(merged.single().userSetName)
}
@Test
fun `successful empty snapshot clears the profile`() {
val merged = mergeRemoteAddonSnapshot(
existingAddons = listOf(
ManagedAddon(
manifestUrl = "https://one.example/manifest.json",
manifest = refreshManifest("one"),
),
),
remoteAddons = emptyList(),
forceManifestRefresh = true,
)
assertTrue(merged.isEmpty())
}
@Test
fun `remote snapshot is rejected when profile or local state changed`() {
assertTrue(
canApplyRemoteAddonSnapshot(
currentProfileId = 2,
snapshotProfileId = 2,
currentMutationRevision = 7,
expectedMutationRevision = 7,
hasPendingPush = false,
),
)
assertFalse(
canApplyRemoteAddonSnapshot(
currentProfileId = 2,
snapshotProfileId = 1,
currentMutationRevision = 7,
expectedMutationRevision = 7,
hasPendingPush = false,
),
)
assertFalse(
canApplyRemoteAddonSnapshot(
currentProfileId = 2,
snapshotProfileId = 2,
currentMutationRevision = 8,
expectedMutationRevision = 7,
hasPendingPush = false,
),
)
assertFalse(
canApplyRemoteAddonSnapshot(
currentProfileId = 2,
snapshotProfileId = 2,
currentMutationRevision = 7,
expectedMutationRevision = 7,
hasPendingPush = true,
),
)
}
}
private fun refreshManifest(id: String) = AddonManifest(
id = id,
name = id,
description = "",
version = "1.0.0",
resources = listOf(AddonResource(name = "catalog", types = listOf("movie"))),
types = listOf("movie"),
catalogs = listOf(AddonCatalog(type = "movie", id = "popular", name = "Popular")),
transportUrl = "https://$id.example/manifest.json",
)

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 addonNamesKey = "installed_manifest_names"
actual fun loadInstalledAddonUrls(profileId: Int): List<String> =
NSUserDefaults.standardUserDefaults
@ -59,6 +60,24 @@ actual object AddonStorage {
forKey = "${addonEnabledStatesKey}_$profileId",
)
}
actual fun loadAddonNames(profileId: Int): Map<String, String> =
NSUserDefaults.standardUserDefaults
.stringForKey("${addonNamesKey}_$profileId")
.orEmpty()
.lineSequence()
.mapNotNull(::parseAddonNameLine)
.toMap()
actual fun saveAddonNames(profileId: Int, names: Map<String, String>) {
val payload = names.entries.joinToString(separator = "\n") { (url, name) ->
"$url\t$name"
}
NSUserDefaults.standardUserDefaults.setObject(
payload,
forKey = "${addonNamesKey}_$profileId",
)
}
}
private fun parseEnabledStateLine(line: String): Pair<String, Boolean>? {
@ -71,6 +90,12 @@ private fun parseEnabledStateLine(line: String): Pair<String, Boolean>? {
return url to enabled
}
private fun parseAddonNameLine(line: String): Pair<String, String>? {
val url = line.substringBefore("\t").trim().takeIf { it.isNotEmpty() } ?: return null
val name = line.substringAfter("\t", "").trim().takeIf { it.isNotEmpty() } ?: return null
return url to name
}
private val addonHttpClient = HttpClient(Darwin) {
install(HttpTimeout) {
requestTimeoutMillis = 60_000