mirror of
https://github.com/atsunatsu/Look4Sat.git
synced 2026-10-02 03:15:37 +00:00
Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a082abb72c | ||
|
|
a2e41a0c1a | ||
|
|
e1daf70f11 | ||
|
|
ca9f93c1ed | ||
|
|
42bd55e180 | ||
|
|
7401e1451b | ||
|
|
fa51fb2b44 | ||
|
|
75a251eb98 | ||
|
|
91b5269740 | ||
|
|
522cac25e8 |
No files matched your search
@@ -54,6 +54,13 @@ interface Look4SatDao {
|
||||
@Query("SELECT catnum FROM radios WHERE downlinkMode IN (:modes) AND uplinkLow IS NOT NULL AND isAlive = 1")
|
||||
suspend fun getIdsWithModesAndUplink(modes: List<String>): List<Int>
|
||||
|
||||
/** Like [getIdsWithModes] but only matches records whose service class is
|
||||
* "Amateur", so the SSTV filter never matches weather birds (TIROS),
|
||||
* launcher debris or other non-amateur transmitters that happen to carry
|
||||
* an SSTV-labelled downlink. */
|
||||
@Query("SELECT catnum FROM radios WHERE downlinkMode IN (:modes) AND service = 'Amateur' AND isAlive = 1")
|
||||
suspend fun getIdsWithModesAndAmateur(modes: List<String>): List<Int>
|
||||
|
||||
@Query("SELECT COUNT(*) FROM radios")
|
||||
suspend fun getRadiosTotal(): Int
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ import androidx.sqlite.db.SupportSQLiteDatabase
|
||||
import com.rtbishop.look4sat.core.data.database.entity.SatEntry
|
||||
import com.rtbishop.look4sat.core.data.database.entity.SatRadio
|
||||
|
||||
@Database(entities = [SatEntry::class, SatRadio::class], version = 2, exportSchema = false)
|
||||
@Database(entities = [SatEntry::class, SatRadio::class], version = 3, exportSchema = false)
|
||||
abstract class Look4SatDb : RoomDatabase() {
|
||||
abstract fun look4SatDao(): Look4SatDao
|
||||
}
|
||||
@@ -37,3 +37,11 @@ val MIGRATION_1_2 = object : Migration(1, 2) {
|
||||
db.execSQL("ALTER TABLE radios ADD COLUMN isCustom INTEGER NOT NULL DEFAULT 0")
|
||||
}
|
||||
}
|
||||
|
||||
/** Adds the transceiver service class ("Amateur" etc.), used by the SSTV virtual
|
||||
* filter to tell amateur satellites apart from debris/weather/launcher stages. */
|
||||
val MIGRATION_2_3 = object : Migration(2, 3) {
|
||||
override fun migrate(db: SupportSQLiteDatabase) {
|
||||
db.execSQL("ALTER TABLE radios ADD COLUMN service TEXT")
|
||||
}
|
||||
}
|
||||
@@ -33,6 +33,10 @@ data class SatRadio(
|
||||
val uplinkMode: String?,
|
||||
val isInverted: Boolean,
|
||||
val catnum: Int?,
|
||||
/** Service class from the transceiver source ("Amateur"/"Unknown" etc.).
|
||||
* Used to tell amateur satellites apart from debris / weather birds
|
||||
* (TIROS) / launcher stages (Ariane 6 R/B) in the SSTV virtual filter. */
|
||||
val service: String? = null,
|
||||
/** Set for manually imported transceivers, which remote updates must not replace. */
|
||||
val isCustom: Boolean = false
|
||||
)
|
||||
@@ -25,6 +25,7 @@ import android.location.LocationManager
|
||||
import androidx.room.Room
|
||||
import com.rtbishop.look4sat.core.data.database.Look4SatDb
|
||||
import com.rtbishop.look4sat.core.data.database.MIGRATION_1_2
|
||||
import com.rtbishop.look4sat.core.data.database.MIGRATION_2_3
|
||||
import com.rtbishop.look4sat.core.data.framework.BluetoothReporter
|
||||
import com.rtbishop.look4sat.core.data.framework.Ft817Controller
|
||||
import com.rtbishop.look4sat.core.data.framework.Ic705Controller
|
||||
@@ -169,7 +170,7 @@ class MainContainer(private val context: Context) : IMainContainer {
|
||||
private fun provideLocalSource(): ILocalSource {
|
||||
val builder = Room.databaseBuilder(context, Look4SatDb::class.java, "Look4SatDBv400")
|
||||
val database = builder
|
||||
.addMigrations(MIGRATION_1_2)
|
||||
.addMigrations(MIGRATION_1_2, MIGRATION_2_3)
|
||||
.fallbackToDestructiveMigration(false)
|
||||
.build()
|
||||
return LocalSource(database.look4SatDao())
|
||||
|
||||
@@ -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,29 +131,78 @@ class DatabaseRepo(
|
||||
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 }
|
||||
fun resolve(names: List<String>): Set<Int> = names.mapNotNull { name ->
|
||||
// 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)
|
||||
nameToCatnum.entries.firstOrNull { (localName, _) ->
|
||||
val all = nameToCatnum.filter { (localName, _) ->
|
||||
dataParser.matchesAmSatName(localName, keys)
|
||||
}?.value
|
||||
}.toSet()
|
||||
val fmCatnums = resolve(fmNames)
|
||||
val linearCatnums = resolve(linearNames)
|
||||
}.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<Int>): Set<Int> =
|
||||
previous.mapNotNull { catnum ->
|
||||
nameToCatnum.entries.firstOrNull { it.value == catnum }?.key
|
||||
}.flatMap { resolvePerName(it) }.toSet()
|
||||
val fmCatnums = fmNames.flatMap { resolvePerName(it) }.toSet()
|
||||
.ifEmpty { cleanStaleList(settingsRepo.getAmSatFmCatnums()) }
|
||||
val linearCatnums = linearNames.flatMap { resolvePerName(it) }.toSet()
|
||||
.ifEmpty { cleanStaleList(settingsRepo.getAmSatLinearCatnums()) }
|
||||
settingsRepo.setAmSatCatnums(fmCatnums, linearCatnums)
|
||||
println("AMSAT live lists updated: FM=${fmCatnums.size}, Linear=${linearCatnums.size}")
|
||||
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()
|
||||
|
||||
@@ -69,6 +69,8 @@ class SatelliteRepo(
|
||||
|
||||
override suspend fun getSatelliteIdsWithModesAndUplink(modes: List<String>) = localStorage.getIdsWithModesAndUplink(modes)
|
||||
|
||||
override suspend fun getSatelliteIdsWithModesAndAmateur(modes: List<String>) = localStorage.getIdsWithModesAndAmateur(modes)
|
||||
|
||||
override suspend fun initRepository() = withContext(dispatcher) {
|
||||
combine(
|
||||
settingsRepo.selectedIds,
|
||||
|
||||
+31
-20
@@ -25,6 +25,7 @@ import com.rtbishop.look4sat.core.domain.source.Sources
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.map
|
||||
@@ -45,17 +46,21 @@ class SelectionRepo(
|
||||
// The HashSet gives O(1) catnum lookups instead of O(n) with a List.
|
||||
// The three virtual types ("AMSAT Live FM", "AMSAT Live Linear", "Live SSTV")
|
||||
// are resolved from live transponder data instead of SharedPreferences.
|
||||
private val itemsWithTypes = currentTypes.flatMapLatest { types: List<String> ->
|
||||
val catnumSet: Set<Int>? = if (types.isEmpty()) {
|
||||
null // null = no filtering
|
||||
} else {
|
||||
val ids = resolveTypeIds(types)
|
||||
if (ids.isEmpty()) emptySet() else ids.toHashSet()
|
||||
// The AMSAT list version counter re-triggers resolution when a background
|
||||
// data sync rewrites the FM/Linear lists, so the UI reflects the new list
|
||||
// without a restart or type toggle.
|
||||
private val itemsWithTypes = combine(currentTypes, settingsRepo.amSatListsVersion) { types, _ -> types }
|
||||
.flatMapLatest { types: List<String> ->
|
||||
val catnumSet: Set<Int>? = if (types.isEmpty()) {
|
||||
null // null = no filtering
|
||||
} else {
|
||||
val ids = resolveTypeIds(types)
|
||||
if (ids.isEmpty()) emptySet() else ids.toHashSet()
|
||||
}
|
||||
currentItems.map { items ->
|
||||
if (catnumSet == null) items else items.filter { it.catnum in catnumSet }
|
||||
}
|
||||
}
|
||||
currentItems.map { items ->
|
||||
if (catnumSet == null) items else items.filter { it.catnum in catnumSet }
|
||||
}
|
||||
}
|
||||
|
||||
private val itemsWithQuery = currentQuery.flatMapLatest { query ->
|
||||
itemsWithTypes.map { items -> filterByQuery(items, query) }
|
||||
@@ -74,16 +79,19 @@ class SelectionRepo(
|
||||
when (type) {
|
||||
Sources.virtualTypeNames[0] -> idsSet.addAll(settingsRepo.getAmSatFmCatnums())
|
||||
Sources.virtualTypeNames[1] -> idsSet.addAll(settingsRepo.getAmSatLinearCatnums())
|
||||
// Live SSTV: mode=SSTV transponder records intersected with the
|
||||
// currently-tracked (in-orbit, from the selected TLE sources)
|
||||
// satellites. The raw mode query alone would include retired
|
||||
// weather/experimental birds (e.g. TIROS catnum 1430 whose old
|
||||
// TV downlink is labelled SSTV but is long gone from orbit).
|
||||
// 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
|
||||
// carry an amateur payload transponder but are not satellites.
|
||||
Sources.virtualTypeNames[2] -> {
|
||||
val inOrbitIds = currentItems.value.map { it.catnum }.toHashSet()
|
||||
idsSet.addAll(
|
||||
localSource.getIdsWithModes(listOf("SSTV")).filter { it in inOrbitIds }
|
||||
)
|
||||
val sstvIds = localSource.getIdsWithModesAndAmateur(listOf("SSTV")).toSet()
|
||||
val inOrbitNames = currentItems.value.associate { it.catnum to it.name }
|
||||
val filtered = sstvIds.filter { catnum ->
|
||||
val name = inOrbitNames[catnum]?.uppercase().orEmpty()
|
||||
!name.endsWith(" R/B") && !name.endsWith(" DEB") &&
|
||||
!name.endsWith("R/B") && !name.endsWith("DEB")
|
||||
}
|
||||
idsSet.addAll(filtered)
|
||||
}
|
||||
else -> idsSet.addAll(settingsRepo.getSatelliteTypesIds(listOf(type)))
|
||||
}
|
||||
@@ -94,7 +102,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) {
|
||||
|
||||
@@ -109,6 +109,7 @@ class SettingsRepo(
|
||||
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 =
|
||||
@@ -497,7 +498,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,6 +751,9 @@ class SettingsRepo(
|
||||
//endregion
|
||||
|
||||
//region # AMSAT live-transponder lists
|
||||
private val _amSatListsVersion = MutableStateFlow(0)
|
||||
override val amSatListsVersion: StateFlow<Int> = _amSatListsVersion
|
||||
|
||||
override fun getAmSatFmCatnums(): Set<Int> {
|
||||
val raw = preferences.getString(keyAmSatFmCatnums, null) ?: return emptySet()
|
||||
return raw.split(separatorComma).mapNotNull { it.toIntOrNull() }.toSet()
|
||||
@@ -763,6 +769,18 @@ class SettingsRepo(
|
||||
putString(keyAmSatFmCatnums, fmCatnums.sorted().joinToString(separatorComma))
|
||||
putString(keyAmSatLinearCatnums, linearCatnums.sorted().joinToString(separatorComma))
|
||||
}
|
||||
_amSatListsVersion.update { it + 1 }
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
|
||||
@@ -49,6 +49,7 @@ class LocalSource(private val look4SatDao: Look4SatDao) : ILocalSource {
|
||||
|
||||
override suspend fun getIdsWithModes(modes: List<String>) = look4SatDao.getIdsWithModes(modes)
|
||||
override suspend fun getIdsWithModesAndUplink(modes: List<String>) = look4SatDao.getIdsWithModesAndUplink(modes)
|
||||
override suspend fun getIdsWithModesAndAmateur(modes: List<String>) = look4SatDao.getIdsWithModesAndAmateur(modes)
|
||||
|
||||
private fun FrameworkEntry.toDomain() = OrbitalData(
|
||||
this.name, this.epoch, this.meanmo, this.eccn, this.incl,
|
||||
@@ -84,12 +85,14 @@ class LocalSource(private val look4SatDao: Look4SatDao) : ILocalSource {
|
||||
|
||||
private fun DomainRadio.toFramework() = FrameworkRadio(
|
||||
this.uuid, this.info, this.isAlive, this.downlinkLow, this.downlinkHigh, this.downlinkMode,
|
||||
this.uplinkLow, this.uplinkHigh, this.uplinkMode, this.isInverted, this.catnum, this.isCustom
|
||||
this.uplinkLow, this.uplinkHigh, this.uplinkMode, this.isInverted, this.catnum,
|
||||
this.service, this.isCustom
|
||||
)
|
||||
|
||||
private fun FrameworkRadio.toDomain() = DomainRadio(
|
||||
this.uuid, this.info, this.isAlive, this.downlinkLow, this.downlinkHigh, this.downlinkMode,
|
||||
this.uplinkLow, this.uplinkHigh, this.uplinkMode, this.isInverted, this.catnum, this.isCustom
|
||||
this.uplinkLow, this.uplinkHigh, this.uplinkMode, this.isInverted, this.catnum,
|
||||
this.service, this.isCustom
|
||||
)
|
||||
|
||||
private fun List<DomainRadio>.toFrameworkRadios() = this.map { radio -> radio.toFramework() }
|
||||
|
||||
+174
-4
@@ -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,6 +166,139 @@ 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)
|
||||
}
|
||||
|
||||
private fun amsatFmPageWithUnknownStream(): InputStream = """
|
||||
<table>
|
||||
<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>TEVEL2-1 thru TEVEL2-9</td><td>145.970 MHz</td><td>436.400 MHz</td><td></td></tr>
|
||||
<tr><td>RS95S(QMR-KWT-2)</td><td>145.920 MHz</td><td>436.950 MHz</td><td></td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
""".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 = """
|
||||
<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 {
|
||||
@@ -167,7 +323,8 @@ private class FakeLocalSource : ILocalSource {
|
||||
|
||||
override suspend fun getEntriesTotal(): Int = insertedEntries.size
|
||||
|
||||
override suspend fun getEntriesList(): List<SatItem> = emptyList()
|
||||
override suspend fun getEntriesList(): List<SatItem> =
|
||||
insertedEntries.map { SatItem(it.catnum, it.name) }
|
||||
|
||||
override suspend fun getEntriesWithIds(ids: List<Int>): List<OrbitalObject> = emptyList()
|
||||
|
||||
@@ -181,6 +338,7 @@ private class FakeLocalSource : ILocalSource {
|
||||
|
||||
override suspend fun getIdsWithModes(modes: List<String>): List<Int> = emptyList()
|
||||
override suspend fun getIdsWithModesAndUplink(modes: List<String>): List<Int> = emptyList()
|
||||
override suspend fun getIdsWithModesAndAmateur(modes: List<String>): List<Int> = emptyList()
|
||||
|
||||
override suspend fun getRadiosTotal(): Int = insertedRadios.size
|
||||
|
||||
@@ -270,9 +428,21 @@ private class FakeSettingsRepo(dataSources: DataSourcesSettings = defaultDataSou
|
||||
(dataSourcesStatus as? MutableStateFlow)?.value = status
|
||||
}
|
||||
|
||||
override fun getAmSatFmCatnums(): Set<Int> = emptySet()
|
||||
override fun getAmSatLinearCatnums(): Set<Int> = emptySet()
|
||||
override fun setAmSatCatnums(fmCatnums: Set<Int>, linearCatnums: Set<Int>) = Unit
|
||||
var amSatFm: Set<Int> = emptySet()
|
||||
var amSatLinear: Set<Int> = emptySet()
|
||||
var amSatActive: Set<Int> = emptySet()
|
||||
override val amSatListsVersion: StateFlow<Int> = MutableStateFlow(0)
|
||||
|
||||
override fun getAmSatFmCatnums(): Set<Int> = amSatFm
|
||||
override fun getAmSatLinearCatnums(): Set<Int> = amSatLinear
|
||||
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
|
||||
|
||||
|
||||
+113
-7
@@ -134,6 +134,35 @@ class SelectionRepoSearchTest {
|
||||
assertEquals(listOf(43803), results.map { it.catnum })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `SSTV virtual type uses the amateur-only radio query`() = runTest {
|
||||
// The repo delegates to getIdsWithModesAndAmateur: the fake returns
|
||||
// only catnums the DAO would have filtered to Amateur service (43803).
|
||||
val repo = createRepo(
|
||||
items = sampleItems,
|
||||
sstvIds = listOf(43803),
|
||||
amSatActive = setOf(43803)
|
||||
)
|
||||
repo.setTypes(listOf("Live SSTV"))
|
||||
val results = repo.getEntriesFlow().first()
|
||||
assertEquals(listOf(43803), results.map { it.catnum })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `SSTV virtual type excludes rocket-body debris by name`() = runTest {
|
||||
// 60239 is "ARIANE 6 R/B" (launcher debris carrying an amateur SSTV
|
||||
// payload); it must be dropped even though its radio is Amateur.
|
||||
val items = sampleItems + SatItem(catnum = 60239, name = "ARIANE 6 R/B")
|
||||
val repo = createRepo(
|
||||
items = items,
|
||||
sstvIds = listOf(43803, 60239),
|
||||
amSatActive = setOf(43803, 60239)
|
||||
)
|
||||
repo.setTypes(listOf("Live SSTV"))
|
||||
val results = repo.getEntriesFlow().first()
|
||||
assertEquals(listOf(43803), results.map { it.catnum })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `multiple virtual types union their satellites`() = runTest {
|
||||
val repo = createRepo(
|
||||
@@ -188,16 +217,79 @@ 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())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `amsat list version bump re-resolves FM filter after data sync`() = runTest {
|
||||
// ISS module aliases: dirty pre-sync FM list contains both ZARYA and DESTINY.
|
||||
val items = listOf(
|
||||
SatItem(25544, "ISS (ZARYA)"),
|
||||
SatItem(26700, "ISS (DESTINY)")
|
||||
)
|
||||
val fake = FakeSettingsRepoForSearch(amSatFm = setOf(25544, 26700))
|
||||
val repo = SelectionRepo(
|
||||
dispatcher = Dispatchers.Unconfined,
|
||||
localSource = FakeLocalSourceForSearch(items),
|
||||
settingsRepo = fake
|
||||
)
|
||||
repo.setTypes(listOf("AMSAT Live FM"))
|
||||
assertEquals(setOf(25544, 26700), repo.getEntriesFlow().first().map { it.catnum }.toSet())
|
||||
|
||||
// Simulate a background data sync: lists rewritten + version bumped.
|
||||
fake.setAmSatCatnums(fmCatnums = setOf(25544), linearCatnums = emptySet())
|
||||
|
||||
// The FM filter must reflect the new list WITHOUT a type toggle or restart.
|
||||
assertEquals(listOf(25544), repo.getEntriesFlow().first().map { it.catnum })
|
||||
}
|
||||
|
||||
private fun createRepo(
|
||||
items: List<SatItem>,
|
||||
amSatFm: Set<Int> = emptySet(),
|
||||
amSatLinear: Set<Int> = emptySet(),
|
||||
sstvIds: List<Int> = emptyList()
|
||||
sstvIds: List<Int> = emptyList(),
|
||||
amSatActive: Set<Int> = emptySet()
|
||||
): ISelectionRepo {
|
||||
return SelectionRepo(
|
||||
dispatcher = Dispatchers.Unconfined,
|
||||
localSource = FakeLocalSourceForSearch(items, sstvIds),
|
||||
settingsRepo = FakeSettingsRepoForSearch(amSatFm, amSatLinear)
|
||||
settingsRepo = FakeSettingsRepoForSearch(amSatFm, amSatLinear, amSatActive)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -213,6 +305,7 @@ private class FakeLocalSourceForSearch(
|
||||
override suspend fun deleteEntries() = Unit
|
||||
override suspend fun getIdsWithModes(modes: List<String>): List<Int> = sstvIds
|
||||
override suspend fun getIdsWithModesAndUplink(modes: List<String>): List<Int> = sstvIds
|
||||
override suspend fun getIdsWithModesAndAmateur(modes: List<String>): List<Int> = sstvIds
|
||||
override suspend fun getRadiosTotal(): Int = 0
|
||||
override suspend fun getRadiosWithId(id: Int): List<SatRadio> = emptyList()
|
||||
override suspend fun insertRadios(radios: List<SatRadio>, isCustom: Boolean) = Unit
|
||||
@@ -221,9 +314,11 @@ private class FakeLocalSourceForSearch(
|
||||
}
|
||||
|
||||
private class FakeSettingsRepoForSearch(
|
||||
private val amSatFm: Set<Int> = emptySet(),
|
||||
private val amSatLinear: Set<Int> = emptySet()
|
||||
var amSatFm: Set<Int> = emptySet(),
|
||||
var amSatLinear: Set<Int> = emptySet(),
|
||||
private var amSatActive: Set<Int> = emptySet()
|
||||
) : ISettingsRepo {
|
||||
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())
|
||||
@@ -258,8 +353,12 @@ 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
|
||||
@@ -267,7 +366,14 @@ private class FakeSettingsRepoForSearch(
|
||||
override fun updateDataSourcesStatus(status: Map<String, Int>) = Unit
|
||||
override fun getAmSatFmCatnums(): Set<Int> = amSatFm
|
||||
override fun getAmSatLinearCatnums(): Set<Int> = amSatLinear
|
||||
override fun setAmSatCatnums(fmCatnums: Set<Int>, linearCatnums: Set<Int>) = Unit
|
||||
override val amSatListsVersion: StateFlow<Int> = MutableStateFlow(0)
|
||||
override fun setAmSatCatnums(fmCatnums: Set<Int>, linearCatnums: Set<Int>) {
|
||||
amSatFm = fmCatnums
|
||||
amSatLinear = linearCatnums
|
||||
(amSatListsVersion as MutableStateFlow<Int>).value++
|
||||
}
|
||||
override fun getAmSatActiveCatnums(): Set<Int> = amSatActive
|
||||
override fun setAmSatActiveCatnums(catnums: Set<Int>) { amSatActive = catnums }
|
||||
override fun updateRadioControlSettings(settings: RadioControlSettings) = Unit
|
||||
override fun getSatelliteOffset(catnum: Int): String = ""
|
||||
override fun setSatelliteOffset(catnum: Int, offset: String) = Unit
|
||||
|
||||
@@ -33,6 +33,10 @@ data class SatRadio(
|
||||
@SerialName("uplink_mode") val uplinkMode: String?,
|
||||
@SerialName("invert") val isInverted: Boolean,
|
||||
@SerialName("norad_cat_id") val catnum: Int?,
|
||||
/** Service class from the transceiver source ("Amateur"/"Unknown" etc.).
|
||||
* Used by the SSTV virtual filter to tell amateur satellites apart from
|
||||
* debris / weather birds (TIROS) / launcher stages (Ariane 6 R/B). */
|
||||
@SerialName("service") val service: String? = null,
|
||||
/** Set for manually imported transceivers, which remote updates must not replace. */
|
||||
val isCustom: Boolean = false
|
||||
)
|
||||
+5
@@ -75,4 +75,9 @@ interface ISatelliteRepo {
|
||||
* (real transponders), excluding downlink-only beacons/telemetry that share
|
||||
* the same mode label. Used by the FM/Linear fallback filter. */
|
||||
suspend fun getSatelliteIdsWithModesAndUplink(modes: List<String>): List<Int>
|
||||
|
||||
/** Like [getSatelliteIdsWithModes] but only matches records whose service
|
||||
* class is "Amateur", so SSTV never matches weather birds (TIROS),
|
||||
* launcher debris or other non-amateur transmitters. */
|
||||
suspend fun getSatelliteIdsWithModesAndAmateur(modes: List<String>): List<Int>
|
||||
}
|
||||
+9
@@ -85,6 +85,15 @@ interface ISettingsRepo {
|
||||
/** 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>)
|
||||
/** Monotonic version counter, incremented every time the AMSAT live
|
||||
* FM/Linear lists are rewritten. Consumers that resolve the virtual
|
||||
* types (SelectionRepo) should re-resolve on change so a background
|
||||
* data sync is reflected in the UI without a restart. */
|
||||
val amSatListsVersion: StateFlow<Int>
|
||||
//endregion
|
||||
|
||||
//region # Radio control settings
|
||||
|
||||
@@ -30,6 +30,7 @@ interface ILocalSource {
|
||||
suspend fun deleteEntries()
|
||||
suspend fun getIdsWithModes(modes: List<String>): List<Int>
|
||||
suspend fun getIdsWithModesAndUplink(modes: List<String>): List<Int>
|
||||
suspend fun getIdsWithModesAndAmateur(modes: List<String>): List<Int>
|
||||
suspend fun getRadiosTotal(): Int
|
||||
suspend fun getRadiosWithId(id: Int): List<SatRadio>
|
||||
suspend fun insertRadios(radios: List<SatRadio>, isCustom: Boolean = false)
|
||||
|
||||
@@ -63,6 +63,20 @@ object Sources {
|
||||
"Linear" to "https://www.amsat.org/live-linear-satellites/"
|
||||
)
|
||||
|
||||
/** 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"
|
||||
)
|
||||
|
||||
/** 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
|
||||
|
||||
@@ -40,6 +40,21 @@ 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)
|
||||
@@ -131,14 +146,25 @@ class DataParser(private val dispatcher: CoroutineDispatcher) {
|
||||
/** 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). */
|
||||
* 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 localTokens = localName.uppercase()
|
||||
val localUpper = localName.uppercase()
|
||||
val localTokens = localUpper
|
||||
.split(Regex("[\\s()\\[\\]/]+"))
|
||||
.filter { it.isNotBlank() }
|
||||
.toSet()
|
||||
return amSatKeys.any { key ->
|
||||
key.uppercase() in localTokens
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -283,6 +283,11 @@ class DataParserTest {
|
||||
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
|
||||
|
||||
+3
@@ -285,7 +285,10 @@ class LoTWGridSyncTest {
|
||||
override fun updateDataSourcesStatus(status: Map<String, Int>) = TODO()
|
||||
override fun getAmSatFmCatnums(): Set<Int> = emptySet()
|
||||
override fun getAmSatLinearCatnums(): Set<Int> = emptySet()
|
||||
override val amSatListsVersion: StateFlow<Int> = MutableStateFlow(0)
|
||||
override fun setAmSatCatnums(fmCatnums: Set<Int>, linearCatnums: Set<Int>) = Unit
|
||||
override fun getAmSatActiveCatnums(): Set<Int> = emptySet()
|
||||
override fun setAmSatActiveCatnums(catnums: Set<Int>) = Unit
|
||||
override fun updateRadioControlSettings(settings: RadioControlSettings) = TODO()
|
||||
override fun getSatelliteOffset(catnum: Int): String = ""
|
||||
override fun setSatelliteOffset(catnum: Int, offset: String) = TODO()
|
||||
|
||||
@@ -46,6 +46,7 @@ class FakeSatelliteRepo(
|
||||
override suspend fun getRadiosWithId(id: Int): List<SatRadio> = TODO()
|
||||
override suspend fun getSatelliteIdsWithModes(modes: List<String>): List<Int> = emptyList()
|
||||
override suspend fun getSatelliteIdsWithModesAndUplink(modes: List<String>): List<Int> = emptyList()
|
||||
override suspend fun getSatelliteIdsWithModesAndAmateur(modes: List<String>): List<Int> = emptyList()
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -111,7 +112,10 @@ class FakeSettingsRepo(
|
||||
|
||||
override fun getAmSatFmCatnums(): Set<Int> = amSatFm
|
||||
override fun getAmSatLinearCatnums(): Set<Int> = amSatLinear
|
||||
override val amSatListsVersion: StateFlow<Int> = MutableStateFlow(0)
|
||||
override fun setAmSatCatnums(fmCatnums: Set<Int>, linearCatnums: Set<Int>) = TODO()
|
||||
override fun getAmSatActiveCatnums(): Set<Int> = emptySet()
|
||||
override fun setAmSatActiveCatnums(catnums: Set<Int>) = TODO()
|
||||
|
||||
override fun updateRadioControlSettings(settings: RadioControlSettings) = TODO()
|
||||
override fun getSatelliteOffset(catnum: Int): String = ""
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
[versions]
|
||||
#noinspection UnusedVersionCatalogEntry
|
||||
appVersionCode = "499"
|
||||
appVersionCode = "504"
|
||||
#noinspection UnusedVersionCatalogEntry
|
||||
appVersionName = "4.4.7-ba7opf.15.2"
|
||||
appVersionName = "4.4.7-ba7opf.15.7"
|
||||
#noinspection UnusedVersionCatalogEntry
|
||||
compileSdk = "37"
|
||||
#noinspection UnusedVersionCatalogEntry
|
||||
|
||||
Reference in new issue
Block a user