Revert "feat: implement manifest caching for addons"

This reverts commit 018e1e50a8.
This commit is contained in:
tapframe 2026-04-16 14:10:00 +05:30
parent db7baf11e3
commit 21793b8ac7
5 changed files with 76 additions and 201 deletions

View file

@ -19,7 +19,6 @@ import java.util.concurrent.TimeUnit
actual object AddonStorage { actual object AddonStorage {
private const val preferencesName = "nuvio_addons" private const val preferencesName = "nuvio_addons"
private const val addonUrlsKey = "installed_manifest_urls" private const val addonUrlsKey = "installed_manifest_urls"
private const val manifestCacheKey = "manifest_cache_payload"
private var preferences: SharedPreferences? = null private var preferences: SharedPreferences? = null
@ -42,20 +41,8 @@ actual object AddonStorage {
?.putString("${addonUrlsKey}_$profileId", urls.joinToString(separator = "\n")) ?.putString("${addonUrlsKey}_$profileId", urls.joinToString(separator = "\n"))
?.apply() ?.apply()
} }
actual fun loadManifestCachePayload(profileId: Int): String? =
preferences?.getString("${manifestCacheKey}_$profileId", null)
actual fun saveManifestCachePayload(profileId: Int, payload: String) {
preferences
?.edit()
?.putString("${manifestCacheKey}_$profileId", payload)
?.apply()
}
} }
internal actual fun addonEpochMs(): Long = System.currentTimeMillis()
private val addonHttpClient = OkHttpClient.Builder() private val addonHttpClient = OkHttpClient.Builder()
.dns(IPv4FirstDns()) .dns(IPv4FirstDns())
.connectTimeout(60, TimeUnit.SECONDS) .connectTimeout(60, TimeUnit.SECONDS)

View file

@ -1,31 +0,0 @@
package com.nuvio.app.features.addons
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
@Serializable
internal data class AddonManifestCachePayload(
val entries: List<AddonManifestCacheEntry> = emptyList(),
)
@Serializable
internal data class AddonManifestCacheEntry(
val manifestUrl: String,
val payload: String,
val fetchedAtEpochMs: Long,
)
internal object AddonManifestCacheCodec {
private val json = Json { ignoreUnknownKeys = true; encodeDefaults = true }
fun decode(payload: String): List<AddonManifestCacheEntry>? =
runCatching {
json.decodeFromString(AddonManifestCachePayload.serializer(), payload).entries
}.getOrNull()
fun encode(entries: Collection<AddonManifestCacheEntry>): String =
json.encodeToString(
AddonManifestCachePayload.serializer(),
AddonManifestCachePayload(entries = entries.toList()),
)
}

View file

