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 preferencesName = "nuvio_addons"
|
||||||
private const val addonUrlsKey = "installed_manifest_urls"
|
private const val addonUrlsKey = "installed_manifest_urls"
|
||||||
private const val addonEnabledStatesKey = "installed_manifest_enabled_states"
|
private const val addonEnabledStatesKey = "installed_manifest_enabled_states"
|
||||||
|
private const val addonNamesKey = "installed_manifest_names"
|
||||||
|
|
||||||
private var preferences: SharedPreferences? = null
|
private var preferences: SharedPreferences? = null
|
||||||
|
|
||||||
|
|
@ -66,6 +67,24 @@ actual object AddonStorage {
|
||||||
?.putString("${addonEnabledStatesKey}_$profileId", payload)
|
?.putString("${addonEnabledStatesKey}_$profileId", payload)
|
||||||
?.apply()
|
?.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>? {
|
private fun parseEnabledStateLine(line: String): Pair<String, Boolean>? {
|
||||||
|
|
@ -78,6 +97,12 @@ private fun parseEnabledStateLine(line: String): Pair<String, Boolean>? {
|
||||||
return url to enabled
|
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()
|
private val addonHttpClient = OkHttpClient.Builder()
|
||||||
.dns(IPv4FirstDns())
|
.dns(IPv4FirstDns())
|
||||||
.connectTimeout(60, TimeUnit.SECONDS)
|
.connectTimeout(60, TimeUnit.SECONDS)
|
||||||
|
|
|
||||||
|
|
@ -56,9 +56,24 @@
|
||||||
<string name="addons_overview_addons">Addons</string>
|
<string name="addons_overview_addons">Addons</string>
|
||||||
<string name="addons_overview_catalogs">Catalogs</string>
|
<string name="addons_overview_catalogs">Catalogs</string>
|
||||||
<string name="addons_refresh">Refresh addon</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_add_addon">Add Addon</string>
|
||||||
<string name="addons_section_installed">Installed Addons</string>
|
<string name="addons_section_installed">Installed Addons</string>
|
||||||
<string name="addons_section_overview">Overview</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_summary_id_rules">%1$d id rules</string>
|
||||||
<string name="addons_version_format">Version %1$s</string>
|
<string name="addons_version_format">Version %1$s</string>
|
||||||
<string name="cd_selected">Selected</string>
|
<string name="cd_selected">Selected</string>
|
||||||
|
|
|
||||||
|
|
@ -67,8 +67,30 @@ data class ManagedAddon(
|
||||||
|
|
||||||
data class AddonsUiState(
|
data class AddonsUiState(
|
||||||
val addons: List<ManagedAddon> = emptyList(),
|
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(
|
data class AddonOverview(
|
||||||
val totalAddons: Int,
|
val totalAddons: Int,
|
||||||
val activeAddons: Int,
|
val activeAddons: Int,
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,8 @@ internal expect object AddonStorage {
|
||||||
fun saveInstalledAddonUrls(profileId: Int, urls: List<String>)
|
fun saveInstalledAddonUrls(profileId: Int, urls: List<String>)
|
||||||
fun loadAddonEnabledStates(profileId: Int): Map<String, Boolean>
|
fun loadAddonEnabledStates(profileId: Int): Map<String, Boolean>
|
||||||
fun saveAddonEnabledStates(profileId: Int, states: 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(
|
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))
|
SectionHeader(stringResource(Res.string.addons_section_overview))
|
||||||
OverviewCard(overview = 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))
|
SectionHeader(stringResource(Res.string.addons_section_add_addon))
|
||||||
AddAddonCard(
|
AddAddonCard(
|
||||||
addonUrl = addonUrl,
|
addonUrl = addonUrl,
|
||||||
|
|
@ -220,6 +230,54 @@ private fun SectionHeader(text: String) {
|
||||||
NuvioSectionLabel(text = text)
|
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
|
@Composable
|
||||||
private fun OverviewCard(overview: AddonOverview) {
|
private fun OverviewCard(overview: AddonOverview) {
|
||||||
NuvioSurfaceCard {
|
NuvioSurfaceCard {
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,17 @@ object HomeRepository {
|
||||||
private var collectionHeroRequestKey: String? = null
|
private var collectionHeroRequestKey: String? = null
|
||||||
private var lastPublishedCatalogHeroEmpty: Boolean = true
|
private var lastPublishedCatalogHeroEmpty: Boolean = true
|
||||||
private var lastErrorMessage: String? = null
|
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) {
|
fun refresh(addons: List<ManagedAddon>, force: Boolean = false) {
|
||||||
val activeAddons = addons.enabledAddons()
|
val activeAddons = addons.enabledAddons()
|
||||||
|
|
|
||||||
|
|
@ -535,10 +535,18 @@ fun HomeScreen(
|
||||||
buildHomeCatalogRefreshSignature(enabledAddons)
|
buildHomeCatalogRefreshSignature(enabledAddons)
|
||||||
}
|
}
|
||||||
|
|
||||||
LaunchedEffect(catalogRefreshKey) {
|
LaunchedEffect(catalogRefreshKey, addonsUiState.refreshRevision) {
|
||||||
if (catalogRefreshKey.isEmpty()) return@LaunchedEffect
|
if (catalogRefreshKey.isEmpty() && addonsUiState.refreshRevision == 0L) {
|
||||||
|
return@LaunchedEffect
|
||||||
|
}
|
||||||
HomeCatalogSettingsRepository.syncCatalogs(enabledAddons)
|
HomeCatalogSettingsRepository.syncCatalogs(enabledAddons)
|
||||||
HomeRepository.refresh(enabledAddons)
|
val forced = HomeRepository.refreshAfterAddonRefresh(
|
||||||
|
addons = enabledAddons,
|
||||||
|
revision = addonsUiState.refreshRevision,
|
||||||
|
)
|
||||||
|
if (!forced) {
|
||||||
|
HomeRepository.refresh(enabledAddons)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
LaunchedEffect(collections, 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 {
|
actual object AddonStorage {
|
||||||
private const val addonUrlsKey = "installed_manifest_urls"
|
private const val addonUrlsKey = "installed_manifest_urls"
|
||||||
private const val addonEnabledStatesKey = "installed_manifest_enabled_states"
|
private const val addonEnabledStatesKey = "installed_manifest_enabled_states"
|
||||||
|
private const val addonNamesKey = "installed_manifest_names"
|
||||||
|
|
||||||
actual fun loadInstalledAddonUrls(profileId: Int): List<String> =
|
actual fun loadInstalledAddonUrls(profileId: Int): List<String> =
|
||||||
NSUserDefaults.standardUserDefaults
|
NSUserDefaults.standardUserDefaults
|
||||||
|
|
@ -59,6 +60,24 @@ actual object AddonStorage {
|
||||||
forKey = "${addonEnabledStatesKey}_$profileId",
|
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>? {
|
private fun parseEnabledStateLine(line: String): Pair<String, Boolean>? {
|
||||||
|
|
@ -71,6 +90,12 @@ private fun parseEnabledStateLine(line: String): Pair<String, Boolean>? {
|
||||||
return url to enabled
|
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) {
|
private val addonHttpClient = HttpClient(Darwin) {
|
||||||
install(HttpTimeout) {
|
install(HttpTimeout) {
|
||||||
requestTimeoutMillis = 60_000
|
requestTimeoutMillis = 60_000
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue