Compare commits

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

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

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

Tests: remote update persists All ids; All type resolves to persisted
CelesTrak active ids; getTypesList excludes only Other.
2026-09-24 10:48:22 +08:00
atsunatsu 42bd55e180 build: bump version to 4.4.7-ba7opf.15.5 (502) 2026-09-24 09:40:56 +08:00
atsunatsu 7401e1451b fix: re-resolve AMSAT virtual types after sync; clean stale FM lists
Two independent defects kept ISS (DESTINY) visible in the satellite-page
AMSAT Live FM filter even after installing the fix and syncing data:

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

2. Data-layer staleness (DatabaseRepo): when the AMSAT FM page fetch
   failed, the previous list was kept verbatim — including pre-whitelist
   era lists containing all five ISS module entries. The stale list is now
   re-disambiguated: previous catnums are looked up by local name and run
   through the same whitelist / smallest-catnum collapse, so ISS modules
   always collapse to the primary ZARYA (25544).
2026-09-24 09:40:42 +08:00
atsunatsu fa51fb2b44 build: bump version to 4.4.7-ba7opf.15.4 (501) 2026-09-24 09:17:55 +08:00
atsunatsu 75a251eb98 fix: keep last good AMSAT live lists on fetch failures; whitelist mirror
- Failed whitelist fetch (raw.githubusercontent.com unreachable on some
  networks) no longer wipes the persisted whitelist or admits every ISS
  module alias: fall back to the persisted whitelist, and only persist
  when a stream was actually fetched.
- Multi-match collapse without any whitelist now keeps the primary entry
  (smallest catnum, ISS ZARYA=25544) instead of every alias (DESTINY etc.).
- Failed FM/Linear page fetch keeps the previous lists instead of
  clearing the filters.
- Amateur whitelist gets a jsDelivr CDN mirror as fallback source.
2026-09-24 09:17:17 +08:00
atsunatsu 91b5269740 build: bump version to 4.4.7-ba7opf.15.3 (500) 2026-09-24 08:49:53 +08:00
atsunatsu 522cac25e8 fix: correct AMSAT live filter satellite lists
- Resolve ISS station modules (ZARYA/UNITY/ZVEZDA/DESTINY/NAUKA) to the
  primary 25544 instead of whichever module appears first in the TLE source
- Match OSCAR designators to local names spelled 'OSCAR N'/'AMSAT-OSCAR N'
  (AO-7 -> OSCAR 7) so Linear lists keep AO-7 on SatNOGS-sourced data
- SSTV virtual type now uses the transceiver service class (Amateur only)
  plus launcher-debris name exclusion, dropping retired weather birds
  (TIROS) and rocket bodies (ARIANE 6 R/B) while keeping ISS/CSS/TEIKYOSAT
- Persist the AMSAT active-frequency whitelist (palewire mirror) on sync;
  used only to disambiguate multi-match names, never as a hard filter
2026-09-24 08:49:53 +08:00
21 changed files with 500 additions and 49 deletions

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,
@@ -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() }
@@ -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
@@ -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
)
@@ -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>
}
@@ -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
@@ -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 = ""
+2 -2
View File
@@ -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