fix: re-resolve AMSAT virtual types after sync; clean stale FM lists

Two independent defects kept ISS (DESTINY) visible in the satellite-page
AMSAT Live FM filter even after installing the fix and syncing data:

1. UI-layer staleness (SelectionRepo): resolveTypeIds ran only when the
   filter type changed and snapshotted the catnum set. A background data
   sync rewriting the FM/Linear lists never re-triggered resolution, so
   the UI kept filtering with the pre-sync (dirty) set until restart or a
   type toggle. ISettingsRepo now exposes a monotonic amSatListsVersion
   StateFlow, bumped by SettingsRepo.setAmSatCatnums; SelectionRepo
   combines it with currentTypes so every rewrite re-resolves instantly.

2. Data-layer staleness (DatabaseRepo): when the AMSAT FM page fetch
   failed, the previous list was kept verbatim — including pre-whitelist
   era lists containing all five ISS module entries. The stale list is now
   re-disambiguated: previous catnums are looked up by local name and run
   through the same whitelist / smallest-catnum collapse, so ISS modules
   always collapse to the primary ZARYA (25544).
This commit is contained in:
atsunatsu committed 2026-09-24 09:40:42 +08:00
1 parent fa51fb2b44
commit 7401e1451b
8 files changed
+80 -22

No files matched your search

@@ -166,12 +166,19 @@ class DatabaseRepo(
// entries) instead of admitting every alias (DESTINY etc.).
return setOf(all.minOrNull() ?: all.first())
}
// A failed FM/Linear page fetch yields an empty list here; keep the
// previous lists so a single network hiccup cannot wipe the filters.
// A failed FM/Linear page fetch yields an empty list here; do NOT
// keep the stale list verbatim (it may predate the whitelist and
// still contain ISS module aliases like DESTINY). Instead re-run
// the multi-match disambiguation on the previous catnums via their
// local names, so the whitelist / smallest-catnum rules clean it.
fun cleanStaleList(previous: Set<Int>): Set<Int> =
previous.mapNotNull { catnum ->
nameToCatnum.entries.firstOrNull { it.value == catnum }?.key
}.flatMap { resolvePerName(it) }.toSet()
val fmCatnums = fmNames.flatMap { resolvePerName(it) }.toSet()
.ifEmpty { settingsRepo.getAmSatFmCatnums() }
.ifEmpty { cleanStaleList(settingsRepo.getAmSatFmCatnums()) }
val linearCatnums = linearNames.flatMap { resolvePerName(it) }.toSet()
.ifEmpty { settingsRepo.getAmSatLinearCatnums() }
.ifEmpty { cleanStaleList(settingsRepo.getAmSatLinearCatnums()) }
settingsRepo.setAmSatCatnums(fmCatnums, linearCatnums)
println("AMSAT live lists updated: FM=${fmCatnums.size}, Linear=${linearCatnums.size}, Active=${activeCatnums.size}")
}.onFailure {
@@ -25,6 +25,7 @@ import com.rtbishop.look4sat.core.domain.source.Sources
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.map
@@ -45,17 +46,21 @@ class SelectionRepo(
// The HashSet gives O(1) catnum lookups instead of O(n) with a List.
// The three virtual types ("AMSAT Live FM", "AMSAT Live Linear", "Live SSTV")
// are resolved from live transponder data instead of SharedPreferences.
private val itemsWithTypes = currentTypes.flatMapLatest { types: List<String> ->
val catnumSet: Set<Int>? = if (types.isEmpty()) {
null // null = no filtering
} else {
val ids = resolveTypeIds(types)
if (ids.isEmpty()) emptySet() else ids.toHashSet()
// The AMSAT list version counter re-triggers resolution when a background
// data sync rewrites the FM/Linear lists, so the UI reflects the new list
// without a restart or type toggle.
private val itemsWithTypes = combine(currentTypes, settingsRepo.amSatListsVersion) { types, _ -> types }
.flatMapLatest { types: List<String> ->
val catnumSet: Set<Int>? = if (types.isEmpty()) {
null // null = no filtering
} else {
val ids = resolveTypeIds(types)
if (ids.isEmpty()) emptySet() else ids.toHashSet()
}
currentItems.map { items ->
if (catnumSet == null) items else items.filter { it.catnum in catnumSet }
}
}
currentItems.map { items ->
if (catnumSet == null) items else items.filter { it.catnum in catnumSet }
}
}
private val itemsWithQuery = currentQuery.flatMapLatest { query ->
itemsWithTypes.map { items -> filterByQuery(items, query) }
@@ -749,6 +749,9 @@ class SettingsRepo(
//endregion
//region # AMSAT live-transponder lists
private val _amSatListsVersion = MutableStateFlow(0)
override val amSatListsVersion: StateFlow<Int> = _amSatListsVersion
override fun getAmSatFmCatnums(): Set<Int> {
val raw = preferences.getString(keyAmSatFmCatnums, null) ?: return emptySet()
return raw.split(separatorComma).mapNotNull { it.toIntOrNull() }.toSet()
@@ -764,6 +767,7 @@ class SettingsRepo(
putString(keyAmSatFmCatnums, fmCatnums.sorted().joinToString(separatorComma))
putString(keyAmSatLinearCatnums, linearCatnums.sorted().joinToString(separatorComma))
}
_amSatListsVersion.update { it + 1 }
}
override fun getAmSatActiveCatnums(): Set<Int> {
@@ -195,24 +195,30 @@ class DatabaseRepoTest {
}
@Test
fun `amsat fm list keeps previous list when fm page fetch fails`() = runTest(dispatcher) {
fun `amsat fm list cleans stale ISS module aliases when fm page fetch fails`() = runTest(dispatcher) {
val satnogsUrl = Sources.satelliteDataUrls.getValue("SatNOGS")
val localSource = FakeLocalSource()
val remoteSource = FakeRemoteSource().apply {
networkStreams[satnogsUrl] = { jamxTleStream() }
// FM page NOT registered -> 404 -> empty parse -> previous list retained.
networkStreams[satnogsUrl] = { issModulesCsvStream() }
// FM page NOT registered -> 404 -> empty parse.
}
val settingsRepo = FakeSettingsRepo(
dataSources = DataSourcesSettings(
satelliteUrls = listOf(satnogsUrl),
transceiversUrls = emptyList()
)
).apply { amSatFm = setOf(25544, 7530) } // list from an earlier good sync
).apply {
// Pre-whitelist era stale list: ALL five ISS module entries.
amSatFm = setOf(25544, 25575, 26400, 26700, 49044)
}
val repository = DatabaseRepo(dispatcher, dataParser, localSource, remoteSource, settingsRepo)
repository.updateFromRemote()
assertEquals(setOf(25544, 7530), settingsRepo.amSatFm)
// The stale list is not kept verbatim: with the FM page down, the
// previous catnums are re-disambiguated via local names, collapsing
// the five ISS modules to the primary ZARYA (25544).
assertEquals(setOf(25544), settingsRepo.amSatFm)
}
private fun issModulesCsvStream(): InputStream = """
@@ -365,6 +371,7 @@ private class FakeSettingsRepo(dataSources: DataSourcesSettings = defaultDataSou
var amSatFm: Set<Int> = emptySet()
var amSatLinear: Set<Int> = emptySet()
var amSatActive: Set<Int> = emptySet()
override val amSatListsVersion: StateFlow<Int> = MutableStateFlow(0)
override fun getAmSatFmCatnums(): Set<Int> = amSatFm
override fun getAmSatLinearCatnums(): Set<Int> = amSatLinear
@@ -217,6 +217,29 @@ class SelectionRepoSearchTest {
assertTrue("All" in types)
}
@Test
fun `amsat list version bump re-resolves FM filter after data sync`() = runTest {
// ISS module aliases: dirty pre-sync FM list contains both ZARYA and DESTINY.
val items = listOf(
SatItem(25544, "ISS (ZARYA)"),
SatItem(26700, "ISS (DESTINY)")
)
val fake = FakeSettingsRepoForSearch(amSatFm = setOf(25544, 26700))
val repo = SelectionRepo(
dispatcher = Dispatchers.Unconfined,
localSource = FakeLocalSourceForSearch(items),
settingsRepo = fake
)
repo.setTypes(listOf("AMSAT Live FM"))
assertEquals(setOf(25544, 26700), repo.getEntriesFlow().first().map { it.catnum }.toSet())
// Simulate a background data sync: lists rewritten + version bumped.
fake.setAmSatCatnums(fmCatnums = setOf(25544), linearCatnums = emptySet())
// The FM filter must reflect the new list WITHOUT a type toggle or restart.
assertEquals(listOf(25544), repo.getEntriesFlow().first().map { it.catnum })
}
private fun createRepo(
items: List<SatItem>,
amSatFm: Set<Int> = emptySet(),
@@ -252,8 +275,8 @@ private class FakeLocalSourceForSearch(
}
private class FakeSettingsRepoForSearch(
private val amSatFm: Set<Int> = emptySet(),
private val amSatLinear: Set<Int> = emptySet(),
var amSatFm: Set<Int> = emptySet(),
var amSatLinear: Set<Int> = emptySet(),
private var amSatActive: Set<Int> = emptySet()
) : ISettingsRepo {
override val appVersionName: String = "test"
@@ -299,7 +322,12 @@ private class FakeSettingsRepoForSearch(
override fun updateDataSourcesStatus(status: Map<String, Int>) = Unit
override fun getAmSatFmCatnums(): Set<Int> = amSatFm
override fun getAmSatLinearCatnums(): Set<Int> = amSatLinear
override fun setAmSatCatnums(fmCatnums: Set<Int>, linearCatnums: Set<Int>) = Unit
override val amSatListsVersion: StateFlow<Int> = MutableStateFlow(0)
override fun setAmSatCatnums(fmCatnums: Set<Int>, linearCatnums: Set<Int>) {
amSatFm = fmCatnums
amSatLinear = linearCatnums
(amSatListsVersion as MutableStateFlow<Int>).value++
}
override fun getAmSatActiveCatnums(): Set<Int> = amSatActive
override fun setAmSatActiveCatnums(catnums: Set<Int>) { amSatActive = catnums }
override fun updateRadioControlSettings(settings: RadioControlSettings) = Unit
@@ -89,6 +89,11 @@ interface ISettingsRepo {
* active-transponder table (whitelist for the virtual type filters). */
fun getAmSatActiveCatnums(): Set<Int>
fun setAmSatActiveCatnums(catnums: Set<Int>)
/** Monotonic version counter, incremented every time the AMSAT live
* FM/Linear lists are rewritten. Consumers that resolve the virtual
* types (SelectionRepo) should re-resolve on change so a background
* data sync is reflected in the UI without a restart. */
val amSatListsVersion: StateFlow<Int>
//endregion
//region # Radio control settings
@@ -285,6 +285,7 @@ class LoTWGridSyncTest {
override fun updateDataSourcesStatus(status: Map<String, Int>) = TODO()
override fun getAmSatFmCatnums(): Set<Int> = emptySet()
override fun getAmSatLinearCatnums(): Set<Int> = emptySet()
override val amSatListsVersion: StateFlow<Int> = MutableStateFlow(0)
override fun setAmSatCatnums(fmCatnums: Set<Int>, linearCatnums: Set<Int>) = Unit
override fun getAmSatActiveCatnums(): Set<Int> = emptySet()
override fun setAmSatActiveCatnums(catnums: Set<Int>) = Unit
@@ -112,6 +112,7 @@ class FakeSettingsRepo(
override fun getAmSatFmCatnums(): Set<Int> = amSatFm
override fun getAmSatLinearCatnums(): Set<Int> = amSatLinear
override val amSatListsVersion: StateFlow<Int> = MutableStateFlow(0)
override fun setAmSatCatnums(fmCatnums: Set<Int>, linearCatnums: Set<Int>) = TODO()
override fun getAmSatActiveCatnums(): Set<Int> = emptySet()
override fun setAmSatActiveCatnums(catnums: Set<Int>) = TODO()