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..7f2754cbd 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 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 = + preferences + ?.getString("${addonNamesKey}_$profileId", null) + .orEmpty() + .lineSequence() + .mapNotNull(::parseAddonNameLine) + .toMap() + + actual fun saveAddonNames(profileId: Int, names: Map) { + 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? { @@ -78,6 +97,12 @@ private fun parseEnabledStateLine(line: String): Pair? { return url to enabled } +private fun parseAddonNameLine(line: String): Pair? { + 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) diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index ae50853a4..e11227d17 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -56,9 +56,24 @@ Addons Catalogs Refresh addon + Refresh Addons + Pull latest addon changes for the current profile. + Refreshing addons... + Refreshed %1$d addons and %2$d manifests. + Updated %1$d addons. %2$d manifests refreshed and %3$d failed. + Refresh incomplete: %1$s + The profile or local addon state changed during refresh. Your local state was kept; refresh again to pull the latest profile. + Refresh failed: %1$s + Unable to refresh addons. + Manifest refresh timed out. + Pending local addon changes could not be uploaded. + Unable to load addons from Nuvio Sync. + Nuvio Sync did not respond before the refresh timed out. + Local addon changes are saved on this device but could not be uploaded yet. Add Addon Installed Addons Overview + Sync %1$d id rules Version %1$s Selected 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..a7e38c33b 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 @@ -67,8 +67,30 @@ data class ManagedAddon( data class AddonsUiState( val addons: List = 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, 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..3c561883a 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 loadAddonNames(profileId: Int): Map + fun saveAddonNames(profileId: Int, names: Map) } data class RawHttpResponse( diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/addons/AddonRefreshRules.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/addons/AddonRefreshRules.kt new file mode 100644 index 000000000..da53216b8 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/addons/AddonRefreshRules.kt @@ -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, + remoteAddons: List, + forceManifestRefresh: Boolean, +): List { + 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 + } + } +} 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..2a87aa358 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 @@ -1,16 +1,26 @@ package com.nuvio.app.features.addons import co.touchlab.kermit.Logger +import com.nuvio.app.core.auth.AuthRepository +import com.nuvio.app.core.auth.AuthState import com.nuvio.app.core.network.SupabaseProvider import com.nuvio.app.core.sync.putSyncOriginClientId import com.nuvio.app.features.profiles.ProfileRepository import io.github.jan.supabase.postgrest.postgrest import io.github.jan.supabase.postgrest.query.Order import io.github.jan.supabase.postgrest.rpc +import kotlinx.atomicfu.locks.SynchronizedObject +import kotlinx.atomicfu.locks.synchronized +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Deferred import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -18,7 +28,12 @@ import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.Semaphore +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.sync.withPermit import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeoutOrNull import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import kotlinx.serialization.json.Json @@ -44,175 +59,187 @@ private data class AddonPushItem( @SerialName("sort_order") val sortOrder: Int = 0, ) +private data class RemoteAddonSnapshot( + val profileId: Int, + val rows: List, +) + +private data class PendingAddonPush( + val profileId: Int, + val revision: Long, + val addons: List, +) + +private data class ManualRefreshOperation( + val profileId: Int, + val deferred: Deferred, +) + +private sealed interface SnapshotApplyResult { + data class Applied(val addons: List) : SnapshotApplyResult + data object Conflict : SnapshotApplyResult +} + object AddonRepository { + private const val MANIFEST_REFRESH_CONCURRENCY = 6 + private const val MANIFEST_REFRESH_TIMEOUT_MS = 15_000L + private const val MANIFEST_REFRESH_TOTAL_TIMEOUT_MS = 30_000L + private const val REMOTE_PROFILE_TIMEOUT_MS = 15_000L + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) private val log = Logger.withTag("AddonRepository") private val json = Json { ignoreUnknownKeys = true; encodeDefaults = true } private val _uiState = MutableStateFlow(AddonsUiState()) val uiState: StateFlow = _uiState.asStateFlow() + private val stateLock = SynchronizedObject() + private val addonSyncMutex = Mutex() private var initialized = false private var pulledFromServer = false private var currentProfileId: Int = 1 + private var localMutationRevision: Long = 0L + private var refreshRevision: Long = 0L + private val pendingPushes = linkedMapOf() + private var manualRefreshOperation: ManualRefreshOperation? = null private val activeRefreshJobs = mutableMapOf() fun initialize() { - val effectiveProfileId = resolveEffectiveProfileId(ProfileRepository.activeProfileId) - if (initialized) return - initialized = true - currentProfileId = effectiveProfileId - log.d { "initialize() — loading local addons for profile $currentProfileId" } + val profileId = synchronized(stateLock) { + val effectiveProfileId = currentEffectiveProfileId() + if (initialized && currentProfileId == effectiveProfileId) return + initialized = true + currentProfileId = effectiveProfileId + effectiveProfileId + } + log.d { "initialize() — loading local addons for profile $profileId" } - val storedUrls = dedupeManifestUrls(AddonStorage.loadInstalledAddonUrls(currentProfileId)) - val enabledByUrl = loadLocalEnabledStates() + val storedUrls = dedupeManifestUrls(AddonStorage.loadInstalledAddonUrls(profileId)) + val enabledByUrl = loadLocalEnabledStates(profileId) + val namesByUrl = loadLocalNames(profileId) log.d { "initialize() — local addon count: ${storedUrls.size}" } if (storedUrls.isEmpty()) return - val existingByUrl = _uiState.value.addons.associateBy(ManagedAddon::manifestUrl) - _uiState.value = AddonsUiState( - addons = storedUrls.map { manifestUrl -> - existingByUrl[manifestUrl].toPendingAddon( - manifestUrl = manifestUrl, - enabled = enabledByUrl[manifestUrl], + val urlsToRefresh = synchronized(stateLock) { + if (currentProfileId != profileId || currentEffectiveProfileId() != profileId) return + val existingByUrl = _uiState.value.addons.associateBy(ManagedAddon::manifestUrl) + _uiState.update { current -> + current.copy( + addons = storedUrls.map { manifestUrl -> + existingByUrl[manifestUrl].toPendingAddon( + manifestUrl = manifestUrl, + userSetName = namesByUrl[manifestUrl], + enabled = enabledByUrl[manifestUrl], + ) + }, ) - }, - ) - - storedUrls.forEach { manifestUrl -> - val existing = existingByUrl[manifestUrl] - val addon = _uiState.value.addons.firstOrNull { it.manifestUrl == manifestUrl } - if (addon?.enabled == true && (existing == null || (addon.manifest == null && !addon.isRefreshing))) { - refreshAddon(manifestUrl) } + storedUrls.filter { manifestUrl -> + val existing = existingByUrl[manifestUrl] + val addon = _uiState.value.addons.firstOrNull { it.manifestUrl == manifestUrl } + addon?.enabled == true && + (existing == null || (addon.manifest == null && !addon.isRefreshing)) + } + } + + urlsToRefresh.forEach { manifestUrl -> + refreshAddon(manifestUrl, expectedProfileId = profileId) } } fun onProfileChanged(profileId: Int) { val effectiveProfileId = resolveEffectiveProfileId(profileId) - if (effectiveProfileId == currentProfileId && initialized) return - cancelActiveRefreshes() - currentProfileId = effectiveProfileId - initialized = false - pulledFromServer = false - _uiState.value = AddonsUiState() + synchronized(stateLock) { + if (effectiveProfileId == currentProfileId && initialized) return + activeRefreshJobs.values.forEach(Job::cancel) + activeRefreshJobs.clear() + manualRefreshOperation?.deferred?.cancel() + manualRefreshOperation = null + currentProfileId = effectiveProfileId + initialized = false + pulledFromServer = false + _uiState.value = AddonsUiState(refreshRevision = refreshRevision) + } } fun clearLocalState() { - cancelActiveRefreshes() - currentProfileId = 1 - initialized = false - pulledFromServer = false - _uiState.value = AddonsUiState() + synchronized(stateLock) { + activeRefreshJobs.values.forEach(Job::cancel) + activeRefreshJobs.clear() + manualRefreshOperation?.deferred?.cancel() + manualRefreshOperation = null + pendingPushes.clear() + currentProfileId = 1 + initialized = false + pulledFromServer = false + _uiState.value = AddonsUiState(refreshRevision = refreshRevision) + } } suspend fun pullFromServer(profileId: Int) { - currentProfileId = resolveEffectiveProfileId(profileId) - log.i { "pullFromServer() — profileId=$profileId, initialized=$initialized, pulledFromServer=$pulledFromServer" } + val effectiveProfileId = resolveEffectiveProfileId(profileId) + log.i { + "pullFromServer() — profileId=$profileId, effectiveProfileId=$effectiveProfileId, " + + "initialized=$initialized, pulledFromServer=$pulledFromServer" + } runCatching { - val rows = SupabaseProvider.client.postgrest - .from("addons") - .select { - filter { eq("profile_id", currentProfileId) } - order("sort_order", Order.ASCENDING) + addonSyncMutex.withLock { + if (currentEffectiveProfileId() != effectiveProfileId) { + log.d { "pullFromServer() — ignored stale request for profile $effectiveProfileId" } + return@withLock } - .decodeList() - - val rowsByUrl = linkedMapOf() - rows.forEach { row -> - val manifestUrl = ensureManifestSuffix(row.url) - if (!rowsByUrl.containsKey(manifestUrl)) { - rowsByUrl[manifestUrl] = row.copy(url = manifestUrl) + if (!flushPendingPushLocked(effectiveProfileId)) { + log.w { "pullFromServer() — skipped because pending local addon changes could not be uploaded" } + return@withLock + } + if (currentEffectiveProfileId() != effectiveProfileId) { + log.d { "pullFromServer() — profile changed while pending changes were uploaded" } + return@withLock + } + performAutomaticPull(effectiveProfileId) + if (!flushPendingPushLocked(effectiveProfileId)) { + log.w { "pullFromServer() — refreshed state applied but pending changes remain" } } } + }.onFailure { error -> + log.e(error) { "pullFromServer() — FAILED" } + } + } - val urls = rowsByUrl.keys.toList() - log.i { "pullFromServer() — server returned ${rows.size} addons" } - urls.forEachIndexed { i, u -> log.d { " server[$i]: $u" } } - - if (urls.isEmpty() && !pulledFromServer) { - val localUrls = dedupeManifestUrls(AddonStorage.loadInstalledAddonUrls(currentProfileId)) - log.i { "pullFromServer() — server empty, local has ${localUrls.size} addons" } - if (localUrls.isNotEmpty()) { - log.i { "pullFromServer() — migrating local addons to server for profile $currentProfileId" } - initialize() - pulledFromServer = true - val enabledByUrl = loadLocalEnabledStates() - val addons = localUrls.mapIndexed { index, addonUrl -> - val manifestUrl = ensureManifestSuffix(addonUrl) - AddonPushItem( - url = manifestUrl, - name = _uiState.value.addons - .find { it.manifestUrl == manifestUrl }?.manifest?.name ?: "", - enabled = enabledByUrl[manifestUrl] - ?: _uiState.value.addons.find { it.manifestUrl == manifestUrl }?.enabled - ?: true, - sortOrder = index, - ) + suspend fun refreshAll(): AddonRefreshState { + initialize() + val effectiveProfileId = resolveEffectiveProfileId(ProfileRepository.activeProfileId) + val operation = synchronized(stateLock) { + manualRefreshOperation + ?.takeIf { it.profileId == effectiveProfileId && it.deferred.isActive } + ?: run { + manualRefreshOperation + ?.takeIf { it.profileId != effectiveProfileId } + ?.deferred + ?.cancel() + _uiState.update { current -> + current.copy(refreshState = AddonRefreshState.Refreshing) } - val params = buildJsonObject { - put("p_profile_id", currentProfileId) - put("p_addons", json.encodeToJsonElement(addons)) - putSyncOriginClientId() + val deferred = scope.async { + performManualRefresh(effectiveProfileId) } - SupabaseProvider.client.postgrest.rpc("sync_push_addons", params) - log.i { "pullFromServer() — migration push done (${addons.size} addons)" } - return - } - } - - if (urls.isEmpty()) { - val localUrls = dedupeManifestUrls(AddonStorage.loadInstalledAddonUrls(currentProfileId)) - if (localUrls.isNotEmpty()) { - log.w { "pullFromServer() — remote empty while local has ${localUrls.size} addons; preserving local addons" } - val enabledByUrl = loadLocalEnabledStates() - val existingByUrl = _uiState.value.addons.associateBy(ManagedAddon::manifestUrl) - _uiState.value = AddonsUiState( - addons = localUrls.map { url -> - existingByUrl[url].toPendingAddon( - manifestUrl = url, - enabled = enabledByUrl[url], - ) - }, - ) - persist() - localUrls.forEach { url -> - val existing = existingByUrl[url] - val addon = _uiState.value.addons.firstOrNull { it.manifestUrl == url } - if (addon?.enabled == true && (existing == null || (addon.manifest == null && !addon.isRefreshing))) { - refreshAddon(url) + ManualRefreshOperation( + profileId = effectiveProfileId, + deferred = deferred, + ).also { created -> + manualRefreshOperation = created + deferred.invokeOnCompletion { + scope.launch { + synchronized(stateLock) { + if (manualRefreshOperation === created) { + manualRefreshOperation = null + } + } + } } } - pulledFromServer = true - initialized = true - return } - } - - val existingByUrl = _uiState.value.addons.associateBy(ManagedAddon::manifestUrl) - _uiState.value = AddonsUiState( - addons = urls.map { url -> - val row = rowsByUrl[url] - existingByUrl[url].toPendingAddon( - manifestUrl = url, - userSetName = row?.name?.takeIf { it.isNotBlank() }, - enabled = row?.enabled, - ) - }, - ) - persist() - urls.forEach { url -> - val existing = existingByUrl[url] - val addon = _uiState.value.addons.firstOrNull { it.manifestUrl == url } - if (addon?.enabled == true && (existing == null || (addon.manifest == null && !addon.isRefreshing))) { - refreshAddon(url) - } - } - pulledFromServer = true - initialized = true - log.i { "pullFromServer() — applied ${urls.size} addons to state" } - }.onFailure { e -> - log.e(e) { "pullFromServer() — FAILED" } } + return operation.deferred.await() } suspend fun awaitManifestsLoaded() { @@ -251,200 +278,768 @@ object AddonRepository { return AddAddonResult.Error(error.message ?: getString(Res.string.addon_load_manifest_failed)) } - _uiState.update { current -> - current.copy( - addons = current.addons + ManagedAddon( - manifestUrl = manifestUrl, - manifest = manifest, - isRefreshing = false, - errorMessage = null, - ), - ) + synchronized(stateLock) { + _uiState.update { current -> + current.copy( + addons = current.addons + ManagedAddon( + manifestUrl = manifestUrl, + manifest = manifest, + isRefreshing = false, + errorMessage = null, + ), + ) + } + persistLocked(currentProfileId) + queueCurrentStatePushLocked() } - persist() - pushToServer() + schedulePendingPush() return AddAddonResult.Success(manifest) } fun removeAddon(manifestUrl: String) { if (isUsingPrimaryAddonsFromSecondaryProfile()) return log.i { "removeAddon() — $manifestUrl" } - _uiState.update { current -> - current.copy( - addons = current.addons.filterNot { it.manifestUrl == manifestUrl }, - ) + synchronized(stateLock) { + _uiState.update { current -> + current.copy( + addons = current.addons.filterNot { it.manifestUrl == manifestUrl }, + ) + } + persistLocked(currentProfileId) + queueCurrentStatePushLocked() } - persist() - pushToServer() + schedulePendingPush() } fun moveAddon(fromIndex: Int, toIndex: Int) { if (isUsingPrimaryAddonsFromSecondaryProfile()) return - _uiState.update { current -> - val addons = current.addons - if ( - fromIndex !in addons.indices || - toIndex !in addons.indices || - fromIndex == toIndex - ) { - return@update current - } + var changed = false + synchronized(stateLock) { + _uiState.update { current -> + val addons = current.addons + if ( + fromIndex !in addons.indices || + toIndex !in addons.indices || + fromIndex == toIndex + ) { + return@update current + } - val reordered = addons.toMutableList() - val movingAddon = reordered.removeAt(fromIndex) - reordered.add(toIndex, movingAddon) - current.copy(addons = reordered) + val reordered = addons.toMutableList() + val movingAddon = reordered.removeAt(fromIndex) + reordered.add(toIndex, movingAddon) + changed = true + current.copy(addons = reordered) + } + if (changed) { + persistLocked(currentProfileId) + queueCurrentStatePushLocked() + } } - persist() - pushToServer() + if (changed) schedulePendingPush() } fun setAddonEnabled(manifestUrl: String, enabled: Boolean) { if (isUsingPrimaryAddonsFromSecondaryProfile()) return var shouldRefresh = false - _uiState.update { current -> - current.copy( - addons = current.addons.map { addon -> - if (addon.manifestUrl != manifestUrl || addon.enabled == enabled) { - addon - } else { - shouldRefresh = enabled && addon.manifest == null && !addon.isRefreshing - addon.copy(enabled = enabled) - } - }, - ) + var changed = false + synchronized(stateLock) { + _uiState.update { current -> + current.copy( + addons = current.addons.map { addon -> + if (addon.manifestUrl != manifestUrl || addon.enabled == enabled) { + addon + } else { + changed = true + shouldRefresh = enabled && addon.manifest == null && !addon.isRefreshing + addon.copy(enabled = enabled) + } + }, + ) + } + if (changed) { + persistLocked(currentProfileId) + queueCurrentStatePushLocked() + } } - persist() - pushToServer() + if (changed) schedulePendingPush() if (shouldRefresh) { refreshAddon(manifestUrl) } } - fun refreshAll() { - _uiState.value.addons.filter { it.enabled }.distinctBy { it.manifestUrl }.forEach { addon -> - refreshAddon(addon.manifestUrl) - } - } - - fun refreshAddon(manifestUrl: String) { - val existingJob = activeRefreshJobs[manifestUrl] - if (existingJob?.isActive == true) return - - markRefreshing(manifestUrl) - var refreshJob: Job? = null - refreshJob = scope.launch { - try { - val result = runCatching { - val payload = httpGetText(manifestUrl) - AddonManifestParser.parse( - manifestUrl = manifestUrl, - payload = payload, - ) - } - + fun refreshAddon( + manifestUrl: String, + expectedProfileId: Int? = null, + ) { + lateinit var refreshJob: Job + val shouldStart = synchronized(stateLock) { + val profileId = expectedProfileId ?: currentProfileId + if ( + currentProfileId != profileId || + activeRefreshJobs[manifestUrl]?.isActive == true + ) { + false + } else { _uiState.update { current -> current.copy( addons = current.addons.map { addon -> - if (addon.manifestUrl != manifestUrl) { - addon - } else { - result.fold( - onSuccess = { manifest -> - addon.copy( - manifest = manifest, - isRefreshing = false, - errorMessage = null, - ) - }, - onFailure = { error -> - addon.copy( - isRefreshing = false, - errorMessage = error.message ?: getString(Res.string.addon_load_manifest_failed), - ) - }, + if (addon.manifestUrl == manifestUrl) { + addon.copy( + isRefreshing = true, + errorMessage = null, ) + } else { + addon } }, ) } - } finally { - if (activeRefreshJobs[manifestUrl] === refreshJob) { - activeRefreshJobs.remove(manifestUrl) + refreshJob = scope.launch(start = CoroutineStart.LAZY) { + try { + applyManifestResult( + manifestUrl = manifestUrl, + result = fetchManifest(manifestUrl), + expectedProfileId = profileId, + ) + } finally { + synchronized(stateLock) { + if (activeRefreshJobs[manifestUrl] === refreshJob) { + activeRefreshJobs.remove(manifestUrl) + } + } + } } + activeRefreshJobs[manifestUrl] = refreshJob + true } } - activeRefreshJobs[manifestUrl] = refreshJob + if (shouldStart) { + refreshJob.start() + } } - private fun pushToServer() { - scope.launch { - runCatching { - if (isUsingPrimaryAddonsFromSecondaryProfile()) { - return@runCatching + private suspend fun performAutomaticPull(profileId: Int) { + val isCurrentProfile = synchronized(stateLock) { + if (currentEffectiveProfileId() != profileId) { + false + } else { + currentProfileId = profileId + true + } + } + if (!isCurrentProfile) return + val expectedRevision = synchronized(stateLock) { localMutationRevision } + val snapshot = fetchRemoteSnapshot(profileId) + val rowsByUrl = snapshot.normalizedRowsByUrl() + val urls = rowsByUrl.keys.toList() + log.i { "pullFromServer() — server returned ${snapshot.rows.size} addons" } + + val shouldAttemptMigration = synchronized(stateLock) { !pulledFromServer } + if (urls.isEmpty() && shouldAttemptMigration) { + val localUrls = dedupeManifestUrls(AddonStorage.loadInstalledAddonUrls(profileId)) + log.i { "pullFromServer() — server empty, local has ${localUrls.size} addons" } + if (localUrls.isNotEmpty()) { + if (currentEffectiveProfileId() != profileId) return + log.i { "pullFromServer() — migrating local addons to server for profile $profileId" } + val enabledByUrl = loadLocalEnabledStates(profileId) + val namesByUrl = loadLocalNames(profileId) + val existingByUrl = synchronized(stateLock) { + if (currentProfileId != profileId) return + _uiState.value.addons.associateBy(ManagedAddon::manifestUrl) } - val profileId = currentProfileId - val addons = _uiState.value.addons - .distinctBy { it.manifestUrl } - .mapIndexed { index, addon -> + val request = PendingAddonPush( + profileId = profileId, + revision = expectedRevision, + addons = localUrls.mapIndexed { index, url -> AddonPushItem( - url = addon.manifestUrl, - name = addon.userSetName?.takeIf { it.isNotBlank() } ?: addon.manifest?.name ?: "", - enabled = addon.enabled, + url = url, + name = namesByUrl[url] + ?: existingByUrl[url]?.userSetName + ?: "", + enabled = enabledByUrl[url] ?: true, sortOrder = index, ) - } - log.d { "pushToServer() — profileId=$profileId, pushing ${addons.size} addons" } - val params = buildJsonObject { - put("p_profile_id", profileId) - put("p_addons", json.encodeToJsonElement(addons)) - putSyncOriginClientId() + }, + ) + if (currentEffectiveProfileId() != profileId) return + if (!pushSnapshot(request)) { + error("Failed to migrate local addons to Nuvio Sync") } - SupabaseProvider.client.postgrest.rpc("sync_push_addons", params) - log.d { "pushToServer() — success" } - }.onFailure { e -> - log.e(e) { "pushToServer() — FAILED" } + synchronized(stateLock) { + if (currentProfileId == profileId && currentEffectiveProfileId() == profileId) { + _uiState.update { current -> + current.copy( + addons = localUrls.map { url -> + existingByUrl[url].toPendingAddon( + manifestUrl = url, + userSetName = namesByUrl[url], + enabled = enabledByUrl[url], + ) + }, + ) + } + persistLocked(profileId) + pulledFromServer = true + initialized = true + } + } + refreshMissingManifests( + profileId = profileId, + existingByUrl = existingByUrl, + ) + log.i { "pullFromServer() — migration push done (${request.addons.size} addons)" } + return + } + } + + if (urls.isEmpty()) { + val localUrls = dedupeManifestUrls(AddonStorage.loadInstalledAddonUrls(profileId)) + if (localUrls.isNotEmpty()) { + log.w { + "pullFromServer() — remote empty while local has ${localUrls.size} addons; " + + "preserving existing startup behavior" + } + val enabledByUrl = loadLocalEnabledStates(profileId) + val namesByUrl = loadLocalNames(profileId) + val existingByUrl = _uiState.value.addons.associateBy(ManagedAddon::manifestUrl) + synchronized(stateLock) { + if ( + !canApplyRemoteAddonSnapshot( + currentProfileId = currentProfileId, + snapshotProfileId = profileId, + currentMutationRevision = localMutationRevision, + expectedMutationRevision = expectedRevision, + hasPendingPush = pendingPushes.containsKey(profileId), + ) + ) { + log.w { "pullFromServer() — local addons changed while the cloud request was running" } + return + } + _uiState.update { current -> + current.copy( + addons = localUrls.map { url -> + existingByUrl[url].toPendingAddon( + manifestUrl = url, + userSetName = namesByUrl[url], + enabled = enabledByUrl[url], + ) + }, + ) + } + persistLocked(profileId) + pulledFromServer = true + initialized = true + } + refreshMissingManifests( + profileId = profileId, + existingByUrl = existingByUrl, + ) + return + } + } + + when ( + val applied = applyRemoteSnapshot( + snapshot = snapshot, + expectedRevision = expectedRevision, + forceManifestRefresh = false, + ) + ) { + is SnapshotApplyResult.Applied -> { + applied.addons + .filter { it.enabled && it.manifest == null } + .forEach { + refreshAddon( + manifestUrl = it.manifestUrl, + expectedProfileId = profileId, + ) + } + synchronized(stateLock) { + if (currentProfileId == profileId && currentEffectiveProfileId() == profileId) { + pulledFromServer = true + initialized = true + } + } + log.i { "pullFromServer() — applied ${applied.addons.size} addons to state" } + } + + SnapshotApplyResult.Conflict -> { + log.w { "pullFromServer() — local addon changes won; remote snapshot was not applied" } } } } - private fun markRefreshing(manifestUrl: String) { - _uiState.update { current -> - current.copy( - addons = current.addons.map { addon -> - if (addon.manifestUrl == manifestUrl) { - addon.copy( - isRefreshing = true, - errorMessage = null, - ) - } else { - addon + private suspend fun performManualRefresh(profileId: Int): AddonRefreshState { + val result = try { + addonSyncMutex.withLock { + val authState = AuthRepository.state.value + val usesCloudSync = authState is AuthState.Authenticated && !authState.isAnonymous + if (usesCloudSync && !flushPendingPushLocked(profileId)) { + return@withLock AddonRefreshState.Failed( + getString(Res.string.addons_refresh_error_pending_upload), + ) + } + if (currentEffectiveProfileId() != profileId) { + return@withLock AddonRefreshState.Conflict + } + + val addons = when (authState) { + is AuthState.Authenticated -> { + if (authState.isAnonymous) { + prepareLocalManifestRefresh(profileId) + } else { + val expectedRevision = synchronized(stateLock) { localMutationRevision } + val snapshot = try { + withTimeoutOrNull(REMOTE_PROFILE_TIMEOUT_MS) { + fetchRemoteSnapshot(profileId) + } ?: return@withLock AddonRefreshState.Failed( + getString(Res.string.addons_refresh_error_sync_timeout), + ) + } catch (error: CancellationException) { + throw error + } catch (error: Throwable) { + return@withLock AddonRefreshState.Failed( + error.message ?: getString(Res.string.addons_refresh_error_sync), + ) + } + + when ( + val applied = applyRemoteSnapshot( + snapshot = snapshot, + expectedRevision = expectedRevision, + forceManifestRefresh = true, + ) + ) { + is SnapshotApplyResult.Applied -> applied.addons + SnapshotApplyResult.Conflict -> { + return@withLock if (flushPendingPushLocked(profileId)) { + AddonRefreshState.Conflict + } else { + AddonRefreshState.Failed( + getString(Res.string.addons_refresh_error_pending_upload), + ) + } + } + } + } } + + AuthState.Loading, + AuthState.Unauthenticated, + -> prepareLocalManifestRefresh(profileId) + } + + val manifestResult = refreshEnabledManifests( + addons = addons, + profileId = profileId, + ) + if (!usesCloudSync || flushPendingPushLocked(profileId)) { + manifestResult + } else { + manifestResult.withPendingPushWarning() + } + } + } catch (error: CancellationException) { + throw error + } catch (error: Throwable) { + log.e(error) { "refreshAll() — FAILED" } + AddonRefreshState.Failed( + error.message ?: getString(Res.string.addons_refresh_error_default), + ) + } + + synchronized(stateLock) { + if (currentEffectiveProfileId() == profileId) { + if ( + result is AddonRefreshState.Complete || + result is AddonRefreshState.Partial + ) { + refreshRevision += 1 + } + _uiState.update { current -> + current.copy( + refreshState = result, + refreshRevision = refreshRevision, + ) + } + } + } + return result + } + + private suspend fun fetchRemoteSnapshot(profileId: Int): RemoteAddonSnapshot = + RemoteAddonSnapshot( + profileId = profileId, + rows = SupabaseProvider.client.postgrest + .from("addons") + .select { + filter { eq("profile_id", profileId) } + order("sort_order", Order.ASCENDING) + } + .decodeList(), + ) + + private fun applyRemoteSnapshot( + snapshot: RemoteAddonSnapshot, + expectedRevision: Long, + forceManifestRefresh: Boolean, + ): SnapshotApplyResult { + val rowsByUrl = snapshot.normalizedRowsByUrl() + return synchronized(stateLock) { + if ( + !canApplyRemoteAddonSnapshot( + currentProfileId = currentProfileId, + snapshotProfileId = snapshot.profileId, + currentMutationRevision = localMutationRevision, + expectedMutationRevision = expectedRevision, + hasPendingPush = pendingPushes.containsKey(snapshot.profileId), + ) + ) { + return@synchronized SnapshotApplyResult.Conflict + } + + if (forceManifestRefresh) { + activeRefreshJobs.values.forEach(Job::cancel) + activeRefreshJobs.clear() + } + val addons = mergeRemoteAddonSnapshot( + existingAddons = _uiState.value.addons, + remoteAddons = rowsByUrl.map { (url, row) -> + RemoteAddonValue( + manifestUrl = url, + userSetName = row.name, + enabled = row.enabled, + ) }, + forceManifestRefresh = forceManifestRefresh, + ) + _uiState.update { current -> + current.copy(addons = addons) + } + persistLocked(snapshot.profileId) + SnapshotApplyResult.Applied(addons) + } + } + + private fun prepareLocalManifestRefresh(profileId: Int): List { + return synchronized(stateLock) { + if (currentProfileId != profileId) return@synchronized emptyList() + activeRefreshJobs.values.forEach(Job::cancel) + activeRefreshJobs.clear() + val addons = _uiState.value.addons.map { addon -> + if (addon.enabled) { + addon.copy(isRefreshing = true, errorMessage = null) + } else { + addon + } + } + _uiState.update { current -> current.copy(addons = addons) } + addons + } + } + + private suspend fun refreshEnabledManifests( + addons: List, + profileId: Int, + ): AddonRefreshState { + val enabledUrls = addons + .filter(ManagedAddon::enabled) + .map(ManagedAddon::manifestUrl) + .distinct() + if (enabledUrls.isEmpty()) { + return AddonRefreshState.Complete( + addonCount = addons.size, + refreshedManifestCount = 0, + ) + } + + val semaphore = Semaphore(MANIFEST_REFRESH_CONCURRENCY) + val manifestTimeoutMessage = getString(Res.string.addons_refresh_error_manifest_timeout) + val completed = withTimeoutOrNull(MANIFEST_REFRESH_TOTAL_TIMEOUT_MS) { + coroutineScope { + enabledUrls.map { manifestUrl -> + async { + semaphore.withPermit { + val result = withTimeoutOrNull(MANIFEST_REFRESH_TIMEOUT_MS) { + fetchManifest(manifestUrl) + } ?: Result.failure(IllegalStateException(manifestTimeoutMessage)) + applyManifestResult( + manifestUrl = manifestUrl, + result = result, + expectedProfileId = profileId, + ) + result.isSuccess + } + } + }.awaitAll() + } + } + + val refreshedCount = completed?.count { it } ?: synchronized(stateLock) { + enabledUrls.count { url -> + _uiState.value.addons.firstOrNull { it.manifestUrl == url } + ?.let { !it.isRefreshing && it.errorMessage == null && it.manifest != null } + ?: false + } + } + val failedCount = enabledUrls.size - refreshedCount + if (completed == null) { + finishTimedOutManifests( + manifestUrls = enabledUrls, + expectedProfileId = profileId, + ) + } + + return if (failedCount == 0) { + AddonRefreshState.Complete( + addonCount = addons.size, + refreshedManifestCount = refreshedCount, + ) + } else { + AddonRefreshState.Partial( + addonCount = addons.size, + refreshedManifestCount = refreshedCount, + failedManifestCount = failedCount, ) } } - private fun persist() { + private suspend fun AddonRefreshState.withPendingPushWarning(): AddonRefreshState { + val warning = getString(Res.string.addons_refresh_warning_pending_upload) + return when (this) { + is AddonRefreshState.Complete -> AddonRefreshState.Partial( + addonCount = addonCount, + refreshedManifestCount = refreshedManifestCount, + failedManifestCount = 0, + warningMessage = warning, + ) + is AddonRefreshState.Partial -> copy(warningMessage = warning) + else -> this + } + } + + private suspend fun fetchManifest(manifestUrl: String): Result = + try { + val payload = httpGetText(manifestUrl) + Result.success( + AddonManifestParser.parse( + manifestUrl = manifestUrl, + payload = payload, + ), + ) + } catch (error: CancellationException) { + throw error + } catch (error: Throwable) { + Result.failure(error) + } + + private suspend fun applyManifestResult( + manifestUrl: String, + result: Result, + expectedProfileId: Int, + ) { + val fallbackError = if (result.isFailure) { + getString(Res.string.addon_load_manifest_failed) + } else { + "" + } + synchronized(stateLock) { + if (currentProfileId != expectedProfileId) return + _uiState.update { current -> + current.copy( + addons = current.addons.map { addon -> + if (addon.manifestUrl != manifestUrl) { + addon + } else { + result.fold( + onSuccess = { manifest -> + addon.copy( + manifest = manifest, + isRefreshing = false, + errorMessage = null, + ) + }, + onFailure = { error -> + addon.copy( + isRefreshing = false, + errorMessage = error.message ?: fallbackError, + ) + }, + ) + } + }, + ) + } + } + } + + private suspend fun finishTimedOutManifests( + manifestUrls: List, + expectedProfileId: Int, + ) { + val timeoutMessage = getString(Res.string.addon_load_manifest_failed) + synchronized(stateLock) { + if (currentProfileId != expectedProfileId) return + _uiState.update { current -> + current.copy( + addons = current.addons.map { addon -> + if (addon.manifestUrl in manifestUrls && addon.isRefreshing) { + addon.copy( + isRefreshing = false, + errorMessage = timeoutMessage, + ) + } else { + addon + } + }, + ) + } + } + } + + private fun refreshMissingManifests( + profileId: Int, + existingByUrl: Map, + ) { + val urlsToRefresh = synchronized(stateLock) { + if (currentProfileId != profileId) return + _uiState.value.addons.mapNotNull { addon -> + val existing = existingByUrl[addon.manifestUrl] + addon.manifestUrl.takeIf { + addon.enabled && + (existing == null || (addon.manifest == null && !addon.isRefreshing)) + } + } + } + urlsToRefresh.forEach { manifestUrl -> + refreshAddon( + manifestUrl = manifestUrl, + expectedProfileId = profileId, + ) + } + } + + private fun queueCurrentStatePushLocked() { + localMutationRevision += 1 + pendingPushes[currentProfileId] = buildPushRequestLocked( + profileId = currentProfileId, + revision = localMutationRevision, + ) + } + + private fun buildPushRequestLocked( + profileId: Int, + revision: Long, + ): PendingAddonPush = + PendingAddonPush( + profileId = profileId, + revision = revision, + addons = _uiState.value.addons + .distinctBy(ManagedAddon::manifestUrl) + .mapIndexed { index, addon -> + AddonPushItem( + url = addon.manifestUrl, + name = addon.userSetName?.takeIf { it.isNotBlank() }.orEmpty(), + enabled = addon.enabled, + sortOrder = index, + ) + }, + ) + + private fun schedulePendingPush() { + scope.launch { + val authState = AuthRepository.state.value + if (authState !is AuthState.Authenticated || authState.isAnonymous) { + return@launch + } + addonSyncMutex.withLock { + flushAllPendingPushesLocked() + } + } + } + + private suspend fun flushPendingPushLocked(profileId: Int): Boolean { + while (true) { + val request = synchronized(stateLock) { pendingPushes[profileId] } ?: return true + if (!pushSnapshot(request)) return false + synchronized(stateLock) { + if (pendingPushes[profileId]?.revision == request.revision) { + pendingPushes.remove(profileId) + } + } + } + } + + private suspend fun flushAllPendingPushesLocked(): Boolean { + val profileIds = synchronized(stateLock) { pendingPushes.keys.toList() } + var allSucceeded = true + profileIds.forEach { profileId -> + if (!flushPendingPushLocked(profileId)) { + allSucceeded = false + } + } + return allSucceeded + } + + private suspend fun pushSnapshot(request: PendingAddonPush): Boolean = + runCatching { + log.d { + "pushToServer() — profileId=${request.profileId}, " + + "pushing ${request.addons.size} addons" + } + val params = buildJsonObject { + put("p_profile_id", request.profileId) + put("p_addons", json.encodeToJsonElement(request.addons)) + putSyncOriginClientId() + } + SupabaseProvider.client.postgrest.rpc("sync_push_addons", params) + log.d { "pushToServer() — success" } + }.onFailure { error -> + log.e(error) { "pushToServer() — FAILED" } + }.isSuccess + + private fun RemoteAddonSnapshot.normalizedRowsByUrl(): LinkedHashMap = + linkedMapOf().apply { + rows.forEach { row -> + if (row.url.isBlank()) return@forEach + val manifestUrl = ensureManifestSuffix(row.url) + if (!containsKey(manifestUrl)) { + put(manifestUrl, row.copy(url = manifestUrl)) + } + } + } + + private fun currentEffectiveProfileId(): Int = + resolveEffectiveProfileId(ProfileRepository.activeProfileId) + + private fun persistLocked(profileId: Int) { 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.saveAddonNames( + profileId, + addons.mapNotNull { addon -> + addon.userSetName + ?.takeIf(String::isNotBlank) + ?.let { addon.manifestUrl to it } + }.toMap(), + ) } - private fun loadLocalEnabledStates(): Map = - AddonStorage.loadAddonEnabledStates(currentProfileId) + private fun loadLocalEnabledStates(profileId: Int): Map = + AddonStorage.loadAddonEnabledStates(profileId) .mapKeys { (url, _) -> ensureManifestSuffix(url) } - private fun cancelActiveRefreshes() { - activeRefreshJobs.values.forEach(Job::cancel) - activeRefreshJobs.clear() - } + private fun loadLocalNames(profileId: Int): Map = + AddonStorage.loadAddonNames(profileId) + .mapKeys { (url, _) -> ensureManifestSuffix(url) } private fun resolveEffectiveProfileId(profileId: Int): Int { val active = ProfileRepository.state.value.activeProfile @@ -457,7 +1052,7 @@ object AddonRepository { } } -private fun ManagedAddon?.toPendingAddon( +internal fun ManagedAddon?.toPendingAddon( manifestUrl: String, userSetName: String? = null, enabled: Boolean? = null, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/addons/AddonsScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/addons/AddonsScreen.kt index 2d7db4a85..b2ca17c19 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/addons/AddonsScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/addons/AddonsScreen.kt @@ -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 { diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeRepository.kt index 04427a33a..e180a52d6 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeRepository.kt @@ -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, + revision: Long, + ): Boolean { + if (revision <= 0L || revision <= lastHandledAddonRefreshRevision) return false + lastHandledAddonRefreshRevision = revision + refresh(addons, force = true) + return true + } fun refresh(addons: List, force: Boolean = false) { val activeAddons = addons.enabledAddons() diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeScreen.kt index af37817bd..492528ba7 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeScreen.kt @@ -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) { diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/addons/AddonRefreshRulesTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/addons/AddonRefreshRulesTest.kt new file mode 100644 index 000000000..a68207404 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/addons/AddonRefreshRulesTest.kt @@ -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", +) 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..c1f2029df 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 addonNamesKey = "installed_manifest_names" actual fun loadInstalledAddonUrls(profileId: Int): List = NSUserDefaults.standardUserDefaults @@ -59,6 +60,24 @@ actual object AddonStorage { forKey = "${addonEnabledStatesKey}_$profileId", ) } + + actual fun loadAddonNames(profileId: Int): Map = + NSUserDefaults.standardUserDefaults + .stringForKey("${addonNamesKey}_$profileId") + .orEmpty() + .lineSequence() + .mapNotNull(::parseAddonNameLine) + .toMap() + + actual fun saveAddonNames(profileId: Int, names: Map) { + 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? { @@ -71,6 +90,12 @@ private fun parseEnabledStateLine(line: String): Pair? { return url to enabled } +private fun parseAddonNameLine(line: String): Pair? { + 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