@ -3,12 +3,8 @@ package com.nuvio.app.features.addons
internal expect object AddonStorage { internal expect object AddonStorage {
fun loadInstalledAddonUrls(profileId: Int): List<String> fun loadInstalledAddonUrls(profileId: Int): List<String>
fun saveInstalledAddonUrls(profileId: Int, urls: List<String>) fun saveInstalledAddonUrls(profileId: Int, urls: List<String>)
fun loadManifestCachePayload(profileId: Int): String?
fun saveManifestCachePayload(profileId: Int, payload: String)
} }
internal expect fun addonEpochMs(): Long
data class RawHttpResponse( data class RawHttpResponse(
val status: Int, val status: Int,
val statusText: String, val statusText: String,

View file

@ -51,7 +51,6 @@ object AddonRepository {
private var pulledFromServer = false private var pulledFromServer = false
private var currentProfileId: Int = 1 private var currentProfileId: Int = 1
private val activeRefreshJobs = mutableMapOf<String, Job>() private val activeRefreshJobs = mutableMapOf<String, Job>()
private var manifestCacheByUrl: Map<String, AddonManifestCacheEntry> = emptyMap()
fun initialize() { fun initialize() {
val effectiveProfileId = resolveEffectiveProfileId(ProfileRepository.activeProfileId) val effectiveProfileId = resolveEffectiveProfileId(ProfileRepository.activeProfileId)
@ -62,12 +61,21 @@ object AddonRepository {
val storedUrls = dedupeManifestUrls(AddonStorage.loadInstalledAddonUrls(currentProfileId)) val storedUrls = dedupeManifestUrls(AddonStorage.loadInstalledAddonUrls(currentProfileId))
log.d { "initialize() — local addon count: ${storedUrls.size}" } log.d { "initialize() — local addon count: ${storedUrls.size}" }
if (storedUrls.isEmpty()) { if (storedUrls.isEmpty()) return
manifestCacheByUrl = emptyMap()
return
}
applyAddonsFromUrls(storedUrls) val existingByUrl = _uiState.value.addons.associateBy(ManagedAddon::manifestUrl)
_uiState.value = AddonsUiState(
addons = storedUrls.map { manifestUrl ->
existingByUrl[manifestUrl].toPendingAddon(manifestUrl)
},
)
storedUrls.forEach { manifestUrl ->
val existing = existingByUrl[manifestUrl]
if (existing == null || (existing.manifest == null && !existing.isRefreshing)) {
refreshAddon(manifestUrl)
}
}
} }
fun onProfileChanged(profileId: Int) { fun onProfileChanged(profileId: Int) {
@ -77,15 +85,11 @@ object AddonRepository {
currentProfileId = effectiveProfileId currentProfileId = effectiveProfileId
initialized = false initialized = false
pulledFromServer = false pulledFromServer = false
manifestCacheByUrl = emptyMap()
_uiState.value = AddonsUiState() _uiState.value = AddonsUiState()
} }
fun clearLocalState() { fun clearLocalState() {
val profileToClear = currentProfileId
cancelActiveRefreshes() cancelActiveRefreshes()
manifestCacheByUrl = emptyMap()
AddonStorage.saveManifestCachePayload(profileToClear, AddonManifestCacheCodec.encode(emptyList()))
currentProfileId = 1 currentProfileId = 1
initialized = false initialized = false
pulledFromServer = false pulledFromServer = false
@ -145,16 +149,38 @@ object AddonRepository {
val localUrls = dedupeManifestUrls(AddonStorage.loadInstalledAddonUrls(currentProfileId)) val localUrls = dedupeManifestUrls(AddonStorage.loadInstalledAddonUrls(currentProfileId))
if (localUrls.isNotEmpty()) { if (localUrls.isNotEmpty()) {
log.w { "pullFromServer() — remote empty while local has ${localUrls.size} addons; preserving local addons" } log.w { "pullFromServer() — remote empty while local has ${localUrls.size} addons; preserving local addons" }
applyAddonsFromUrls(localUrls) val existingByUrl = _uiState.value.addons.associateBy(ManagedAddon::manifestUrl)
_uiState.value = AddonsUiState(
addons = localUrls.map { url ->
existingByUrl[url].toPendingAddon(url)
},
)
persist() persist()
localUrls.forEach { url ->
val existing = existingByUrl[url]
if (existing == null || (existing.manifest == null && !existing.isRefreshing)) {
refreshAddon(url)
}
}
pulledFromServer = true pulledFromServer = true
initialized = true initialized = true
return return
} }
} }
applyAddonsFromUrls(urls, namesByUrl) val existingByUrl = _uiState.value.addons.associateBy(ManagedAddon::manifestUrl)
_uiState.value = AddonsUiState(
addons = urls.map { url ->
existingByUrl[url].toPendingAddon(url, namesByUrl[url])
},
)
persist() persist()
urls.forEach { url ->
val existing = existingByUrl[url]
if (existing == null || (existing.manifest == null && !existing.isRefreshing)) {
refreshAddon(url)
}
}
pulledFromServer = true pulledFromServer = true
initialized = true initialized = true
log.i { "pullFromServer() — applied ${urls.size} addons to state" } log.i { "pullFromServer() — applied ${urls.size} addons to state" }
@ -185,19 +211,17 @@ object AddonRepository {
return AddAddonResult.Error("That addon is already installed.") return AddAddonResult.Error("That addon is already installed.")
} }
val fetched = try { val manifest = try {
withContext(Dispatchers.Default) { withContext(Dispatchers.Default) {
val payload = httpGetText(manifestUrl) val payload = httpGetText(manifestUrl)
val manifest = AddonManifestParser.parse( AddonManifestParser.parse(
manifestUrl = manifestUrl, manifestUrl = manifestUrl,
payload = payload, payload = payload,
) )
payload to manifest
} }
} catch (error: Throwable) { } catch (error: Throwable) {
return AddAddonResult.Error(error.message ?: "Unable to load manifest") return AddAddonResult.Error(error.message ?: "Unable to load manifest")
} }
val (payload, manifest) = fetched
_uiState.update { current -> _uiState.update { current ->
current.copy( current.copy(
@ -209,7 +233,6 @@ object AddonRepository {
), ),
) )
} }
updateManifestCache(manifestUrl, payload)
persist() persist()
pushToServer() pushToServer()
return AddAddonResult.Success(manifest) return AddAddonResult.Success(manifest)
@ -218,16 +241,11 @@ object AddonRepository {
fun removeAddon(manifestUrl: String) { fun removeAddon(manifestUrl: String) {
if (isUsingPrimaryAddonsFromSecondaryProfile()) return if (isUsingPrimaryAddonsFromSecondaryProfile()) return
log.i { "removeAddon() — $manifestUrl" } log.i { "removeAddon() — $manifestUrl" }
val normalizedUrl = ensureManifestSuffix(manifestUrl)
_uiState.update { current -> _uiState.update { current ->
current.copy( current.copy(
addons = current.addons.filterNot { it.manifestUrl == normalizedUrl }, addons = current.addons.filterNot { it.manifestUrl == manifestUrl },
) )
} }
if (manifestCacheByUrl.containsKey(normalizedUrl)) {
manifestCacheByUrl = manifestCacheByUrl - normalizedUrl
persistManifestCache()
}
persist() persist()
pushToServer() pushToServer()
} }
@ -239,34 +257,29 @@ object AddonRepository {
} }
fun refreshAddon(manifestUrl: String) { fun refreshAddon(manifestUrl: String) {
val normalizedUrl = ensureManifestSuffix(manifestUrl) val existingJob = activeRefreshJobs[manifestUrl]
val existingJob = activeRefreshJobs[normalizedUrl]
if (existingJob?.isActive == true) return if (existingJob?.isActive == true) return
markRefreshing(normalizedUrl) markRefreshing(manifestUrl)
var refreshJob: Job? = null var refreshJob: Job? = null
refreshJob = scope.launch { refreshJob = scope.launch {
try { try {
val result = runCatching { val result = runCatching {
val payload = httpGetText(normalizedUrl) val payload = httpGetText(manifestUrl)
val manifest = AddonManifestParser.parse( AddonManifestParser.parse(
manifestUrl = normalizedUrl, manifestUrl = manifestUrl,
payload = payload, payload = payload,
) )
payload to manifest
}
result.onSuccess { (payload, _) ->
updateManifestCache(normalizedUrl, payload)
} }
_uiState.update { current -> _uiState.update { current ->
current.copy( current.copy(
addons = current.addons.map { addon -> addons = current.addons.map { addon ->
if (addon.manifestUrl != normalizedUrl) { if (addon.manifestUrl != manifestUrl) {
addon addon
} else { } else {
result.fold( result.fold(
onSuccess = { (_, manifest) -> onSuccess = { manifest ->
addon.copy( addon.copy(
manifest = manifest, manifest = manifest,
isRefreshing = false, isRefreshing = false,
@ -276,7 +289,7 @@ object AddonRepository {
onFailure = { error -> onFailure = { error ->
addon.copy( addon.copy(
isRefreshing = false, isRefreshing = false,
errorMessage = error.message ?: addon.errorMessage ?: "Unable to load manifest", errorMessage = error.message ?: "Unable to load manifest",
) )
}, },
) )
@ -285,111 +298,12 @@ object AddonRepository {
) )
} }
} finally { } finally {
if (activeRefreshJobs[normalizedUrl] === refreshJob) { if (activeRefreshJobs[manifestUrl] === refreshJob) {
activeRefreshJobs.remove(normalizedUrl) activeRefreshJobs.remove(manifestUrl)
} }
} }
} }
activeRefreshJobs[normalizedUrl] = refreshJob activeRefreshJobs[manifestUrl] = refreshJob
}
private fun applyAddonsFromUrls(
urls: List<String>,
namesByUrl: Map<String, String> = emptyMap(),
) {
val normalizedUrls = dedupeManifestUrls(urls)
val normalizedUrlSet = normalizedUrls.toSet()
val now = addonEpochMs()
val existingByUrl = _uiState.value.addons.associateBy(ManagedAddon::manifestUrl)
val loadedCache = loadManifestCacheByUrl()
val nextCache = loadedCache.filterKeys { key -> key in normalizedUrlSet }.toMutableMap()
val addons = normalizedUrls.map { manifestUrl ->
val existing = existingByUrl[manifestUrl]
val cachedEntry = nextCache[manifestUrl]
val cachedManifest = cachedEntry
?.takeIf { it.payload.isNotBlank() }
?.let { entry ->
runCatching {
AddonManifestParser.parse(
manifestUrl = manifestUrl,
payload = entry.payload,
)
}.getOrNull()
}
if (cachedEntry != null && cachedManifest == null) {
nextCache.remove(manifestUrl)
}
val manifest = existing?.manifest ?: cachedManifest
val shouldRefresh = when {
manifest == null -> true
existing?.manifest != null && !existing.isRefreshing -> false
cachedEntry == null -> true
cachedEntry.fetchedAtEpochMs <= 0L -> true
now - cachedEntry.fetchedAtEpochMs >= MANIFEST_CACHE_TTL_MS -> true
else -> false
}
ManagedAddon(
manifestUrl = manifestUrl,
manifest = manifest,
userSetName = namesByUrl[manifestUrl] ?: existing?.userSetName,
isRefreshing = shouldRefresh,
errorMessage = if (manifest != null) null else existing?.errorMessage,
)
}
manifestCacheByUrl = nextCache.toMap()
persistManifestCache()
_uiState.value = AddonsUiState(addons = addons)
addons.filter { it.isRefreshing }.forEach { addon ->
refreshAddon(addon.manifestUrl)
}
}
private fun loadManifestCacheByUrl(): Map<String, AddonManifestCacheEntry> {
val payload = AddonStorage.loadManifestCachePayload(currentProfileId).orEmpty()
if (payload.isBlank()) {
manifestCacheByUrl = emptyMap()
return manifestCacheByUrl
}
val decoded = AddonManifestCacheCodec.decode(payload)
?.mapNotNull { entry ->
val normalizedUrl = ensureManifestSuffix(entry.manifestUrl)
if (entry.payload.isBlank()) {
null
} else {
entry.copy(manifestUrl = normalizedUrl)
}
}
?.associateBy(AddonManifestCacheEntry::manifestUrl)
.orEmpty()
manifestCacheByUrl = decoded
return decoded
}
private fun updateManifestCache(manifestUrl: String, payload: String) {
if (payload.isBlank()) return
val normalizedUrl = ensureManifestSuffix(manifestUrl)
manifestCacheByUrl = manifestCacheByUrl + (
normalizedUrl to AddonManifestCacheEntry(
manifestUrl = normalizedUrl,
payload = payload,
fetchedAtEpochMs = addonEpochMs(),
)
)
persistManifestCache()
}
private fun persistManifestCache() {
AddonStorage.saveManifestCachePayload(
profileId = currentProfileId,
payload = AddonManifestCacheCodec.encode(manifestCacheByUrl.values),
)
} }
private fun pushToServer() { private fun pushToServer() {
@ -462,6 +376,30 @@ object AddonRepository {
} }
} }
private fun ManagedAddon?.toPendingAddon(manifestUrl: String, userSetName: String? = null): ManagedAddon =
when {
this == null -> ManagedAddon(
manifestUrl = manifestUrl,
isRefreshing = true,
userSetName = userSetName,
)
manifest != null -> copy(
manifestUrl = manifestUrl,
isRefreshing = false,
userSetName = userSetName ?: this.userSetName,
)
isRefreshing -> copy(
manifestUrl = manifestUrl,
userSetName = userSetName ?: this.userSetName,
)
else -> copy(
manifestUrl = manifestUrl,
isRefreshing = true,
errorMessage = null,
userSetName = userSetName ?: this.userSetName,
)
}
private fun dedupeManifestUrls(urls: List<String>): List<String> = private fun dedupeManifestUrls(urls: List<String>): List<String> =
urls.map(::ensureManifestSuffix).distinct() urls.map(::ensureManifestSuffix).distinct()
@ -493,5 +431,3 @@ private fun normalizeManifestUrl(rawUrl: String): String {
return if (query.isEmpty()) manifestPath else "$manifestPath?$query" return if (query.isEmpty()) manifestPath else "$manifestPath?$query"
} }
private const val MANIFEST_CACHE_TTL_MS = 12L * 60L * 60L * 1000L

View file

@ -15,13 +15,10 @@ import io.ktor.http.ContentType
import io.ktor.http.HttpHeaders import io.ktor.http.HttpHeaders
import io.ktor.http.HttpMethod import io.ktor.http.HttpMethod
import io.ktor.http.isSuccess import io.ktor.http.isSuccess
import platform.Foundation.NSDate
import platform.Foundation.NSUserDefaults import platform.Foundation.NSUserDefaults
import platform.Foundation.timeIntervalSince1970
actual object AddonStorage { actual object AddonStorage {
private const val addonUrlsKey = "installed_manifest_urls" private const val addonUrlsKey = "installed_manifest_urls"
private const val manifestCacheKey = "manifest_cache_payload"
actual fun loadInstalledAddonUrls(profileId: Int): List<String> = actual fun loadInstalledAddonUrls(profileId: Int): List<String> =
NSUserDefaults.standardUserDefaults NSUserDefaults.standardUserDefaults
@ -38,18 +35,8 @@ actual object AddonStorage {
forKey = "${addonUrlsKey}_$profileId", forKey = "${addonUrlsKey}_$profileId",
) )
} }
actual fun loadManifestCachePayload(profileId: Int): String? =
NSUserDefaults.standardUserDefaults.stringForKey("${manifestCacheKey}_$profileId")
actual fun saveManifestCachePayload(profileId: Int, payload: String) {
NSUserDefaults.standardUserDefaults.setObject(payload, forKey = "${manifestCacheKey}_$profileId")
}
} }
internal actual fun addonEpochMs(): Long =
(NSDate().timeIntervalSince1970 * 1000.0).toLong()
private val addonHttpClient = HttpClient(Darwin) { private val addonHttpClient = HttpClient(Darwin) {
install(HttpTimeout) { install(HttpTimeout) {
requestTimeoutMillis = 60_000 requestTimeoutMillis = 60_000