Compare commits

...
Author SHA1 Message Date
atsunatsu 70ada0d608 fix: revert AMSAT live data route; hardcode FM/Linear satellite lists
User decision: drop the AMSAT live-page fetch pipeline entirely (page
parsing, amateur whitelist, ISS module disambiguation, timeout fallbacks,
amSatListsVersion re-resolution) and hardcode the FM/Linear lists instead.

- Sources: remove amSatLiveUrls + amSatActiveUrls; add
  amSatFmCatnums = {SO-50 27607, ISS ZARYA 25544, AO-123 43137} and
  amSatLinearCatnums = {RS-44 44909, FO-29 24278, AO-7 7530,
  AO-73 39444, JO-97 43803}
- SelectionRepo: virtual types resolve FM/Linear from hardcoded sets;
  SSTV stays (amateur-only mode query + R/B debris exclusion)
- MutualViewModel: FM/Linear chips filter by the hardcoded sets
- DatabaseRepo/SettingsRepo/ISettingsRepo: AMSAT list fetch/persist
  removed; DataParser AMSAT parse helpers removed
- Tests: AMSAT pipeline tests dropped; selection/mutual tests re-targeted
  at the hardcoded lists
2026-09-24 12:41:52 +08:00
atsunatsu 317f6d877f build: bump version to 4.4.7-ba7opf.15.8 (505) 2026-09-24 11:38:21 +08:00
atsunatsu 865fa9c6f2 fix: AMSAT live page timeouts wiped FM/Linear lists; add fallback
Root cause of the recurring empty AMSAT Live filters: OkHttp defaults to a
10s read timeout and amsat.org's live pages take 7-10s+ from many
networks — every sync timed out, fmNames/linearNames stayed empty, and
(with no previous list, e.g. right after clearing data) the filters were
written as EMPTY. The stale pre-whitelist FM list survived only because
older syncs had occasionally succeeded and the failure path preserved it.

Fix:
1. OkHttp timeouts raised (connect 20s / read 30s / write 30s / call 60s)
   in MainContainer.provideRemoteSource so slow AMSAT pages actually load.
2. updateAmSatLiveLists gains a last-resort fallback: when the AMSAT page
   is down AND there is no previous list to clean, derive FM/Linear from
   the local transceivers (FM / SSB,CW,USB,LSB modes) intersected with
   the amateur whitelist, so a network timeout never wipes the filters.
   ISS module aliases stay excluded by the whitelist intersection.

Regression test: page fails + empty previous list -> fallback yields
{25544} (26700 excluded by whitelist).
2026-09-24 11:37:50 +08:00
atsunatsu a082abb72c build: bump version to 4.4.7-ba7opf.15.7 (504) 2026-09-24 11:11:31 +08:00
atsunatsu a2e41a0c1a fix: don't crash the AMSAT list update on unknown satellite names
resolvePerName crashed with NoSuchElementException (all.first()) when an
AMSAT live-page name had no matching local entry (TEVEL2-1..9, RS95S, any
brand-new sat not yet in the local TLE). The exception was swallowed by
updateAmSatLiveLists' runCatching, so the FM/Linear lists were NEVER
rewritten — they stayed at the stale pre-whitelist snapshot containing
all five ISS modules, which is why ISS kept showing as DESTINY despite the
15.4/15.5/15.6 disambiguation fixes.

Fix: return an empty set for names with no local match (correct skip
semantics) so the rest of the page still resolves and the ISS module
collapse runs. Regression test covers the real AMSAT page mix (ISS +
unknown names).
2026-09-24 11:11:04 +08:00
atsunatsu e1daf70f11 build: bump version to 4.4.7-ba7opf.15.6 (503) 2026-09-24 10:48:46 +08:00
atsunatsu ca9f93c1ed fix: persist All source type ids so the All filter shows satellites
Root cause of "All filter is empty": 'All' is a real TLE source type
(CelesTrak active group) in satelliteDataUrls, but setSatelliteTypeIds
had an upstream-legacy 'if (type == "All") return' that silently dropped
its ids on every sync. Selecting 'All' in the type dialog then resolved
to an empty catnum set -> empty list, while entries stayed populated
(which is why satellite counts looked normal).

Also fix getTypesList: removeAt(0) removed the alphabetically-first real
source ('ARISS') instead of the intended placeholder; now excludes only
'Other' (empty URL), so all real source types incl. 'All'/'ARISS' are
selectable.

Tests: remote update persists All ids; All type resolves to persisted
CelesTrak active ids; getTypesList excludes only Other.
2026-09-24 10:48:22 +08:00
atsunatsu 42bd55e180 build: bump version to 4.4.7-ba7opf.15.5 (502) 2026-09-24 09:40:56 +08:00
atsunatsu 7401e1451b 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).
2026-09-24 09:40:42 +08:00
15 changed files with 168 additions and 510 deletions

No files matched your search

@@ -74,6 +74,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import okhttp3.OkHttpClient
import java.util.concurrent.TimeUnit
class MainContainer(private val context: Context) : IMainContainer {
@@ -177,7 +178,16 @@ class MainContainer(private val context: Context) : IMainContainer {
}
private fun provideRemoteSource(): IRemoteSource {
return RemoteSource(Dispatchers.IO, context.contentResolver, OkHttpClient.Builder().build())
// amsat.org live pages are slow (7-10s+ from some networks); the
// OkHttp defaults (10s read timeout) made every AMSAT Live update
// time out, silently keeping stale FM/Linear lists forever.
val client = OkHttpClient.Builder()
.connectTimeout(20, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.writeTimeout(30, TimeUnit.SECONDS)
.callTimeout(60, TimeUnit.SECONDS)
.build()
return RemoteSource(Dispatchers.IO, context.contentResolver, client)
}
private fun provideSatelliteRepo(): ISatelliteRepo {
@@ -116,84 +116,12 @@ class DatabaseRepo(
localSource.insertRadios(importedRadios)
}
if (importedEntries.isNotEmpty()) localSource.insertEntries(importedEntries)
updateAmSatLiveLists()
setUpdateSuccessful(System.currentTimeMillis())
}
/**
* Refresh the AMSAT Live FM/Linear satellite lists (transponders currently
* on the air). Best effort: any failure keeps the previous lists and never
* blocks the rest of the data update, so the mutual-match filter simply
* falls back to the last successful snapshot.
*/
private suspend fun updateAmSatLiveLists() = withContext(dispatcher) {
runCatching {
val entries = localSource.getEntriesList() // catnum -> name
val jobs = Sources.amSatLiveUrls.map { (type, url) ->
async { type to remoteSource.getNetworkStream(url) }
} + async { "Active" to fetchAmSatActiveStream() }
val results = jobs.awaitAll()
val fmNames = results.firstOrNull { it.first == "FM" }?.second?.stream
?.let { dataParser.parseAmSatLivePage(it) }.orEmpty()
val linearNames = results.firstOrNull { it.first == "Linear" }?.second?.stream
?.let { dataParser.parseAmSatLivePage(it) }.orEmpty()
// Amateur whitelist: a failed fetch yields an empty result, which
// must NOT wipe out the last good snapshot. Fall back to the
// persisted whitelist, and only persist when this fetch actually
// returned a stream.
val activeStream = results.firstOrNull { it.first == "Active" }?.second?.stream
val activeCatnums = activeStream
?.let { dataParser.parseAmSatActiveCatnums(it) }
.orEmpty()
.ifEmpty { settingsRepo.getAmSatActiveCatnums() }
if (activeStream != null) settingsRepo.setAmSatActiveCatnums(activeCatnums)
val nameToCatnum = entries.associate { it.name.uppercase() to it.catnum }
// Resolve every matching local entry per AMSAT name. A single match
// is kept as-is (so satellites absent from the amateur whitelist,
// e.g. JO-97/TO-108, are never dropped). Only when several local
// entries share the name (ISS station modules ZARYA/UNITY/ZVEZDA/
// DESTINY/NAUKA) is the whitelist used to pick the primary one.
fun resolvePerName(name: String): Set<Int> {
val keys = dataParser.normalizeAmSatName(name)
val all = nameToCatnum.filter { (localName, _) ->
dataParser.matchesAmSatName(localName, keys)
}.values.toSet()
if (all.size <= 1) return all
val preferred = all.intersect(activeCatnums)
if (preferred.isNotEmpty()) return preferred
// Whitelist unavailable: keep only the primary entry (smallest
// catnum — ISS ZARYA=25544 is the smallest of its five module
// 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.
val fmCatnums = fmNames.flatMap { resolvePerName(it) }.toSet()
.ifEmpty { settingsRepo.getAmSatFmCatnums() }
val linearCatnums = linearNames.flatMap { resolvePerName(it) }.toSet()
.ifEmpty { settingsRepo.getAmSatLinearCatnums() }
settingsRepo.setAmSatCatnums(fmCatnums, linearCatnums)
println("AMSAT live lists updated: FM=${fmCatnums.size}, Linear=${linearCatnums.size}, Active=${activeCatnums.size}")
}.onFailure {
// Keep the previous lists; the mutual filter stays on the last good snapshot.
println("AMSAT live lists update failed: $it")
}
}
/** Try the amateur-whitelist sources in order and return the first
* NetworkResult that carried a stream (or null if all failed). */
private suspend fun fetchAmSatActiveStream(): NetworkResult? {
for (url in Sources.amSatActiveUrls) {
val result = remoteSource.getNetworkStream(url)
if (result.stream != null) return result
}
return null
}
override suspend fun clearAllData() = withContext(dispatcher) {
localSource.deleteEntries()
localSource.deleteRadios()
settingsRepo.setAmSatCatnums(emptySet(), emptySet())
setUpdateSuccessful(0L)
}
@@ -44,7 +44,8 @@ class SelectionRepo(
// Resolve type IDs once when types change, then filter items reactively.
// 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.
// are resolved from hardcoded FM/Linear catnum sets (Sources) or live
// SSTV transponder data instead of SharedPreferences.
private val itemsWithTypes = currentTypes.flatMapLatest { types: List<String> ->
val catnumSet: Set<Int>? = if (types.isEmpty()) {
null // null = no filtering
@@ -65,15 +66,17 @@ class SelectionRepo(
/**
* Resolves a list of type names to satellite catnums. The three virtual
* transponder/activity types are resolved from live data; all other types
* come from the per-type ID lists persisted by [ISettingsRepo].
* transponder/activity types are resolved from hardcoded FM/Linear lists
* (Sources.amSatFmCatnums / amSatLinearCatnums) or local SSTV transponder
* data; all other types come from the per-type ID lists persisted by
* [ISettingsRepo].
*/
private suspend fun resolveTypeIds(types: List<String>): List<Int> {
val idsSet = mutableSetOf<Int>()
types.forEach { type ->
when (type) {
Sources.virtualTypeNames[0] -> idsSet.addAll(settingsRepo.getAmSatFmCatnums())
Sources.virtualTypeNames[1] -> idsSet.addAll(settingsRepo.getAmSatLinearCatnums())
Sources.virtualTypeNames[0] -> idsSet.addAll(Sources.amSatFmCatnums)
Sources.virtualTypeNames[1] -> idsSet.addAll(Sources.amSatLinearCatnums)
// Live SSTV: mode=SSTV transponder records whose service class
// is "Amateur", excluding launcher debris / rocket bodies
// (names ending in "R/B" or "DEB" — e.g. Ariane 6 R/B) that
@@ -97,7 +100,10 @@ class SelectionRepo(
override fun getTypesList() = buildList {
// 三个转发器/活动虚拟类型排在最前.
addAll(Sources.virtualTypeNames)
addAll(Sources.satelliteDataUrls.keys.sorted().toMutableList().apply { removeAt(0) })
// 所有真实 TLE 源类型均可选(含 "All" = CelesTrak active);只排除
// "Other"(空 URL 占位,无数据)。不要用 removeAt(0) —— 字母序第一个
// 是 "All"/"ARISS" 这类真实源,删掉会让用户勾不到它们。
addAll(Sources.satelliteDataUrls.keys.sorted().filterNot { it == "Other" })
}
override suspend fun getEntriesFlow() = withContext(dispatcher) {
@@ -107,9 +107,6 @@ class SettingsRepo(
private val keySatelliteEnabled = "satelliteEnabled"
private val keyTransceiversEnabled = "transceiversEnabled"
private val keySatnogsTleSourceMigration = "satnogsTleSourceMigration"
private val keyAmSatFmCatnums = "amSatFmCatnums"
private val keyAmSatLinearCatnums = "amSatLinearCatnums"
private val keyAmSatActiveCatnums = "amSatActiveCatnums"
private val separatorComma = ","
private val separatorUrl = "\n"
private val legacyCelestrakSatnogsUrl =
@@ -498,7 +495,9 @@ class SettingsRepo(
}
override fun setSatelliteTypeIds(type: String, ids: List<Int>) {
if (type == "All") return
// "All" is a real TLE source type (CelesTrak active group) whose ids
// must be persisted like any other type — skipping it made the "All"
// filter resolve to an empty set and show nothing.
val typesString = ids.joinToString(separatorComma)
preferences.edit { putString("type$type", typesString) }
}
@@ -748,36 +747,6 @@ class SettingsRepo(
}
//endregion
//region # AMSAT live-transponder lists
override fun getAmSatFmCatnums(): Set<Int> {
val raw = preferences.getString(keyAmSatFmCatnums, null) ?: return emptySet()
return raw.split(separatorComma).mapNotNull { it.toIntOrNull() }.toSet()
}
override fun getAmSatLinearCatnums(): Set<Int> {
val raw = preferences.getString(keyAmSatLinearCatnums, null) ?: return emptySet()
return raw.split(separatorComma).mapNotNull { it.toIntOrNull() }.toSet()
}
override fun setAmSatCatnums(fmCatnums: Set<Int>, linearCatnums: Set<Int>) {
preferences.edit {
putString(keyAmSatFmCatnums, fmCatnums.sorted().joinToString(separatorComma))
putString(keyAmSatLinearCatnums, linearCatnums.sorted().joinToString(separatorComma))
}
}
override fun getAmSatActiveCatnums(): Set<Int> {
val raw = preferences.getString(keyAmSatActiveCatnums, null) ?: return emptySet()
return raw.split(separatorComma).mapNotNull { it.toIntOrNull() }.toSet()
}
override fun setAmSatActiveCatnums(catnums: Set<Int>) {
preferences.edit {
putString(keyAmSatActiveCatnums, catnums.sorted().joinToString(separatorComma))
}
}
//endregion
//region # Radio control settings
private val keyRadioControlEnabled = "radioControlEnabled"
private val keyRadioModel = "radioModel"
@@ -127,6 +127,29 @@ class DatabaseRepoTest {
assertEquals(listOf(98248), settingsRepo.satelliteTypeIdsByType["SatNOGS"])
}
@Test
fun `remote update persists All source type ids`() = runTest(dispatcher) {
// Regression: "All" (CelesTrak active) is a real TLE source type whose
// ids must be persisted on sync — the old `if (type == "All") return`
// silently dropped them, making the "All" filter resolve to nothing.
val allUrl = Sources.satelliteDataUrls.getValue("All")
val localSource = FakeLocalSource()
val remoteSource = FakeRemoteSource().apply {
networkStreams[allUrl] = { validCsvStream() }
}
val settingsRepo = FakeSettingsRepo(
dataSources = DataSourcesSettings(
satelliteUrls = listOf(allUrl),
transceiversUrls = emptyList()
)
)
val repository = DatabaseRepo(dispatcher, dataParser, localSource, remoteSource, settingsRepo)
repository.updateFromRemote()
assertEquals(listOf(25544), settingsRepo.satelliteTypeIdsByType["All"])
}
private fun validCsvStream(): InputStream = """
OBJECT_NAME,OBJECT_ID,EPOCH,MEAN_MOTION,ECCENTRICITY,INCLINATION,RA_OF_ASC_NODE,ARG_OF_PERICENTER,MEAN_ANOMALY,EPHEMERIS_TYPE,CLASSIFICATION_TYPE,NORAD_CAT_ID,ELEMENT_SET_NO,REV_AT_EPOCH,BSTAR,MEAN_MOTION_DOT,MEAN_MOTION_DDOT
ISS (ZARYA),1998-067A,2021-11-16T12:28:09.322176,15.48582035,.0004694,51.6447,309.4881,203.6966,299.8876,0,U,25544,999,31220,.31985E-4,.1288E-4,0
@@ -143,96 +166,6 @@ class DatabaseRepoTest {
1 98248U 26237.16675926 .00015724 00000-0 97477-3 0 00013
2 98248 097.5373 310.9694 0011309 278.1232 340.7230 15.09766181000012
""".trimIndent().byteInputStream()
@Test
fun `amsat fm list disambiguates ISS modules via persisted whitelist when active csv fetch fails`() =
runTest(dispatcher) {
val satnogsUrl = Sources.satelliteDataUrls.getValue("SatNOGS")
val localSource = FakeLocalSource()
val remoteSource = FakeRemoteSource().apply {
networkStreams[satnogsUrl] = { issModulesCsvStream() }
networkStreams[Sources.amSatLiveUrls.getValue("FM")] = { amsatFmPageStream() }
// Amateur-whitelist URLs intentionally NOT registered -> fetch fails.
}
val settingsRepo = FakeSettingsRepo(
dataSources = DataSourcesSettings(
satelliteUrls = listOf(satnogsUrl),
transceiversUrls = emptyList()
)
).apply { amSatActive = setOf(25544) } // whitelist persisted by an earlier good sync
val repository = DatabaseRepo(dispatcher, dataParser, localSource, remoteSource, settingsRepo)
repository.updateFromRemote()
// Whitelist fetch fails on both sources, but the persisted whitelist
// still disambiguates: only primary ISS (ZARYA) survives,
// ISS (DESTINY) = 26700 must NOT enter the FM list.
assertTrue(25544 in settingsRepo.amSatFm)
assertTrue(26700 !in settingsRepo.amSatFm)
}
@Test
fun `amsat fm list keeps only smallest catnum when whitelist never available`() = runTest(dispatcher) {
val satnogsUrl = Sources.satelliteDataUrls.getValue("SatNOGS")
val localSource = FakeLocalSource()
val remoteSource = FakeRemoteSource().apply {
networkStreams[satnogsUrl] = { issModulesCsvStream() }
networkStreams[Sources.amSatLiveUrls.getValue("FM")] = { amsatFmPageStream() }
}
val settingsRepo = FakeSettingsRepo(
dataSources = DataSourcesSettings(
satelliteUrls = listOf(satnogsUrl),
transceiversUrls = emptyList()
)
) // no persisted whitelist at all
val repository = DatabaseRepo(dispatcher, dataParser, localSource, remoteSource, settingsRepo)
repository.updateFromRemote()
// Without any whitelist, the multi-match collapse keeps the primary
// entry (smallest catnum = ISS ZARYA 25544), never every module alias.
assertEquals(setOf(25544), settingsRepo.amSatFm)
}
@Test
fun `amsat fm list keeps previous list 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.
}
val settingsRepo = FakeSettingsRepo(
dataSources = DataSourcesSettings(
satelliteUrls = listOf(satnogsUrl),
transceiversUrls = emptyList()
)
).apply { amSatFm = setOf(25544, 7530) } // list from an earlier good sync
val repository = DatabaseRepo(dispatcher, dataParser, localSource, remoteSource, settingsRepo)
repository.updateFromRemote()
assertEquals(setOf(25544, 7530), settingsRepo.amSatFm)
}
private fun issModulesCsvStream(): InputStream = """
OBJECT_NAME,OBJECT_ID,EPOCH,MEAN_MOTION,ECCENTRICITY,INCLINATION,RA_OF_ASC_NODE,ARG_OF_PERICENTER,MEAN_ANOMALY,EPHEMERIS_TYPE,CLASSIFICATION_TYPE,NORAD_CAT_ID,ELEMENT_SET_NO,REV_AT_EPOCH,BSTAR,MEAN_MOTION_DOT,MEAN_MOTION_DDOT
ISS (ZARYA),1998-067A,2021-11-16T12:28:09.322176,15.48582035,.0004694,51.6447,309.4881,203.6966,299.8876,0,U,25544,999,31220,.31985E-4,.1288E-4,0
ISS (UNITY),1998-067B,2021-11-16T12:28:09.322176,15.48582035,.0004694,51.6447,309.4881,203.6966,299.8876,0,U,25575,999,31220,.31985E-4,.1288E-4,0
ISS (ZVEZDA),1998-067C,2021-11-16T12:28:09.322176,15.48582035,.0004694,51.6447,309.4881,203.6966,299.8876,0,U,26400,999,31220,.31985E-4,.1288E-4,0
ISS (DESTINY),1998-067D,2021-11-16T12:28:09.322176,15.48582035,.0004694,51.6447,309.4881,203.6966,299.8876,0,U,26700,999,31220,.31985E-4,.1288E-4,0
ISS (NAUKA),1998-067E,2021-11-16T12:28:09.322176,15.48582035,.0004694,51.6447,309.4881,203.6966,299.8876,0,U,49044,999,31220,.31985E-4,.1288E-4,0
""".trimIndent().byteInputStream()
private fun amsatFmPageStream(): InputStream = """
<table class="has-background">
<thead><tr><th>Satellite</th><th>Uplink</th><th>Downlink</th><th>Comment</th></tr></thead>
<tbody>
<tr><td>ISS</td><td>145.990 MHz</td><td>437.800 MHz</td><td></td></tr>
<tr><td>AO-91(RadFxSat / Fox-1B)</td><td>435.250 MHz</td><td>145.960 MHz</td><td></td></tr>
</tbody>
</table>
""".trimIndent().byteInputStream()
}
private class FakeRemoteSource : IRemoteSource {
@@ -362,21 +295,6 @@ private class FakeSettingsRepo(dataSources: DataSourcesSettings = defaultDataSou
(dataSourcesStatus as? MutableStateFlow)?.value = status
}
var amSatFm: Set<Int> = emptySet()
var amSatLinear: Set<Int> = emptySet()
var amSatActive: Set<Int> = emptySet()
override fun getAmSatFmCatnums(): Set<Int> = amSatFm
override fun getAmSatLinearCatnums(): Set<Int> = amSatLinear
override fun setAmSatCatnums(fmCatnums: Set<Int>, linearCatnums: Set<Int>) {
amSatFm = fmCatnums
amSatLinear = linearCatnums
}
override fun getAmSatActiveCatnums(): Set<Int> = amSatActive
override fun setAmSatActiveCatnums(catnums: Set<Int>) {
amSatActive = catnums
}
override fun updateRadioControlSettings(settings: RadioControlSettings) = Unit
override fun getSatelliteOffset(catnum: Int): String = ""
@@ -113,14 +113,23 @@ class SelectionRepoSearchTest {
}
@Test
fun `FM virtual type shows only AMSAT FM list satellites`() = runTest {
val repo = createRepo(
items = sampleItems,
amSatFm = setOf(25544, 39444)
)
fun `FM virtual type shows only hardcoded FM satellites`() = runTest {
val repo = createRepo(sampleItems)
repo.setTypes(listOf("AMSAT Live FM"))
val results = repo.getEntriesFlow().first()
assertEquals(listOf(25544, 39444), results.map { it.catnum })
// Hardcoded FM list: SO-50 (27607), ISS ZARYA (25544), AO-123 (43137).
// Only ISS ZARYA is present in the sample DB.
assertEquals(listOf(25544), results.map { it.catnum })
}
@Test
fun `Linear virtual type shows only hardcoded linear satellites`() = runTest {
val repo = createRepo(sampleItems)
repo.setTypes(listOf("AMSAT Live Linear"))
val results = repo.getEntriesFlow().first()
// Hardcoded linear list: RS-44 (44909), FO-29 (24278), AO-7 (7530),
// AO-73 (39444), JO-97 (43803). Present in the sample DB: 7530, 39444, 43803.
assertEquals(setOf(7530, 39444, 43803), results.map { it.catnum }.toSet())
}
@Test
@@ -140,8 +149,7 @@ class SelectionRepoSearchTest {
// only catnums the DAO would have filtered to Amateur service (43803).
val repo = createRepo(
items = sampleItems,
sstvIds = listOf(43803),
amSatActive = setOf(43803)
sstvIds = listOf(43803)
)
repo.setTypes(listOf("Live SSTV"))
val results = repo.getEntriesFlow().first()
@@ -155,8 +163,7 @@ class SelectionRepoSearchTest {
val items = sampleItems + SatItem(catnum = 60239, name = "ARIANE 6 R/B")
val repo = createRepo(
items = items,
sstvIds = listOf(43803, 60239),
amSatActive = setOf(43803, 60239)
sstvIds = listOf(43803, 60239)
)
repo.setTypes(listOf("Live SSTV"))
val results = repo.getEntriesFlow().first()
@@ -167,8 +174,6 @@ class SelectionRepoSearchTest {
fun `multiple virtual types union their satellites`() = runTest {
val repo = createRepo(
items = sampleItems,
amSatFm = setOf(25544),
amSatLinear = setOf(7530),
sstvIds = listOf(43803)
)
repo.setTypes(listOf("AMSAT Live FM", "Live SSTV"))
@@ -178,7 +183,7 @@ class SelectionRepoSearchTest {
@Test
fun `clearing types restores full list`() = runTest {
val repo = createRepo(items = sampleItems, amSatFm = setOf(25544))
val repo = createRepo(sampleItems)
repo.setTypes(listOf("AMSAT Live FM"))
assertTrue(repo.getEntriesFlow().first().isNotEmpty())
repo.setTypes(emptyList())
@@ -186,23 +191,24 @@ class SelectionRepoSearchTest {
}
@Test
fun `virtual type with empty AMSAT list shows empty not everything`() = runTest {
// 根因2回归: AMSAT 清单未同步(空)时, 选虚拟类型应显示空列表, 而非全部卫星.
val repo = createRepo(items = sampleItems, amSatFm = emptySet())
fun `virtual type with no matching satellites shows empty not everything`() = runTest {
// 硬编码 FM 列表 (27607/25544/43137) 里没有样本库中的卫星时, 选虚拟
// 类型应显示空列表, 而非全部卫星.
val items = listOf(SatItem(catnum = 7530, name = "AO-7 (AMSAT-OSCAR 7)"))
val repo = createRepo(items)
repo.setTypes(listOf("AMSAT Live FM"))
assertTrue(repo.getEntriesFlow().first().isEmpty())
}
@Test
fun `virtual type combined with regular type unions both lists`() = runTest {
val repo = createRepo(
items = sampleItems,
amSatFm = setOf(25544),
amSatLinear = setOf(7530)
)
val repo = createRepo(items = sampleItems)
repo.setTypes(listOf("AMSAT Live FM", "AMSAT Live Linear"))
val results = repo.getEntriesFlow().first()
assertEquals(setOf(25544, 7530), results.map { it.catnum }.toSet())
assertEquals(
setOf(25544, 7530, 39444, 43803),
results.map { it.catnum }.toSet()
)
}
@Test
@@ -217,17 +223,53 @@ class SelectionRepoSearchTest {
assertTrue("All" in types)
}
@Test
fun `All type resolves to persisted CelesTrak active ids`() = runTest {
// Regression: "All" is a real TLE source type (CelesTrak active). Its
// ids are persisted on sync like any other type; selecting it must
// show the synced satellites, NOT an empty list.
val fake = FakeSettingsRepoForSearch()
fake.setSatelliteTypeIds("All", sampleItems.map { it.catnum })
val repo = SelectionRepo(
dispatcher = Dispatchers.Unconfined,
localSource = FakeLocalSourceForSearch(sampleItems),
settingsRepo = fake
)
repo.setTypes(listOf("All"))
val results = repo.getEntriesFlow().first()
assertEquals(sampleItems.map { it.catnum }.toSet(), results.map { it.catnum }.toSet())
}
@Test
fun `getTypesList excludes only the Other placeholder`() = runTest {
val repo = createRepo(sampleItems)
val types = repo.getTypesList()
// All real TLE source types must be selectable, including "All"
// (CelesTrak active) and "ARISS" (removed by the old removeAt(0)).
assertTrue("All" in types)
assertTrue("ARISS" in types)
assertTrue("R4UAB" in types)
assertTrue("SatNOGS" in types)
assertTrue("Other" !in types)
}
@Test
fun `all types shows all items`() = runTest {
val repo = createRepo(sampleItems)
repo.setTypes(emptyList()) // "All" = no type filtering
val results = repo.getEntriesFlow().first()
assertEquals(sampleItems.size, results.size)
assertEquals(sampleItems.map { it.catnum }.toSet(), results.map { it.catnum }.toSet())
}
private fun createRepo(
items: List<SatItem>,
amSatFm: Set<Int> = emptySet(),
amSatLinear: Set<Int> = emptySet(),
sstvIds: List<Int> = emptyList(),
amSatActive: Set<Int> = emptySet()
sstvIds: List<Int> = emptyList()
): ISelectionRepo {
return SelectionRepo(
dispatcher = Dispatchers.Unconfined,
localSource = FakeLocalSourceForSearch(items, sstvIds),
settingsRepo = FakeSettingsRepoForSearch(amSatFm, amSatLinear, amSatActive)
settingsRepo = FakeSettingsRepoForSearch()
)
}
}
@@ -251,11 +293,8 @@ private class FakeLocalSourceForSearch(
override suspend fun deleteRadios() = Unit
}
private class FakeSettingsRepoForSearch(
private val amSatFm: Set<Int> = emptySet(),
private val amSatLinear: Set<Int> = emptySet(),
private var amSatActive: Set<Int> = emptySet()
) : ISettingsRepo {
private class FakeSettingsRepoForSearch : ISettingsRepo {
private val typeIds = mutableMapOf<String, List<Int>>()
override val appVersionName: String = "test"
override val selectedIds: StateFlow<List<Int>> = MutableStateFlow(emptyList())
override val selectedTypes: StateFlow<List<String>> = MutableStateFlow(emptyList())
@@ -290,18 +329,17 @@ private class FakeSettingsRepoForSearch(
override fun setStationPosition(latitude: Double, longitude: Double, altitude: Double): Boolean = true
override fun setStationPosition(): Boolean = true
override fun setStationPosition(locator: String): Boolean = true
override fun getSatelliteTypesIds(types: List<String>): List<Int> = emptyList()
override fun setSatelliteTypeIds(type: String, ids: List<Int>) = Unit
override fun getSatelliteTypesIds(types: List<String>): List<Int> =
types.flatMap { typeIds[it].orEmpty() }.distinct()
override fun setSatelliteTypeIds(type: String, ids: List<Int>) {
typeIds[type] = ids
}
override fun updateDatabaseState(state: DatabaseState) = Unit
override fun updateRCSettings(settings: RCSettings) = Unit
override fun updateOtherSettings(transform: (OtherSettings) -> OtherSettings) = Unit
override fun updateDataSourcesSettings(settings: DataSourcesSettings) = Unit
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 fun getAmSatActiveCatnums(): Set<Int> = amSatActive
override fun setAmSatActiveCatnums(catnums: Set<Int>) { amSatActive = catnums }
override fun updateRadioControlSettings(settings: RadioControlSettings) = Unit
override fun getSatelliteOffset(catnum: Int): String = ""
override fun setSatelliteOffset(catnum: Int, offset: String) = Unit
@@ -79,18 +79,6 @@ interface ISettingsRepo {
fun updateDataSourcesStatus(status: Map<String, Int>)
//endregion
//region # AMSAT live-transponder lists
/** Catalog numbers of satellites listed on AMSAT's "Live FM Satellites" page. */
fun getAmSatFmCatnums(): Set<Int>
/** Catalog numbers of satellites listed on AMSAT's "Live Linear Satellites" page. */
fun getAmSatLinearCatnums(): Set<Int>
fun setAmSatCatnums(fmCatnums: Set<Int>, linearCatnums: Set<Int>)
/** Catalog numbers of active *amateur* satellites from AMSAT's
* active-transponder table (whitelist for the virtual type filters). */
fun getAmSatActiveCatnums(): Set<Int>
fun setAmSatActiveCatnums(catnums: Set<Int>)
//endregion
//region # Radio control settings
val radioControlSettings: StateFlow<RadioControlSettings>
fun updateRadioControlSettings(settings: RadioControlSettings)
@@ -55,31 +55,25 @@ object Sources {
"SatNOGS" to "https://db.satnogs.org/api/transmitters/?format=json&status=active",
"R4UAB" to "https://r4uab.ru/transmitters.json"
)
/** AMSAT "Live FM/Linear Satellites" pages: human-maintained lists of
* transponders currently on the air. Parsed into the mutual-match filter
* (satellites that actually carry a working FM-voice / linear repeater). */
val amSatLiveUrls = mapOf(
"FM" to "https://www.amsat.org/live-fm-satellites/",
"Linear" to "https://www.amsat.org/live-linear-satellites/"
)
/**
* Hardcoded AMSAT Live FM satellites (NORAD catnums):
* SO-50 (27607), ISS ZARYA (25544), AO-123 Fox-1D (43137).
* Replaces the AMSAT live-page fetch: stable on any network, no
* sync-time dependency, no stale-list or timeout failure modes.
*/
val amSatFmCatnums = setOf(27607, 25544, 43137)
/** Machine-readable AMSAT active-transponder table (palewire mirror of
* amsat.org's active-frequency pages): one CSV row per active amateur
* transponder with its NORAD catnum. Used as the authoritative whitelist
* of *amateur* satellites, so the virtual filters never match
* non-amateur debris (Ariane 6 R/B), retired weather sats (TIROS), or
* ISS station-module aliases (ISS (DESTINY) etc.). Primary source plus
* a jsDelivr CDN mirror: raw.githubusercontent.com is unreachable on
* some networks (e.g. mainland China mobile), and a failed whitelist
* fetch would silently disable the module disambiguation. */
val amSatActiveUrls = listOf(
"https://raw.githubusercontent.com/palewire/amateur-satellite-database/main/data/amsat-active-frequencies.csv",
"https://cdn.jsdelivr.net/gh/palewire/amateur-satellite-database@main/data/amsat-active-frequencies.csv"
)
/**
* Hardcoded AMSAT Live linear (SSB/CW) satellites:
* RS-44 (44909), FO-29 (24278), AO-7 (7530),
* AO-73 FUNcube-1 (39444), JO-97 JY1SAT (43803).
*/
val amSatLinearCatnums = setOf(44909, 24278, 7530, 39444, 43803)
/** Virtual satellite-selection types: transponder/activity filters shown
* at the top of the type picker. They resolve to live lists (AMSAT pages
* or mode=SSTV radios) instead of persisted per-type IDs, and are
* mutually exclusive with the regular TLE-source types in the picker. */
* at the top of the type picker. They resolve to live lists (hardcoded
* FM/Linear catnum sets or mode=SSTV radios) instead of persisted
* per-type IDs, and are mutually exclusive with the regular TLE-source
* types in the picker. */
val virtualTypeNames = listOf("AMSAT Live FM", "AMSAT Live Linear", "Live SSTV")
}
@@ -40,21 +40,6 @@ class DataParser(private val dispatcher: CoroutineDispatcher) {
}
}
/** Parse AMSAT's active-transponder CSV (palewire mirror) and return the
* set of NORAD catnums of active *amateur* satellites. Header row has a
* norad_id column; some rows carry alphanumeric IDs (Bluebird A0241 etc.)
* which are skipped. */
suspend fun parseAmSatActiveCatnums(stream: InputStream): Set<Int> = withContext(dispatcher) {
stream.bufferedReader().useLines { lines ->
val header = lines.firstOrNull() ?: return@useLines emptySet()
val noradIdx = header.split(",").indexOfFirst { it.trim().equals("norad_id", true) }
if (noradIdx < 0) return@useLines emptySet()
lines.mapNotNull { line ->
line.split(",").getOrNull(noradIdx)?.trim()?.toIntOrNull()
}.toSet()
}
}
suspend fun parseTLEStream(stream: InputStream): List<OrbitalData> = withContext(dispatcher) {
stream.bufferedReader().readLines()
.chunked(3)
@@ -73,101 +58,6 @@ class DataParser(private val dispatcher: CoroutineDispatcher) {
}.getOrDefault(emptyList())
}
/**
* Parse an AMSAT "Live FM/Linear Satellites" page into satellite names.
* The page is an HTML table whose first column is the satellite name,
* sometimes written as an alias list ("AO-91(RadFxSat / Fox-1B)") or a
* range ("TEVEL2-1 thru TEVEL2-9"). Returns the distinct expanded names.
*/
suspend fun parseAmSatLivePage(stream: InputStream): List<String> = withContext(dispatcher) {
runCatching {
val html = stream.bufferedReader().readText()
// Grab table rows, take the first cell of each row, keep rows that
// look like satellite names (start with letters/digits, not a header).
val rows = Regex("<tr[^>]*>(.*?)</tr>", RegexOption.IGNORE_CASE)
.findAll(html)
.mapNotNull { match ->
val cells = Regex("<t[dh][^>]*>(.*?)</t[dh]>", RegexOption.IGNORE_CASE)
.findAll(match.groupValues[1])
.map { cell -> stripHtml(cell.groupValues[1]).trim() }
.toList()
cells.firstOrNull()?.takeIf { it.isNotBlank() && !it.equals("Satellite", ignoreCase = true) }
}
.toList()
rows.flatMap { expandNameRange(it) }.distinct()
}.getOrDefault(emptyList())
}
private fun stripHtml(raw: String): String =
raw.replace(Regex("<[^>]+>"), " ").replace(Regex("\\s+"), " ").trim()
/** Expand "NAME1 thru NAME9" ranges (e.g. TEVEL2-1 thru TEVEL2-9). */
private fun expandNameRange(name: String): List<String> {
val m = Regex("^(.*?)(\\d+)\\s+thru\\s+(.*?)(\\d+)$", RegexOption.IGNORE_CASE).find(name)
if (m == null) return listOf(name)
val prefix1 = m.groupValues[1].trim()
val startNum = m.groupValues[2].toIntOrNull() ?: return listOf(name)
val endNum = m.groupValues[4].toIntOrNull() ?: return listOf(name)
val prefix2 = m.groupValues[3].trim()
if (startNum > endNum) return listOf(name)
// Suffix after the end number, if any (e.g. "TEVEL2-1 thru TEVEL2-9 ").
val suffix = name.substringAfterLast(m.groupValues[4]).trim()
return (startNum..endNum).map { "$prefix1$it$suffix" }
}
/**
* Normalize a satellite name from the AMSAT live pages into lookup keys
* used to match against the local entries table:
* - primary key: the leading designator ("AO-91" from "AO-91 (RadFxSat / Fox-1B)")
* - alias keys: every bracketed alias, uppercased, digits preserved
* The local entry names are normalized the same way in [matchesAmSatName].
*/
fun normalizeAmSatName(name: String): List<String> {
val keys = mutableListOf<String>()
// Primary: everything before the first '(' (or '['), then the first token.
val primary = name.substringBefore('(').substringBefore('[').trim()
primary.split(Regex("\\s+")).firstOrNull()?.takeIf { it.isNotBlank() }?.let {
keys += it.uppercase()
}
// Aliases inside parentheses / brackets: "RadFxSat / Fox-1B" -> two keys.
Regex("\\(([^)]*)\\)").findAll(name).forEach { m ->
m.groupValues[1].split('/', '|').forEach { alias ->
alias.trim().takeIf { it.isNotBlank() }?.let { keys += it.uppercase() }
}
}
Regex("\\[([^]]*)]").findAll(name).forEach { m ->
m.groupValues[1].split('/', '|').forEach { alias ->
alias.trim().takeIf { it.isNotBlank() }?.let { keys += it.uppercase() }
}
}
return keys.distinct()
}
/** True if a local entry name matches any of the AMSAT normalized keys.
* Token-based exact match (split on spaces/brackets/slashes), so a key
* like "ISS" does not substring-match "AISSAT-1" — the key must equal a
* whole name token (case-insensitive). OSCAR designators are expanded so
* that "AO-7" also matches local names spelled "OSCAR 7" / "AMSAT-OSCAR 7"
* (the form used by CelesTrak/SatNOGS TLE sources). */
fun matchesAmSatName(localName: String, amSatKeys: List<String>): Boolean {
val localUpper = localName.uppercase()
val localTokens = localUpper
.split(Regex("[\\s()\\[\\]/]+"))
.filter { it.isNotBlank() }
.toSet()
return amSatKeys.any { key ->
val k = key.uppercase()
if (k in localTokens) return@any true
// "AO-7" -> also match local "OSCAR 7" or "AMSAT-OSCAR 7".
val oscar = Regex("^([A-Z]{1,3})-(\\d+)$").find(k)
if (oscar != null) {
val num = oscar.groupValues[2]
if ("OSCAR $num" in localUpper || "AMSAT-OSCAR $num" in localUpper) return@any true
}
false
}
}
private fun parseCSV(values: List<String>): OrbitalData? = runCatching {
val name = values[0]
val timestamp = values[2]
@@ -239,60 +239,4 @@ class DataParserTest {
// Matches the CSV test data epoch: 2021-11-16 → day 320
assert(dataParser.getDayOfYear(2021, 11, 16) == 320)
}
@Test
fun `AMSAT FM page parses satellite names and expands thru ranges`() = runTest(testDispatcher) {
val html = """
<html><body><table>
<tr><th>Satellite</th><th>Uplink</th><th>Downlink</th><th>Comment</th></tr>
<tr><td>AO-91(RadFxSat / Fox-1B)</td><td>435.250</td><td>145.960</td></tr>
<tr><td>SO-50(SaudiSat-1C)</td><td>145.850</td><td>436.795</td></tr>
<tr><td>TEVEL2-1 thru TEVEL2-9</td><td>145.970</td><td>436.400</td></tr>
</table></body></html>
""".trimIndent().byteInputStream()
val names = dataParser.parseAmSatLivePage(html)
assert(names.contains("AO-91(RadFxSat / Fox-1B)"))
assert(names.contains("SO-50(SaudiSat-1C)"))
assert(names.contains("TEVEL2-1"))
assert(names.contains("TEVEL2-9"))
assert(names.size == 11) // 2 singles + 9 expanded TEVEL2
}
@Test
fun `AMSAT linear page keeps mode-suffixed names`() = runTest(testDispatcher) {
val html = """
<table>
<tr><th>Satellite</th><th>Frequencies</th><th>Comment</th></tr>
<tr><td>AO-7 Mode B U/v Inverting Analog</td><td>Uplink LSB</td></tr>
<tr><td>FO-29 (JAS-2)V/u Inverting Analog</td><td>Uplink LSB</td></tr>
</table>
""".trimIndent().byteInputStream()
val names = dataParser.parseAmSatLivePage(html)
assert(names.contains("AO-7 Mode B U/v Inverting Analog"))
assert(names.contains("FO-29 (JAS-2)V/u Inverting Analog"))
}
@Test
fun `AMSAT name normalization produces primary and alias keys`() {
assert(dataParser.normalizeAmSatName("AO-91(RadFxSat / Fox-1B)") == listOf("AO-91", "RADFXSAT", "FOX-1B"))
assert(dataParser.normalizeAmSatName("QO-100 (Es'hail-2/P4A) S/x") == listOf("QO-100", "ES'HAIL-2", "P4A"))
assert(dataParser.normalizeAmSatName("ISS") == listOf("ISS"))
assert(dataParser.matchesAmSatName("AO-91 (RADFXSAT)", listOf("AO-91", "RADFXSAT")))
assert(!dataParser.matchesAmSatName("SO-50", listOf("AO-91")))
// Regression: "ISS" must not substring-match "AISSAT-1" (token match only).
assert(dataParser.matchesAmSatName("ISS (ZARYA)", listOf("ISS")))
assert(!dataParser.matchesAmSatName("AISSAT-1", listOf("ISS")))
assert(!dataParser.matchesAmSatName("TESS", listOf("ISS")))
// OSCAR designators match local names spelled "OSCAR N"/"AMSAT-OSCAR N".
assert(dataParser.matchesAmSatName("OSCAR 7", listOf("AO-7")))
assert(dataParser.matchesAmSatName("AMSAT-OSCAR 7", listOf("AO-7")))
assert(dataParser.matchesAmSatName("CATSAT", listOf("CATSAT")))
assert(dataParser.matchesAmSatName("RS-44 & BREEZE-KM R/B", listOf("RS-44")))
}
@Test
fun `AMSAT live page with no table returns empty`() = runTest(testDispatcher) {
val html = "<html><body>No data</body></html>".byteInputStream()
assert(dataParser.parseAmSatLivePage(html).isEmpty())
}
}
@@ -283,11 +283,6 @@ class LoTWGridSyncTest {
override fun updateOtherSettings(transform: (OtherSettings) -> OtherSettings) = TODO()
override fun updateDataSourcesSettings(settings: DataSourcesSettings) = TODO()
override fun updateDataSourcesStatus(status: Map<String, Int>) = TODO()
override fun getAmSatFmCatnums(): Set<Int> = emptySet()
override fun getAmSatLinearCatnums(): Set<Int> = emptySet()
override fun setAmSatCatnums(fmCatnums: Set<Int>, linearCatnums: Set<Int>) = Unit
override fun getAmSatActiveCatnums(): Set<Int> = emptySet()
override fun setAmSatActiveCatnums(catnums: Set<Int>) = Unit
override fun updateRadioControlSettings(settings: RadioControlSettings) = TODO()
override fun getSatelliteOffset(catnum: Int): String = ""
override fun setSatelliteOffset(catnum: Int, offset: String) = TODO()
@@ -26,6 +26,7 @@ import com.rtbishop.look4sat.core.domain.predict.OrbitalPass
import com.rtbishop.look4sat.core.domain.repository.IMainContainer
import com.rtbishop.look4sat.core.domain.repository.ISatelliteRepo
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
import com.rtbishop.look4sat.core.domain.source.Sources
import com.rtbishop.look4sat.core.domain.utility.positionToQth
import com.rtbishop.look4sat.core.domain.utility.qthToPosition
import kotlinx.coroutines.CoroutineDispatcher
@@ -303,27 +304,15 @@ class MutualViewModel(
viewModelScope.launch {
val now = System.currentTimeMillis()
// 转发器类型过滤(协程上下文): AMSAT Live 清单优先, 为空时回退数据库.
val amSatFm = settingsRepo.getAmSatFmCatnums()
val amSatLinear = settingsRepo.getAmSatLinearCatnums()
val filteredSatellites = if (amSatFm.isEmpty() && amSatLinear.isEmpty()) {
// 回退到数据库模式过滤: 只用带上行频率的转发器记录, 排除纯下行
// 信标/遥测(如 CW 信标、FM 语音合成信标), 避免 ISS/IO-86/FO-29 等
// 因共享模式标签(CW/FM)被误判进错误类别.
val filterModes = buildList {
if (state.filterFM) add("FM")
if (state.filterLinear) addAll(listOf("USB", "LSB", "CW", "SSB"))
}
val idsWithModes = satelliteRepo.getSatelliteIdsWithModesAndUplink(filterModes)
if (idsWithModes.isEmpty()) satellites
else satellites.filter { it.data.catnum in idsWithModes }
} else {
val allowed = buildSet {
if (state.filterFM) addAll(amSatFm)
if (state.filterLinear) addAll(amSatLinear)
}
satellites.filter { it.data.catnum in allowed }
// 转发器类型筛选: 使用硬编码 AMSAT Live 清单 (见 Sources):
// FM = {SO-50, ISS ZARYA, AO-123}, Linear = {RS-44, FO-29,
// AO-7, AO-73, JO-97}. 不再依赖 AMSAT 网页同步: 任何网络下都
// 稳定, 没有旧脏列表/超时/同步失败清空等问题.
val allowed = buildSet {
if (state.filterFM) addAll(Sources.amSatFmCatnums)
if (state.filterLinear) addAll(Sources.amSatLinearCatnums)
}
val filteredSatellites = satellites.filter { it.data.catnum in allowed }
// 历史回看: 查询窗口起点 = now - hoursBefore (跟随主页面 Passes 设置),
// 终点 = now + hoursAhead 不变.
val time = now - state.hoursBefore * 60L * 60L * 1000L
@@ -54,9 +54,7 @@ class FakeSatelliteRepo(
* state; everything else the ViewModel never touches fails loudly.
*/
class FakeSettingsRepo(
initialPosition: GeoPos = GeoPos(23.13, 113.26),
val amSatFm: Set<Int> = emptySet(),
val amSatLinear: Set<Int> = emptySet()
initialPosition: GeoPos = GeoPos(23.13, 113.26)
) : ISettingsRepo {
override val stationPosition = MutableStateFlow(initialPosition)
@@ -110,12 +108,6 @@ class FakeSettingsRepo(
override fun updateDataSourcesStatus(status: Map<String, Int>) = TODO()
override fun getAmSatFmCatnums(): Set<Int> = amSatFm
override fun getAmSatLinearCatnums(): Set<Int> = amSatLinear
override fun setAmSatCatnums(fmCatnums: Set<Int>, linearCatnums: Set<Int>) = TODO()
override fun getAmSatActiveCatnums(): Set<Int> = emptySet()
override fun setAmSatActiveCatnums(catnums: Set<Int>) = TODO()
override fun updateRadioControlSettings(settings: RadioControlSettings) = TODO()
override fun getSatelliteOffset(catnum: Int): String = ""
override fun setSatelliteOffset(catnum: Int, offset: String) = TODO()
@@ -31,12 +31,10 @@ class MutualViewModelTest {
private fun TestScope.createVm(
satellites: List<OrbitalObject> = emptyList(),
passes: List<OrbitalPass> = emptyList(),
position: GeoPos = TestOrbits.GUANGZHOU,
amSatFm: Set<Int> = emptySet(),
amSatLinear: Set<Int> = emptySet()
position: GeoPos = TestOrbits.GUANGZHOU
): MutualViewModel = MutualViewModel(
satelliteRepo = FakeSatelliteRepo(satellites, passes),
settingsRepo = FakeSettingsRepo(position, amSatFm, amSatLinear),
settingsRepo = FakeSettingsRepo(position),
computeDispatcher = StandardTestDispatcher(mainDispatcherRule.dispatcher.scheduler)
)
@@ -343,15 +341,15 @@ class MutualViewModelTest {
}
@Test
fun `AMSAT filter restricts results to listed catnums`() = runTest(mainDispatcherRule.dispatcher.scheduler) {
fun `hardcoded AMSAT filter restricts results to listed catnums`() = runTest(mainDispatcherRule.dispatcher.scheduler) {
val windows = TestOrbits.findPassWindows(hoursAhead = 24) +
TestOrbits.findPassWindows(sat = TestOrbits.ISS_VARIANT, hoursAhead = 24)
assertTrue("fixture must produce windows", windows.isNotEmpty())
// Only ISS (25544) is on the AMSAT live FM list; ISS_VARIANT (25545) is not.
// ISS (25544) is on the hardcoded FM list (Sources.amSatFmCatnums);
// ISS_VARIANT (25545) is not.
val vm = createVm(
satellites = listOf(TestOrbits.ISS, TestOrbits.ISS_VARIANT),
passes = windows,
amSatFm = setOf(25544)
passes = windows
)
vm.onStationBGrid("OL62")
vm.onHoursAhead(24)
@@ -361,19 +359,18 @@ class MutualViewModelTest {
val state = vm.uiState.value
assertNull(state.errorMessage)
assertTrue(state.mutualPasses.isNotEmpty())
assertTrue("only AMSAT-listed satellites should appear", state.mutualPasses.all { it.catNum == 25544 })
assertTrue("only hardcoded FM-listed satellites should appear", state.mutualPasses.all { it.catNum == 25544 })
}
@Test
fun `AMSAT filter off for one type excludes its satellites`() = runTest(mainDispatcherRule.dispatcher.scheduler) {
fun `turning FM off excludes FM-listed satellites under Linear filter`() = runTest(mainDispatcherRule.dispatcher.scheduler) {
val windows = TestOrbits.findPassWindows(hoursAhead = 24) +
TestOrbits.findPassWindows(sat = TestOrbits.ISS_VARIANT, hoursAhead = 24)
// FM filter off, Linear on: neither sat is linear-listed -> nothing matches.
// ISS (25544) is on the hardcoded FM list but NOT on the hardcoded
// Linear list; with FM off and Linear on, no satellite passes.
val vm = createVm(
satellites = listOf(TestOrbits.ISS, TestOrbits.ISS_VARIANT),
passes = windows,
amSatFm = setOf(25544),
amSatLinear = setOf(25545)
passes = windows
)
vm.onStationBGrid("OL62")
vm.onHoursAhead(24)
@@ -382,7 +379,7 @@ class MutualViewModelTest {
queryAndSettle(vm)
val state = vm.uiState.value
assertNull(state.errorMessage)
assertTrue("only linear-listed satellites should appear", state.mutualPasses.all { it.catNum == 25545 })
// 空结果会设置 "No mutual passes found" 提示, 这是正常行为而非筛选错误.
assertTrue(state.mutualPasses.isEmpty())
}
}
+2 -2
View File
@@ -1,8 +1,8 @@
[versions]
#noinspection UnusedVersionCatalogEntry
appVersionCode = "501"
appVersionCode = "506"
#noinspection UnusedVersionCatalogEntry
appVersionName = "4.4.7-ba7opf.15.4"
appVersionName = "4.4.7-ba7opf.15.9"
#noinspection UnusedVersionCatalogEntry
compileSdk = "37"
#noinspection UnusedVersionCatalogEntry