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 8f6543b6..fe736f42 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 @@ -23,6 +23,7 @@ import com.rtbishop.look4sat.core.domain.repository.IDatabaseRepo import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo import com.rtbishop.look4sat.core.domain.source.ILocalSource import com.rtbishop.look4sat.core.domain.source.IRemoteSource +import com.rtbishop.look4sat.core.domain.source.NetworkResult import com.rtbishop.look4sat.core.domain.source.Sources import com.rtbishop.look4sat.core.domain.utility.DataParser import kotlinx.coroutines.CoroutineDispatcher @@ -130,15 +131,22 @@ class DatabaseRepo( val entries = localSource.getEntriesList() // catnum -> name val jobs = Sources.amSatLiveUrls.map { (type, url) -> async { type to remoteSource.getNetworkStream(url) } - } + async { "Active" to remoteSource.getNetworkStream(Sources.amSatActiveUrl) } + } + 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() - val activeCatnums = results.firstOrNull { it.first == "Active" }?.second?.stream - ?.let { dataParser.parseAmSatActiveCatnums(it) }.orEmpty() - settingsRepo.setAmSatActiveCatnums(activeCatnums) + // 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, @@ -152,10 +160,18 @@ class DatabaseRepo( }.values.toSet() if (all.size <= 1) return all val preferred = all.intersect(activeCatnums) - return if (preferred.isNotEmpty()) preferred else all + 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 { @@ -164,6 +180,16 @@ class DatabaseRepo( } } + /** 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() 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 a44e7494..dd2dc318 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 @@ -143,6 +143,96 @@ 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 = """ + + + + + + +
SatelliteUplinkDownlinkComment
ISS145.990 MHz437.800 MHz
AO-91(RadFxSat / Fox-1B)435.250 MHz145.960 MHz
+ """.trimIndent().byteInputStream() } private class FakeRemoteSource : IRemoteSource { @@ -167,7 +257,8 @@ private class FakeLocalSource : ILocalSource { override suspend fun getEntriesTotal(): Int = insertedEntries.size - override suspend fun getEntriesList(): List = emptyList() + override suspend fun getEntriesList(): List = + insertedEntries.map { SatItem(it.catnum, it.name) } override suspend fun getEntriesWithIds(ids: List): List = emptyList() @@ -271,11 +362,20 @@ private class FakeSettingsRepo(dataSources: DataSourcesSettings = defaultDataSou (dataSourcesStatus as? MutableStateFlow)?.value = status } - override fun getAmSatFmCatnums(): Set = emptySet() - override fun getAmSatLinearCatnums(): Set = emptySet() - override fun setAmSatCatnums(fmCatnums: Set, linearCatnums: Set) = Unit - override fun getAmSatActiveCatnums(): Set = emptySet() - override fun setAmSatActiveCatnums(catnums: Set) = Unit + var amSatFm: Set = emptySet() + var amSatLinear: Set = emptySet() + var amSatActive: Set = emptySet() + + 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 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 f34cfcce..8337f644 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 @@ -68,9 +68,14 @@ object Sources { * 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.). */ - const val amSatActiveUrl = - "https://raw.githubusercontent.com/palewire/amateur-satellite-database/main/data/amsat-active-frequencies.csv" + * 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" + ) /** Virtual satellite-selection types: transponder/activity filters shown * at the top of the type picker. They resolve to live lists (AMSAT pages