mirror of
https://github.com/NuvioMedia/NuvioMobile.git
synced 2026-07-26 17:12:16 +00:00
Merge c690be6566 into b60995324b
This commit is contained in:
commit
8242ae314e
11 changed files with 1281 additions and 262 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
)
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue