From 70ada0d608537bf8fc79de2ae270902d581df8f4 Mon Sep 17 00:00:00 2001 From: atsunatsu Date: Thu, 24 Sep 2026 12:41:52 +0800 Subject: [PATCH] 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 --- .../core/data/repository/DatabaseRepo.kt | 95 --------- .../core/data/repository/SelectionRepo.kt | 38 ++-- .../core/data/repository/SettingsRepo.kt | 37 ---- .../core/data/repository/DatabaseRepoTest.kt | 183 +----------------- .../repository/SelectionRepoSearchTest.kt | 96 +++------ .../core/domain/repository/ISettingsRepo.kt | 17 -- .../look4sat/core/domain/source/Sources.kt | 40 ++-- .../core/domain/utility/DataParser.kt | 110 ----------- .../look4sat/core/domain/DataParserTest.kt | 56 ------ .../domain/repository/LoTWGridSyncTest.kt | 6 - .../feature/mutual/MutualViewModel.kt | 29 +-- .../rtbishop/look4sat/feature/mutual/Fakes.kt | 11 +- .../feature/mutual/MutualViewModelTest.kt | 29 ++- gradle/libs.versions.toml | 4 +- 14 files changed, 93 insertions(+), 658 deletions(-) diff --git a/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/DatabaseRepo.kt b/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/DatabaseRepo.kt index 35f6092c..b61b04e6 100644 --- a/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/DatabaseRepo.kt +++ b/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/DatabaseRepo.kt @@ -116,107 +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 { - val keys = dataParser.normalizeAmSatName(name) - val all = nameToCatnum.filter { (localName, _) -> - dataParser.matchesAmSatName(localName, keys) - }.values.toSet() - // No local entry matches this AMSAT name (e.g. a brand-new - // satellite not yet in the local TLE, like TEVEL2/RS95S): - // skip it. Returning empty is correct — crashing here (via - // all.first()) aborted the WHOLE list update and kept the - // stale pre-whitelist FM list with all five ISS modules. - if (all.isEmpty()) return emptySet() - 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; 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): Set = - previous.mapNotNull { catnum -> - nameToCatnum.entries.firstOrNull { it.value == catnum }?.key - }.flatMap { resolvePerName(it) }.toSet() - // Last-resort fallback when the AMSAT page is down AND there is no - // previous list to clean (e.g. right after clearing data): derive - // the FM/Linear sets from the local transceivers intersected with - // the amateur whitelist, so a network timeout never wipes the - // filters to empty. - val fmFallback = localSource.getIdsWithModes(listOf("FM")).toSet().intersect(activeCatnums) - val linearFallback = localSource - .getIdsWithModes(listOf("SSB", "CW", "USB", "LSB")).toSet().intersect(activeCatnums) - val fmCatnums = fmNames.flatMap { resolvePerName(it) }.toSet() - .ifEmpty { cleanStaleList(settingsRepo.getAmSatFmCatnums()) } - .ifEmpty { fmFallback } - val linearCatnums = linearNames.flatMap { resolvePerName(it) }.toSet() - .ifEmpty { cleanStaleList(settingsRepo.getAmSatLinearCatnums()) } - .ifEmpty { linearFallback } - 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) } diff --git a/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/SelectionRepo.kt b/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/SelectionRepo.kt index 19101d4e..a69f74d3 100644 --- a/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/SelectionRepo.kt +++ b/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/SelectionRepo.kt @@ -25,7 +25,6 @@ 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,22 +44,19 @@ 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. - // 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 -> - val catnumSet: Set? = 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 } - } + // are resolved from hardcoded FM/Linear catnum sets (Sources) or live + // SSTV transponder data instead of SharedPreferences. + private val itemsWithTypes = currentTypes.flatMapLatest { types: List -> + val catnumSet: Set? = 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 } + } + } private val itemsWithQuery = currentQuery.flatMapLatest { query -> itemsWithTypes.map { items -> filterByQuery(items, query) } @@ -70,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): List { val idsSet = mutableSetOf() 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 diff --git a/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/SettingsRepo.kt b/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/SettingsRepo.kt index b90f290b..3c8de86b 100644 --- a/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/SettingsRepo.kt +++ b/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/SettingsRepo.kt @@ -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 = @@ -750,40 +747,6 @@ class SettingsRepo( } //endregion - //region # AMSAT live-transponder lists - private val _amSatListsVersion = MutableStateFlow(0) - override val amSatListsVersion: StateFlow = _amSatListsVersion - - override fun getAmSatFmCatnums(): Set { - val raw = preferences.getString(keyAmSatFmCatnums, null) ?: return emptySet() - return raw.split(separatorComma).mapNotNull { it.toIntOrNull() }.toSet() - } - - override fun getAmSatLinearCatnums(): Set { - val raw = preferences.getString(keyAmSatLinearCatnums, null) ?: return emptySet() - return raw.split(separatorComma).mapNotNull { it.toIntOrNull() }.toSet() - } - - override fun setAmSatCatnums(fmCatnums: Set, linearCatnums: Set) { - preferences.edit { - putString(keyAmSatFmCatnums, fmCatnums.sorted().joinToString(separatorComma)) - putString(keyAmSatLinearCatnums, linearCatnums.sorted().joinToString(separatorComma)) - } - _amSatListsVersion.update { it + 1 } - } - - override fun getAmSatActiveCatnums(): Set { - val raw = preferences.getString(keyAmSatActiveCatnums, null) ?: return emptySet() - return raw.split(separatorComma).mapNotNull { it.toIntOrNull() }.toSet() - } - - override fun setAmSatActiveCatnums(catnums: Set) { - preferences.edit { - putString(keyAmSatActiveCatnums, catnums.sorted().joinToString(separatorComma)) - } - } - //endregion - //region # Radio control settings private val keyRadioControlEnabled = "radioControlEnabled" private val keyRadioModel = "radioModel" diff --git a/core/data/src/test/java/com/rtbishop/look4sat/core/data/repository/DatabaseRepoTest.kt b/core/data/src/test/java/com/rtbishop/look4sat/core/data/repository/DatabaseRepoTest.kt index e37e8269..75c87b7a 100644 --- a/core/data/src/test/java/com/rtbishop/look4sat/core/data/repository/DatabaseRepoTest.kt +++ b/core/data/src/test/java/com/rtbishop/look4sat/core/data/repository/DatabaseRepoTest.kt @@ -166,168 +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 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] = { issModulesCsvStream() } - // FM page NOT registered -> 404 -> empty parse. - } - val settingsRepo = FakeSettingsRepo( - dataSources = DataSourcesSettings( - satelliteUrls = listOf(satnogsUrl), - transceiversUrls = emptyList() - ) - ).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() - - // 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) - } - - @Test - fun `amsat fm list survives unknown satellite names and still disambiguates ISS`() = runTest(dispatcher) { - // Regression: AMSAT pages list satellites absent from the local TLE - // (TEVEL2-1..9, RS95S). resolvePerName used to crash on the empty - // match set (all.first()), aborting the WHOLE list update and keeping - // the stale pre-whitelist FM list with all five ISS modules. - val satnogsUrl = Sources.satelliteDataUrls.getValue("SatNOGS") - val localSource = FakeLocalSource() - val remoteSource = FakeRemoteSource().apply { - networkStreams[satnogsUrl] = { issModulesCsvStream() } - networkStreams[Sources.amSatLiveUrls.getValue("FM")] = { amsatFmPageWithUnknownStream() } - } - val settingsRepo = FakeSettingsRepo( - dataSources = DataSourcesSettings( - satelliteUrls = listOf(satnogsUrl), - transceiversUrls = emptyList() - ) - ).apply { amSatActive = setOf(25544) } - val repository = DatabaseRepo(dispatcher, dataParser, localSource, remoteSource, settingsRepo) - - repository.updateFromRemote() - - // No crash; ISS still collapses to the primary ZARYA (25544). - assertEquals(setOf(25544), settingsRepo.amSatFm) - } - - @Test - fun `amsat fm list falls back to local FM transceivers when page fails and no previous list`() = - runTest(dispatcher) { - // After clearing data, the previous FM list is empty AND the AMSAT - // page is down (timeout) -> the filter must fall back to local FM - // transceivers intersected with the whitelist, never stay empty. - val satnogsUrl = Sources.satelliteDataUrls.getValue("SatNOGS") - val localSource = FakeLocalSource().apply { - idsWithModes = listOf(25544, 26700, 39444) // local FM-transceiver sats - } - val remoteSource = FakeRemoteSource().apply { - networkStreams[satnogsUrl] = { issModulesCsvStream() } - // FM/Linear pages NOT registered -> 404 -> fetch fails. - } - val settingsRepo = FakeSettingsRepo( - dataSources = DataSourcesSettings( - satelliteUrls = listOf(satnogsUrl), - transceiversUrls = emptyList() - ) - ).apply { amSatActive = setOf(25544) } // whitelist has only ZARYA - val repository = DatabaseRepo(dispatcher, dataParser, localSource, remoteSource, settingsRepo) - - repository.updateFromRemote() - - // Fallback = local FM ids ∩ whitelist = {25544}; ISS module aliases - // (26700) are excluded by the whitelist. - assertEquals(setOf(25544), settingsRepo.amSatFm) - } - - private fun amsatFmPageWithUnknownStream(): InputStream = """ - - - - - - - -
SatelliteUplinkDownlinkComment
ISS145.990 MHz437.800 MHz
TEVEL2-1 thru TEVEL2-9145.970 MHz436.400 MHz
RS95S(QMR-KWT-2)145.920 MHz436.950 MHz
- """.trimIndent().byteInputStream() - - 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 = """ - - - - - - -
SatelliteUplinkDownlinkComment
ISS145.990 MHz437.800 MHz
AO-91(RadFxSat / Fox-1B)435.250 MHz145.960 MHz
- """.trimIndent().byteInputStream() } private class FakeRemoteSource : IRemoteSource { @@ -349,7 +187,6 @@ private class FakeRemoteSource : IRemoteSource { private class FakeLocalSource : ILocalSource { val insertedEntries = mutableListOf() private val insertedRadios = mutableListOf() - var idsWithModes: List = emptyList() override suspend fun getEntriesTotal(): Int = insertedEntries.size @@ -366,8 +203,8 @@ private class FakeLocalSource : ILocalSource { insertedEntries.clear() } - override suspend fun getIdsWithModes(modes: List): List = idsWithModes - override suspend fun getIdsWithModesAndUplink(modes: List): List = idsWithModes + override suspend fun getIdsWithModes(modes: List): List = emptyList() + override suspend fun getIdsWithModesAndUplink(modes: List): List = emptyList() override suspend fun getIdsWithModesAndAmateur(modes: List): List = emptyList() override suspend fun getRadiosTotal(): Int = insertedRadios.size @@ -458,22 +295,6 @@ private class FakeSettingsRepo(dataSources: DataSourcesSettings = defaultDataSou (dataSourcesStatus as? MutableStateFlow)?.value = status } - var amSatFm: Set = emptySet() - var amSatLinear: Set = emptySet() - var amSatActive: Set = emptySet() - override val amSatListsVersion: StateFlow = MutableStateFlow(0) - - override fun getAmSatFmCatnums(): Set = amSatFm - override fun getAmSatLinearCatnums(): Set = amSatLinear - override fun setAmSatCatnums(fmCatnums: Set, linearCatnums: Set) { - amSatFm = fmCatnums - amSatLinear = linearCatnums - } - override fun getAmSatActiveCatnums(): Set = amSatActive - override fun setAmSatActiveCatnums(catnums: Set) { - amSatActive = catnums - } - override fun updateRadioControlSettings(settings: RadioControlSettings) = Unit override fun getSatelliteOffset(catnum: Int): String = "" diff --git a/core/data/src/test/java/com/rtbishop/look4sat/core/data/repository/SelectionRepoSearchTest.kt b/core/data/src/test/java/com/rtbishop/look4sat/core/data/repository/SelectionRepoSearchTest.kt index 1d5c0177..25f4694e 100644 --- a/core/data/src/test/java/com/rtbishop/look4sat/core/data/repository/SelectionRepoSearchTest.kt +++ b/core/data/src/test/java/com/rtbishop/look4sat/core/data/repository/SelectionRepoSearchTest.kt @@ -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 @@ -256,40 +262,14 @@ class SelectionRepoSearchTest { assertEquals(sampleItems.map { it.catnum }.toSet(), results.map { it.catnum }.toSet()) } - @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, - amSatFm: Set = emptySet(), - amSatLinear: Set = emptySet(), - sstvIds: List = emptyList(), - amSatActive: Set = emptySet() + sstvIds: List = emptyList() ): ISelectionRepo { return SelectionRepo( dispatcher = Dispatchers.Unconfined, localSource = FakeLocalSourceForSearch(items, sstvIds), - settingsRepo = FakeSettingsRepoForSearch(amSatFm, amSatLinear, amSatActive) + settingsRepo = FakeSettingsRepoForSearch() ) } } @@ -313,11 +293,7 @@ private class FakeLocalSourceForSearch( override suspend fun deleteRadios() = Unit } -private class FakeSettingsRepoForSearch( - var amSatFm: Set = emptySet(), - var amSatLinear: Set = emptySet(), - private var amSatActive: Set = emptySet() -) : ISettingsRepo { +private class FakeSettingsRepoForSearch : ISettingsRepo { private val typeIds = mutableMapOf>() override val appVersionName: String = "test" override val selectedIds: StateFlow> = MutableStateFlow(emptyList()) @@ -364,16 +340,6 @@ private class FakeSettingsRepoForSearch( override fun updateOtherSettings(transform: (OtherSettings) -> OtherSettings) = Unit override fun updateDataSourcesSettings(settings: DataSourcesSettings) = Unit override fun updateDataSourcesStatus(status: Map) = Unit - override fun getAmSatFmCatnums(): Set = amSatFm - override fun getAmSatLinearCatnums(): Set = amSatLinear - override val amSatListsVersion: StateFlow = MutableStateFlow(0) - override fun setAmSatCatnums(fmCatnums: Set, linearCatnums: Set) { - amSatFm = fmCatnums - amSatLinear = linearCatnums - (amSatListsVersion as MutableStateFlow).value++ - } - override fun getAmSatActiveCatnums(): Set = amSatActive - override fun setAmSatActiveCatnums(catnums: Set) { amSatActive = catnums } override fun updateRadioControlSettings(settings: RadioControlSettings) = Unit override fun getSatelliteOffset(catnum: Int): String = "" override fun setSatelliteOffset(catnum: Int, offset: String) = Unit diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/ISettingsRepo.kt b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/ISettingsRepo.kt index 56929dff..cc80662a 100644 --- a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/ISettingsRepo.kt +++ b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/ISettingsRepo.kt @@ -79,23 +79,6 @@ interface ISettingsRepo { fun updateDataSourcesStatus(status: Map) //endregion - //region # AMSAT live-transponder lists - /** Catalog numbers of satellites listed on AMSAT's "Live FM Satellites" page. */ - fun getAmSatFmCatnums(): Set - /** Catalog numbers of satellites listed on AMSAT's "Live Linear Satellites" page. */ - fun getAmSatLinearCatnums(): Set - fun setAmSatCatnums(fmCatnums: Set, linearCatnums: Set) - /** Catalog numbers of active *amateur* satellites from AMSAT's - * active-transponder table (whitelist for the virtual type filters). */ - fun getAmSatActiveCatnums(): Set - fun setAmSatActiveCatnums(catnums: Set) - /** 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 - //endregion - //region # Radio control settings val radioControlSettings: StateFlow fun updateRadioControlSettings(settings: RadioControlSettings) diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/source/Sources.kt b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/source/Sources.kt index 8337f644..48cf6610 100644 --- a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/source/Sources.kt +++ b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/source/Sources.kt @@ -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") } diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/utility/DataParser.kt b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/utility/DataParser.kt index 44b547f7..f82d2348 100644 --- a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/utility/DataParser.kt +++ b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/utility/DataParser.kt @@ -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 = 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 = 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 = 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("]*>(.*?)", RegexOption.IGNORE_CASE) - .findAll(html) - .mapNotNull { match -> - val cells = Regex("]*>(.*?)", 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 { - 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 { - val keys = mutableListOf() - // 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): 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): OrbitalData? = runCatching { val name = values[0] val timestamp = values[2] diff --git a/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/DataParserTest.kt b/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/DataParserTest.kt index 131e4975..5d7a5588 100644 --- a/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/DataParserTest.kt +++ b/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/DataParserTest.kt @@ -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 = """ - - - - - -
SatelliteUplinkDownlinkComment
AO-91(RadFxSat / Fox-1B)435.250145.960
SO-50(SaudiSat-1C)145.850436.795
TEVEL2-1 thru TEVEL2-9145.970436.400
- """.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 = """ - - - - -
SatelliteFrequenciesComment
AO-7 Mode B U/v Inverting AnalogUplink LSB
FO-29 (JAS-2)V/u Inverting AnalogUplink LSB
- """.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 = "No data".byteInputStream() - assert(dataParser.parseAmSatLivePage(html).isEmpty()) - } } diff --git a/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/repository/LoTWGridSyncTest.kt b/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/repository/LoTWGridSyncTest.kt index 4299f0ce..78da21e5 100644 --- a/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/repository/LoTWGridSyncTest.kt +++ b/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/repository/LoTWGridSyncTest.kt @@ -283,12 +283,6 @@ class LoTWGridSyncTest { override fun updateOtherSettings(transform: (OtherSettings) -> OtherSettings) = TODO() override fun updateDataSourcesSettings(settings: DataSourcesSettings) = TODO() override fun updateDataSourcesStatus(status: Map) = TODO() - override fun getAmSatFmCatnums(): Set = emptySet() - override fun getAmSatLinearCatnums(): Set = emptySet() - override val amSatListsVersion: StateFlow = MutableStateFlow(0) - override fun setAmSatCatnums(fmCatnums: Set, linearCatnums: Set) = Unit - override fun getAmSatActiveCatnums(): Set = emptySet() - override fun setAmSatActiveCatnums(catnums: Set) = Unit override fun updateRadioControlSettings(settings: RadioControlSettings) = TODO() override fun getSatelliteOffset(catnum: Int): String = "" override fun setSatelliteOffset(catnum: Int, offset: String) = TODO() diff --git a/feature/mutual/src/main/java/com/rtbishop/look4sat/feature/mutual/MutualViewModel.kt b/feature/mutual/src/main/java/com/rtbishop/look4sat/feature/mutual/MutualViewModel.kt index 88ef2157..0020d534 100644 --- a/feature/mutual/src/main/java/com/rtbishop/look4sat/feature/mutual/MutualViewModel.kt +++ b/feature/mutual/src/main/java/com/rtbishop/look4sat/feature/mutual/MutualViewModel.kt @@ -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 diff --git a/feature/mutual/src/test/java/com/rtbishop/look4sat/feature/mutual/Fakes.kt b/feature/mutual/src/test/java/com/rtbishop/look4sat/feature/mutual/Fakes.kt index 514dbf33..7319d43e 100644 --- a/feature/mutual/src/test/java/com/rtbishop/look4sat/feature/mutual/Fakes.kt +++ b/feature/mutual/src/test/java/com/rtbishop/look4sat/feature/mutual/Fakes.kt @@ -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 = emptySet(), - val amSatLinear: Set = emptySet() + initialPosition: GeoPos = GeoPos(23.13, 113.26) ) : ISettingsRepo { override val stationPosition = MutableStateFlow(initialPosition) @@ -110,13 +108,6 @@ class FakeSettingsRepo( override fun updateDataSourcesStatus(status: Map) = TODO() - override fun getAmSatFmCatnums(): Set = amSatFm - override fun getAmSatLinearCatnums(): Set = amSatLinear - override val amSatListsVersion: StateFlow = MutableStateFlow(0) - override fun setAmSatCatnums(fmCatnums: Set, linearCatnums: Set) = TODO() - override fun getAmSatActiveCatnums(): Set = emptySet() - override fun setAmSatActiveCatnums(catnums: Set) = TODO() - override fun updateRadioControlSettings(settings: RadioControlSettings) = TODO() override fun getSatelliteOffset(catnum: Int): String = "" override fun setSatelliteOffset(catnum: Int, offset: String) = TODO() diff --git a/feature/mutual/src/test/java/com/rtbishop/look4sat/feature/mutual/MutualViewModelTest.kt b/feature/mutual/src/test/java/com/rtbishop/look4sat/feature/mutual/MutualViewModelTest.kt index 72e328f1..e9546247 100644 --- a/feature/mutual/src/test/java/com/rtbishop/look4sat/feature/mutual/MutualViewModelTest.kt +++ b/feature/mutual/src/test/java/com/rtbishop/look4sat/feature/mutual/MutualViewModelTest.kt @@ -31,12 +31,10 @@ class MutualViewModelTest { private fun TestScope.createVm( satellites: List = emptyList(), passes: List = emptyList(), - position: GeoPos = TestOrbits.GUANGZHOU, - amSatFm: Set = emptySet(), - amSatLinear: Set = 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()) } } \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 9f3eabf1..f364b2da 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,8 +1,8 @@ [versions] #noinspection UnusedVersionCatalogEntry -appVersionCode = "505" +appVersionCode = "506" #noinspection UnusedVersionCatalogEntry -appVersionName = "4.4.7-ba7opf.15.8" +appVersionName = "4.4.7-ba7opf.15.9" #noinspection UnusedVersionCatalogEntry compileSdk = "37" #noinspection UnusedVersionCatalogEntry