mirror of
https://github.com/atsunatsu/Look4Sat.git
synced 2026-10-02 19:17:21 +00:00
Compare commits
20
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
70ada0d608 | ||
|
|
317f6d877f | ||
|
|
865fa9c6f2 | ||
|
|
a082abb72c | ||
|
|
a2e41a0c1a | ||
|
|
e1daf70f11 | ||
|
|
ca9f93c1ed | ||
|
|
42bd55e180 | ||
|
|
7401e1451b | ||
|
|
fa51fb2b44 | ||
|
|
75a251eb98 | ||
|
|
91b5269740 | ||
|
|
522cac25e8 | ||
|
|
732412deb2 | ||
|
|
30000a913e | ||
|
|
e7754a4c9e | ||
|
|
cf1e2b99b4 | ||
|
|
b295500d14 | ||
|
|
0163c7b217 | ||
|
|
85a4b9cfa3 |
No files matched your search
@@ -45,9 +45,22 @@ interface Look4SatDao {
|
||||
@Query("DELETE FROM entries")
|
||||
suspend fun deleteEntries()
|
||||
|
||||
@Query("SELECT catnum FROM radios WHERE downlinkMode IN (:modes)")
|
||||
@Query("SELECT catnum FROM radios WHERE downlinkMode IN (:modes) AND isAlive = 1")
|
||||
suspend fun getIdsWithModes(modes: List<String>): List<Int>
|
||||
|
||||
/** Like [getIdsWithModes] but only matches transponder records with an uplink,
|
||||
* excluding downlink-only beacons/telemetry that share the same mode label
|
||||
* (e.g. CW beacons, FM voice-synthesis beacons) from FM/Linear filter fallback. */
|
||||
@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
|
||||
@@ -73,6 +74,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import okhttp3.OkHttpClient
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
class MainContainer(private val context: Context) : IMainContainer {
|
||||
|
||||
@@ -169,14 +171,23 @@ 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())
|
||||
}
|
||||
|
||||
private fun provideRemoteSource(): IRemoteSource {
|
||||
return RemoteSource(Dispatchers.IO, context.contentResolver, OkHttpClient.Builder().build())
|
||||
// amsat.org live pages are slow (7-10s+ from some networks); the
|
||||
// OkHttp defaults (10s read timeout) made every AMSAT Live update
|
||||
// time out, silently keeping stale FM/Linear lists forever.
|
||||
val client = OkHttpClient.Builder()
|
||||
.connectTimeout(20, TimeUnit.SECONDS)
|
||||
.readTimeout(30, TimeUnit.SECONDS)
|
||||
.writeTimeout(30, TimeUnit.SECONDS)
|
||||
.callTimeout(60, TimeUnit.SECONDS)
|
||||
.build()
|
||||
return RemoteSource(Dispatchers.IO, context.contentResolver, client)
|
||||
}
|
||||
|
||||
private fun provideSatelliteRepo(): ISatelliteRepo {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -65,6 +65,12 @@ class SatelliteRepo(
|
||||
|
||||
override suspend fun getRadiosWithId(id: Int) = localStorage.getRadiosWithId(id)
|
||||
|
||||
override suspend fun getSatelliteIdsWithModes(modes: List<String>) = localStorage.getIdsWithModes(modes)
|
||||
|
||||
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,
|
||||
|
||||
+45
-4
@@ -43,12 +43,15 @@ class SelectionRepo(
|
||||
|
||||
// Resolve type IDs once when types change, then filter items reactively.
|
||||
// The HashSet gives O(1) catnum lookups instead of O(n) with a List.
|
||||
// The three virtual types ("AMSAT Live FM", "AMSAT Live Linear", "Live SSTV")
|
||||
// are resolved from hardcoded FM/Linear catnum sets (Sources) or live
|
||||
// SSTV transponder data instead of SharedPreferences.
|
||||
private val itemsWithTypes = currentTypes.flatMapLatest { types: List<String> ->
|
||||
val catnumSet: Set<Int>? = if (types.isEmpty()) {
|
||||
null // null = no filtering
|
||||
} else {
|
||||
val ids = settingsRepo.getSatelliteTypesIds(types)
|
||||
if (ids.isEmpty()) null else ids.toHashSet()
|
||||
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 }
|
||||
@@ -61,8 +64,46 @@ class SelectionRepo(
|
||||
|
||||
override fun getCurrentTypes() = currentTypes.value
|
||||
|
||||
override fun getTypesList() = Sources.satelliteDataUrls.keys.sorted().toMutableList().apply {
|
||||
removeAt(0)
|
||||
/**
|
||||
* Resolves a list of type names to satellite catnums. The three virtual
|
||||
* transponder/activity types are resolved from hardcoded FM/Linear lists
|
||||
* (Sources.amSatFmCatnums / amSatLinearCatnums) or local SSTV transponder
|
||||
* data; all other types come from the per-type ID lists persisted by
|
||||
* [ISettingsRepo].
|
||||
*/
|
||||
private suspend fun resolveTypeIds(types: List<String>): List<Int> {
|
||||
val idsSet = mutableSetOf<Int>()
|
||||
types.forEach { type ->
|
||||
when (type) {
|
||||
Sources.virtualTypeNames[0] -> idsSet.addAll(Sources.amSatFmCatnums)
|
||||
Sources.virtualTypeNames[1] -> idsSet.addAll(Sources.amSatLinearCatnums)
|
||||
// Live SSTV: mode=SSTV transponder records whose service class
|
||||
// is "Amateur", excluding launcher debris / rocket bodies
|
||||
// (names ending in "R/B" or "DEB" — e.g. Ariane 6 R/B) that
|
||||
// carry an amateur payload transponder but are not satellites.
|
||||
Sources.virtualTypeNames[2] -> {
|
||||
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)))
|
||||
}
|
||||
}
|
||||
return idsSet.toList()
|
||||
}
|
||||
|
||||
override fun getTypesList() = buildList {
|
||||
// 三个转发器/活动虚拟类型排在最前.
|
||||
addAll(Sources.virtualTypeNames)
|
||||
// 所有真实 TLE 源类型均可选(含 "All" = CelesTrak active);只排除
|
||||
// "Other"(空 URL 占位,无数据)。不要用 removeAt(0) —— 字母序第一个
|
||||
// 是 "All"/"ARISS" 这类真实源,删掉会让用户勾不到它们。
|
||||
addAll(Sources.satelliteDataUrls.keys.sorted().filterNot { it == "Other" })
|
||||
}
|
||||
|
||||
override suspend fun getEntriesFlow() = withContext(dispatcher) {
|
||||
|
||||
@@ -495,7 +495,9 @@ class SettingsRepo(
|
||||
}
|
||||
|
||||
override fun setSatelliteTypeIds(type: String, ids: List<Int>) {
|
||||
if (type == "All") return
|
||||
// "All" is a real TLE source type (CelesTrak active group) whose ids
|
||||
// must be persisted like any other type — skipping it made the "All"
|
||||
// filter resolve to an empty set and show nothing.
|
||||
val typesString = ids.joinToString(separatorComma)
|
||||
preferences.edit { putString("type$type", typesString) }
|
||||
}
|
||||
|
||||
@@ -48,6 +48,8 @@ class LocalSource(private val look4SatDao: Look4SatDao) : ILocalSource {
|
||||
override suspend fun deleteEntries() = look4SatDao.deleteEntries()
|
||||
|
||||
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,
|
||||
@@ -83,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() }
|
||||
|
||||
+27
-1
@@ -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
|
||||
@@ -167,7 +190,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()
|
||||
|
||||
@@ -180,6 +204,8 @@ 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
|
||||
|
||||
|
||||
+169
-6
@@ -112,22 +112,180 @@ class SelectionRepoSearchTest {
|
||||
assertTrue(repo.getEntriesFlow().first().isEmpty())
|
||||
}
|
||||
|
||||
private fun createRepo(items: List<SatItem>): ISelectionRepo {
|
||||
@Test
|
||||
fun `FM virtual type shows only hardcoded FM satellites`() = runTest {
|
||||
val repo = createRepo(sampleItems)
|
||||
repo.setTypes(listOf("AMSAT Live FM"))
|
||||
val results = repo.getEntriesFlow().first()
|
||||
// Hardcoded FM list: SO-50 (27607), ISS ZARYA (25544), AO-123 (43137).
|
||||
// Only ISS ZARYA is present in the sample DB.
|
||||
assertEquals(listOf(25544), results.map { it.catnum })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Linear virtual type shows only hardcoded linear satellites`() = runTest {
|
||||
val repo = createRepo(sampleItems)
|
||||
repo.setTypes(listOf("AMSAT Live Linear"))
|
||||
val results = repo.getEntriesFlow().first()
|
||||
// Hardcoded linear list: RS-44 (44909), FO-29 (24278), AO-7 (7530),
|
||||
// AO-73 (39444), JO-97 (43803). Present in the sample DB: 7530, 39444, 43803.
|
||||
assertEquals(setOf(7530, 39444, 43803), results.map { it.catnum }.toSet())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `SSTV virtual type shows only satellites with SSTV radios`() = runTest {
|
||||
val repo = createRepo(
|
||||
items = sampleItems,
|
||||
sstvIds = listOf(43803)
|
||||
)
|
||||
repo.setTypes(listOf("Live SSTV"))
|
||||
val results = repo.getEntriesFlow().first()
|
||||
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)
|
||||
)
|
||||
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)
|
||||
)
|
||||
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(
|
||||
items = sampleItems,
|
||||
sstvIds = listOf(43803)
|
||||
)
|
||||
repo.setTypes(listOf("AMSAT Live FM", "Live SSTV"))
|
||||
val results = repo.getEntriesFlow().first()
|
||||
assertEquals(setOf(25544, 43803), results.map { it.catnum }.toSet())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `clearing types restores full list`() = runTest {
|
||||
val repo = createRepo(sampleItems)
|
||||
repo.setTypes(listOf("AMSAT Live FM"))
|
||||
assertTrue(repo.getEntriesFlow().first().isNotEmpty())
|
||||
repo.setTypes(emptyList())
|
||||
assertEquals(sampleItems.size, repo.getEntriesFlow().first().size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `virtual type with no matching satellites shows empty not everything`() = runTest {
|
||||
// 硬编码 FM 列表 (27607/25544/43137) 里没有样本库中的卫星时, 选虚拟
|
||||
// 类型应显示空列表, 而非全部卫星.
|
||||
val items = listOf(SatItem(catnum = 7530, name = "AO-7 (AMSAT-OSCAR 7)"))
|
||||
val repo = createRepo(items)
|
||||
repo.setTypes(listOf("AMSAT Live FM"))
|
||||
assertTrue(repo.getEntriesFlow().first().isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `virtual type combined with regular type unions both lists`() = runTest {
|
||||
val repo = createRepo(items = sampleItems)
|
||||
repo.setTypes(listOf("AMSAT Live FM", "AMSAT Live Linear"))
|
||||
val results = repo.getEntriesFlow().first()
|
||||
assertEquals(
|
||||
setOf(25544, 7530, 39444, 43803),
|
||||
results.map { it.catnum }.toSet()
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `types list has virtual transponder types first and keeps All`() {
|
||||
val repo = createRepo(sampleItems)
|
||||
val types = repo.getTypesList()
|
||||
assertTrue(types.size >= 4)
|
||||
assertEquals("AMSAT Live FM", types[0])
|
||||
assertEquals("AMSAT Live Linear", types[1])
|
||||
assertEquals("Live SSTV", types[2])
|
||||
// "All" must be kept in the list (first original type shown after virtual ones).
|
||||
assertTrue("All" in types)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `All type resolves to persisted CelesTrak active ids`() = runTest {
|
||||
// Regression: "All" is a real TLE source type (CelesTrak active). Its
|
||||
// ids are persisted on sync like any other type; selecting it must
|
||||
// show the synced satellites, NOT an empty list.
|
||||
val fake = FakeSettingsRepoForSearch()
|
||||
fake.setSatelliteTypeIds("All", sampleItems.map { it.catnum })
|
||||
val repo = SelectionRepo(
|
||||
dispatcher = Dispatchers.Unconfined,
|
||||
localSource = FakeLocalSourceForSearch(sampleItems),
|
||||
settingsRepo = fake
|
||||
)
|
||||
repo.setTypes(listOf("All"))
|
||||
val results = repo.getEntriesFlow().first()
|
||||
assertEquals(sampleItems.map { it.catnum }.toSet(), results.map { it.catnum }.toSet())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getTypesList excludes only the Other placeholder`() = runTest {
|
||||
val repo = createRepo(sampleItems)
|
||||
val types = repo.getTypesList()
|
||||
// All real TLE source types must be selectable, including "All"
|
||||
// (CelesTrak active) and "ARISS" (removed by the old removeAt(0)).
|
||||
assertTrue("All" in types)
|
||||
assertTrue("ARISS" in types)
|
||||
assertTrue("R4UAB" in types)
|
||||
assertTrue("SatNOGS" in types)
|
||||
assertTrue("Other" !in types)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `all types shows all items`() = runTest {
|
||||
val repo = createRepo(sampleItems)
|
||||
repo.setTypes(emptyList()) // "All" = no type filtering
|
||||
val results = repo.getEntriesFlow().first()
|
||||
assertEquals(sampleItems.size, results.size)
|
||||
assertEquals(sampleItems.map { it.catnum }.toSet(), results.map { it.catnum }.toSet())
|
||||
}
|
||||
|
||||
private fun createRepo(
|
||||
items: List<SatItem>,
|
||||
sstvIds: List<Int> = emptyList()
|
||||
): ISelectionRepo {
|
||||
return SelectionRepo(
|
||||
dispatcher = Dispatchers.Unconfined,
|
||||
localSource = FakeLocalSourceForSearch(items),
|
||||
localSource = FakeLocalSourceForSearch(items, sstvIds),
|
||||
settingsRepo = FakeSettingsRepoForSearch()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private class FakeLocalSourceForSearch(private val items: List<SatItem>) : ILocalSource {
|
||||
private class FakeLocalSourceForSearch(
|
||||
private val items: List<SatItem>,
|
||||
private val sstvIds: List<Int> = emptyList()
|
||||
) : ILocalSource {
|
||||
override suspend fun getEntriesTotal(): Int = items.size
|
||||
override suspend fun getEntriesList(): List<SatItem> = items
|
||||
override suspend fun getEntriesWithIds(ids: List<Int>): List<OrbitalObject> = emptyList()
|
||||
override suspend fun insertEntries(entries: List<OrbitalData>) = Unit
|
||||
override suspend fun deleteEntries() = Unit
|
||||
override suspend fun getIdsWithModes(modes: List<String>): List<Int> = emptyList()
|
||||
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
|
||||
@@ -136,6 +294,7 @@ private class FakeLocalSourceForSearch(private val items: List<SatItem>) : ILoca
|
||||
}
|
||||
|
||||
private class FakeSettingsRepoForSearch : ISettingsRepo {
|
||||
private val typeIds = mutableMapOf<String, List<Int>>()
|
||||
override val appVersionName: String = "test"
|
||||
override val selectedIds: StateFlow<List<Int>> = MutableStateFlow(emptyList())
|
||||
override val selectedTypes: StateFlow<List<String>> = MutableStateFlow(emptyList())
|
||||
@@ -170,8 +329,12 @@ private class FakeSettingsRepoForSearch : ISettingsRepo {
|
||||
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
|
||||
|
||||
@@ -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
|
||||
)
|
||||
+15
@@ -65,4 +65,19 @@ interface ISatelliteRepo {
|
||||
|
||||
/** Fetch radio transceivers for a satellite by its catalog number. */
|
||||
suspend fun getRadiosWithId(id: Int): List<SatRadio>
|
||||
|
||||
/** Satellite catnums whose radios carry any of the given downlink modes.
|
||||
* Empty modes -> empty result; used to filter the mutual-match satellite
|
||||
* set to transponder satellites (FM voice / linear). */
|
||||
suspend fun getSatelliteIdsWithModes(modes: List<String>): List<Int>
|
||||
|
||||
/** Like [getSatelliteIdsWithModes] but only matches records with an uplink
|
||||
* (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>
|
||||
}
|
||||
@@ -29,6 +29,8 @@ interface ILocalSource {
|
||||
suspend fun insertEntries(entries: List<OrbitalData>)
|
||||
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)
|
||||
|
||||
@@ -55,4 +55,25 @@ object Sources {
|
||||
"SatNOGS" to "https://db.satnogs.org/api/transmitters/?format=json&status=active",
|
||||
"R4UAB" to "https://r4uab.ru/transmitters.json"
|
||||
)
|
||||
/**
|
||||
* Hardcoded AMSAT Live FM satellites (NORAD catnums):
|
||||
* SO-50 (27607), ISS ZARYA (25544), AO-123 Fox-1D (43137).
|
||||
* Replaces the AMSAT live-page fetch: stable on any network, no
|
||||
* sync-time dependency, no stale-list or timeout failure modes.
|
||||
*/
|
||||
val amSatFmCatnums = setOf(27607, 25544, 43137)
|
||||
|
||||
/**
|
||||
* Hardcoded AMSAT Live linear (SSB/CW) satellites:
|
||||
* RS-44 (44909), FO-29 (24278), AO-7 (7530),
|
||||
* AO-73 FUNcube-1 (39444), JO-97 JY1SAT (43803).
|
||||
*/
|
||||
val amSatLinearCatnums = setOf(44909, 24278, 7530, 39444, 43803)
|
||||
|
||||
/** Virtual satellite-selection types: transponder/activity filters shown
|
||||
* at the top of the type picker. They resolve to live lists (hardcoded
|
||||
* FM/Linear catnum sets or mode=SSTV radios) instead of persisted
|
||||
* per-type IDs, and are mutually exclusive with the regular TLE-source
|
||||
* types in the picker. */
|
||||
val virtualTypeNames = listOf("AMSAT Live FM", "AMSAT Live Linear", "Live SSTV")
|
||||
}
|
||||
@@ -308,6 +308,9 @@
|
||||
<string name="mutual_grid_hint">支持 4/6/8 位 Maidenhead 网格;也可直接输入经纬度</string>
|
||||
<string name="mutual_min_elevation">最小仰角:%d°</string>
|
||||
<string name="mutual_time_range">时间范围</string>
|
||||
<string name="mutual_filter_transponder">转发器</string>
|
||||
<string name="mutual_filter_fm">FM</string>
|
||||
<string name="mutual_filter_linear">线性</string>
|
||||
<string name="mutual_calculating">计算中…</string>
|
||||
<string name="mutual_query">查询匹配</string>
|
||||
<string name="mutual_elevation_you">你:%d°</string>
|
||||
|
||||
@@ -338,6 +338,9 @@
|
||||
<string name="mutual_grid_hint">Supports 4/6/8-char Maidenhead grid; or enter lat/lon directly</string>
|
||||
<string name="mutual_min_elevation">Min elevation: %d°</string>
|
||||
<string name="mutual_time_range">Time range</string>
|
||||
<string name="mutual_filter_transponder">Transponders</string>
|
||||
<string name="mutual_filter_fm">FM</string>
|
||||
<string name="mutual_filter_linear">Linear</string>
|
||||
<string name="mutual_calculating">Calculating…</string>
|
||||
<string name="mutual_query">Query Match</string>
|
||||
<string name="mutual_elevation_you">You: %d°</string>
|
||||
|
||||
@@ -174,6 +174,8 @@ fun MutualScreen(
|
||||
onStationBMinElev = viewModel::onStationBMinElev,
|
||||
onUseCurrentPosition = viewModel::onUseCurrentPosition,
|
||||
onHoursAhead = viewModel::onHoursAhead,
|
||||
onFilterFM = viewModel::onFilterFM,
|
||||
onFilterLinear = viewModel::onFilterLinear,
|
||||
onClearError = viewModel::clearError
|
||||
)
|
||||
}
|
||||
@@ -197,6 +199,8 @@ private fun MutualContent(
|
||||
onStationBMinElev: (Double) -> Unit,
|
||||
onUseCurrentPosition: () -> Unit,
|
||||
onHoursAhead: (Int) -> Unit,
|
||||
onFilterFM: (Boolean) -> Unit,
|
||||
onFilterLinear: (Boolean) -> Unit,
|
||||
onClearError: () -> Unit
|
||||
) {
|
||||
val timeFormat = remember(state.isUtc) {
|
||||
@@ -353,6 +357,10 @@ private fun MutualContent(
|
||||
MatchSearchCard(
|
||||
hoursAhead = state.hoursAhead,
|
||||
isCalculating = state.isCalculating,
|
||||
filterFM = state.filterFM,
|
||||
filterLinear = state.filterLinear,
|
||||
onFilterFM = onFilterFM,
|
||||
onFilterLinear = onFilterLinear,
|
||||
onHoursAhead = onHoursAhead,
|
||||
onQuery = onQuery
|
||||
)
|
||||
@@ -570,6 +578,10 @@ private fun StationInputCard(
|
||||
private fun MatchSearchCard(
|
||||
hoursAhead: Int,
|
||||
isCalculating: Boolean,
|
||||
filterFM: Boolean,
|
||||
filterLinear: Boolean,
|
||||
onFilterFM: (Boolean) -> Unit,
|
||||
onFilterLinear: (Boolean) -> Unit,
|
||||
onHoursAhead: (Int) -> Unit,
|
||||
onQuery: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
@@ -596,6 +608,28 @@ private fun MatchSearchCard(
|
||||
)
|
||||
}
|
||||
}
|
||||
// 转发器类型筛选: 只匹配 AMSAT Live 清单里的 FM 话音 / 线性转发器卫星,
|
||||
// 防止选入大量无转发器卫星占用匹配页.
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.mutual_filter_transponder),
|
||||
style = MaterialTheme.typography.labelLarge
|
||||
)
|
||||
FilterChip(
|
||||
selected = filterFM,
|
||||
onClick = { onFilterFM(!filterFM) },
|
||||
label = { Text(stringResource(R.string.mutual_filter_fm)) }
|
||||
)
|
||||
FilterChip(
|
||||
selected = filterLinear,
|
||||
onClick = { onFilterLinear(!filterLinear) },
|
||||
label = { Text(stringResource(R.string.mutual_filter_linear)) }
|
||||
)
|
||||
}
|
||||
Button(
|
||||
onClick = onQuery,
|
||||
enabled = !isCalculating,
|
||||
|
||||
+32
-2
@@ -26,6 +26,7 @@ import com.rtbishop.look4sat.core.domain.predict.OrbitalPass
|
||||
import com.rtbishop.look4sat.core.domain.repository.IMainContainer
|
||||
import com.rtbishop.look4sat.core.domain.repository.ISatelliteRepo
|
||||
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
|
||||
import com.rtbishop.look4sat.core.domain.source.Sources
|
||||
import com.rtbishop.look4sat.core.domain.utility.positionToQth
|
||||
import com.rtbishop.look4sat.core.domain.utility.qthToPosition
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
@@ -55,6 +56,10 @@ data class MutualUiState(
|
||||
/** 历史过境回看窗口(小时), 跟随主页面 Passes 设置; 匹配查询从
|
||||
* now-hoursBefore 开始, 使设置过历史回看时匹配页也包含历史过境. */
|
||||
val hoursBefore: Int = 0,
|
||||
/** 转发器类型筛选: 只匹配搭载 FM 话音转发器的卫星. 默认开启. */
|
||||
val filterFM: Boolean = true,
|
||||
/** 转发器类型筛选: 只匹配搭载线性(USB/LSB/CW/SSB)转发器的卫星. 默认开启. */
|
||||
val filterLinear: Boolean = true,
|
||||
val mutualPasses: List<MutualPass> = emptyList(),
|
||||
val isCalculating: Boolean = false,
|
||||
val hasSearched: Boolean = false,
|
||||
@@ -201,6 +206,8 @@ class MutualViewModel(
|
||||
) }
|
||||
}
|
||||
fun onHoursAhead(value: Int) = _uiState.update { it.copy(hoursAhead = value) }
|
||||
fun onFilterFM(value: Boolean) = _uiState.update { it.copy(filterFM = value) }
|
||||
fun onFilterLinear(value: Boolean) = _uiState.update { it.copy(filterLinear = value) }
|
||||
fun onSelectPass(index: Int) = _uiState.update { it.copy(selectedPassIndex = index) }
|
||||
|
||||
/**
|
||||
@@ -264,6 +271,17 @@ class MutualViewModel(
|
||||
return
|
||||
}
|
||||
|
||||
// 转发器类型筛选: 至少勾选一种(默认 FM + Linear 都开), 否则提示不查询.
|
||||
// 防止用户选入大量无转发器卫星占用匹配页.
|
||||
if (!state.filterFM && !state.filterLinear) {
|
||||
_uiState.update { it.copy(errorMessage = "Select at least one transponder type (FM / Linear) to run the match.") }
|
||||
return
|
||||
}
|
||||
// AMSAT Live 清单(同步自 AMSAT "Live FM/Linear Satellites" 页面)是本过滤的
|
||||
// 权威来源: 只匹配当前在轨且话音/线性转发器实际工作的卫星. 清单为空
|
||||
// (从未成功同步过)时回退到数据库 downlinkMode 过滤, 避免首启匹配页空结果.
|
||||
// 过滤本身在下面的协程里执行 (getSatelliteIdsWithModes 是 suspend).
|
||||
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isCalculating = true,
|
||||
@@ -286,6 +304,15 @@ class MutualViewModel(
|
||||
|
||||
viewModelScope.launch {
|
||||
val now = System.currentTimeMillis()
|
||||
// 转发器类型筛选: 使用硬编码 AMSAT Live 清单 (见 Sources):
|
||||
// FM = {SO-50, ISS ZARYA, AO-123}, Linear = {RS-44, FO-29,
|
||||
// AO-7, AO-73, JO-97}. 不再依赖 AMSAT 网页同步: 任何网络下都
|
||||
// 稳定, 没有旧脏列表/超时/同步失败清空等问题.
|
||||
val allowed = buildSet {
|
||||
if (state.filterFM) addAll(Sources.amSatFmCatnums)
|
||||
if (state.filterLinear) addAll(Sources.amSatLinearCatnums)
|
||||
}
|
||||
val filteredSatellites = satellites.filter { it.data.catnum in allowed }
|
||||
// 历史回看: 查询窗口起点 = now - hoursBefore (跟随主页面 Passes 设置),
|
||||
// 终点 = now + hoursAhead 不变.
|
||||
val time = now - state.hoursBefore * 60L * 60L * 1000L
|
||||
@@ -295,7 +322,7 @@ class MutualViewModel(
|
||||
|
||||
val results = try {
|
||||
withContext(computeDispatcher) {
|
||||
findMutualPasses(satellites, posA, posB, minElevA, minElevB, time, hours, state.hoursBefore)
|
||||
findMutualPasses(filteredSatellites, posA, posB, minElevA, minElevB, time, hours, state.hoursBefore)
|
||||
}
|
||||
} catch (t: Throwable) {
|
||||
// Never leave the query stuck in "calculating" (which would also
|
||||
@@ -350,7 +377,10 @@ class MutualViewModel(
|
||||
// Use the main page's pass list for AOS/LOS times, then sample the curves
|
||||
// using the actual positions. The passes list is already computed by getLeoPass
|
||||
// and its AOS/LOS times match the Passes page exactly.
|
||||
val existingPasses = satelliteRepo.passes.value
|
||||
// The satellites argument may be a subset (transponder-type filter), so
|
||||
// only passes belonging to the filtered satellites are reused.
|
||||
val allowedCatnums = satellites.map { it.data.catnum }.toSet()
|
||||
val existingPasses = satelliteRepo.passes.value.filter { it.catNum in allowedCatnums }
|
||||
val results = findMutualPassesFromList(existingPasses, satellites, posA, posB,
|
||||
minElevADeg, minElevBDeg, time, endTime, sampleInterval)
|
||||
if (results.isNotEmpty()) return results
|
||||
|
||||
@@ -44,13 +44,18 @@ class FakeSatelliteRepo(
|
||||
override suspend fun getTrack(sat: OrbitalObject, pos: GeoPos, start: Long, end: Long): List<OrbitalPos> = TODO()
|
||||
override suspend fun getRadios(sat: OrbitalObject, pos: GeoPos, radios: List<SatRadio>, time: Long): List<SatRadio> = TODO()
|
||||
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()
|
||||
}
|
||||
|
||||
/**
|
||||
* Test fake for ISettingsRepo. Only [stationPosition] is backed by mutable
|
||||
* state; everything else the ViewModel never touches fails loudly.
|
||||
*/
|
||||
class FakeSettingsRepo(initialPosition: GeoPos = GeoPos(23.13, 113.26)) : ISettingsRepo {
|
||||
class FakeSettingsRepo(
|
||||
initialPosition: GeoPos = GeoPos(23.13, 113.26)
|
||||
) : ISettingsRepo {
|
||||
|
||||
override val stationPosition = MutableStateFlow(initialPosition)
|
||||
|
||||
|
||||
+62
@@ -320,4 +320,66 @@ class MutualViewModelTest {
|
||||
assertEquals(4, vm.listScrollIndex)
|
||||
assertEquals(137, vm.listScrollOffset)
|
||||
}
|
||||
|
||||
// --------------------------------------------- transponder filters (AMSAT)
|
||||
|
||||
@Test
|
||||
fun `both filters off sets an error and does not search`() = runTest(mainDispatcherRule.dispatcher.scheduler) {
|
||||
val windows = TestOrbits.findPassWindows()
|
||||
val vm = createVm(satellites = listOf(TestOrbits.ISS), passes = windows)
|
||||
vm.onStationBGrid("OL62")
|
||||
vm.onFilterFM(false)
|
||||
vm.onFilterLinear(false)
|
||||
|
||||
queryAndSettle(vm)
|
||||
|
||||
assertEquals(
|
||||
"Select at least one transponder type (FM / Linear) to run the match.",
|
||||
vm.uiState.value.errorMessage
|
||||
)
|
||||
assertFalse(vm.uiState.value.hasSearched)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `hardcoded AMSAT filter restricts results to listed catnums`() = runTest(mainDispatcherRule.dispatcher.scheduler) {
|
||||
val windows = TestOrbits.findPassWindows(hoursAhead = 24) +
|
||||
TestOrbits.findPassWindows(sat = TestOrbits.ISS_VARIANT, hoursAhead = 24)
|
||||
assertTrue("fixture must produce windows", windows.isNotEmpty())
|
||||
// ISS (25544) is on the hardcoded FM list (Sources.amSatFmCatnums);
|
||||
// ISS_VARIANT (25545) is not.
|
||||
val vm = createVm(
|
||||
satellites = listOf(TestOrbits.ISS, TestOrbits.ISS_VARIANT),
|
||||
passes = windows
|
||||
)
|
||||
vm.onStationBGrid("OL62")
|
||||
vm.onHoursAhead(24)
|
||||
|
||||
queryAndSettle(vm)
|
||||
|
||||
val state = vm.uiState.value
|
||||
assertNull(state.errorMessage)
|
||||
assertTrue(state.mutualPasses.isNotEmpty())
|
||||
assertTrue("only hardcoded FM-listed satellites should appear", state.mutualPasses.all { it.catNum == 25544 })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `turning FM off excludes FM-listed satellites under Linear filter`() = runTest(mainDispatcherRule.dispatcher.scheduler) {
|
||||
val windows = TestOrbits.findPassWindows(hoursAhead = 24) +
|
||||
TestOrbits.findPassWindows(sat = TestOrbits.ISS_VARIANT, hoursAhead = 24)
|
||||
// ISS (25544) is on the hardcoded FM list but NOT on the hardcoded
|
||||
// Linear list; with FM off and Linear on, no satellite passes.
|
||||
val vm = createVm(
|
||||
satellites = listOf(TestOrbits.ISS, TestOrbits.ISS_VARIANT),
|
||||
passes = windows
|
||||
)
|
||||
vm.onStationBGrid("OL62")
|
||||
vm.onHoursAhead(24)
|
||||
vm.onFilterFM(false)
|
||||
|
||||
queryAndSettle(vm)
|
||||
|
||||
val state = vm.uiState.value
|
||||
// 空结果会设置 "No mutual passes found" 提示, 这是正常行为而非筛选错误.
|
||||
assertTrue(state.mutualPasses.isEmpty())
|
||||
}
|
||||
}
|
||||
+13
-1
@@ -42,6 +42,7 @@ import androidx.compose.ui.unit.dp
|
||||
import com.rtbishop.look4sat.core.presentation.MainTheme
|
||||
import com.rtbishop.look4sat.core.presentation.R
|
||||
import com.rtbishop.look4sat.core.presentation.SharedDialog
|
||||
import com.rtbishop.look4sat.core.domain.source.Sources
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Composable
|
||||
@@ -54,9 +55,20 @@ private fun MultiTypesDialogPreview() {
|
||||
internal fun MultiTypesDialog(
|
||||
allTypes: List<String>, types: List<String>, cancel: () -> Unit, accept: (List<String>) -> Unit
|
||||
) {
|
||||
// 虚拟类型(转发器/活动筛选)与普通 TLE 源类型互斥: 勾虚拟类型时取消普通类型,
|
||||
// 勾普通类型时取消虚拟类型. 虚拟类型之间可多选组合.
|
||||
val virtualTypes = Sources.virtualTypeNames
|
||||
val selected = remember { mutableStateOf(types.toSet()) }
|
||||
val toggle = { type: String ->
|
||||
selected.value = if (type in selected.value) selected.value - type else selected.value + type
|
||||
selected.value = if (type in selected.value) {
|
||||
selected.value - type
|
||||
} else if (type in virtualTypes) {
|
||||
// 勾选虚拟类型: 移除所有普通类型, 保留已勾的虚拟类型.
|
||||
(selected.value.filterTo(mutableSetOf()) { it in virtualTypes }) + type
|
||||
} else {
|
||||
// 勾选普通类型: 移除所有虚拟类型, 保留已勾的普通类型.
|
||||
(selected.value.filterTo(mutableSetOf()) { it !in virtualTypes }) + type
|
||||
}
|
||||
}
|
||||
val onAccept = { accept(selected.value.toList()) }
|
||||
SharedDialog(title = stringResource(R.string.sat_type_title), onCancel = cancel, onAccept = onAccept) {
|
||||
|
||||
+1
@@ -177,6 +177,7 @@ private fun SatellitesScreen(
|
||||
)
|
||||
}
|
||||
}
|
||||
// 转发器/活动筛选已在"卫星类型"对话框前三个选项中体现(AMSAT Live FM/Linear, Live SSTV).
|
||||
ElevatedCard(modifier = Modifier.fillMaxSize()) {
|
||||
val emptyMessage = stringResource(R.string.sat_empty_list_message)
|
||||
when {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
[versions]
|
||||
#noinspection UnusedVersionCatalogEntry
|
||||
appVersionCode = "496"
|
||||
appVersionCode = "506"
|
||||
#noinspection UnusedVersionCatalogEntry
|
||||
appVersionName = "4.4.7-ba7opf.14.2"
|
||||
appVersionName = "4.4.7-ba7opf.15.9"
|
||||
#noinspection UnusedVersionCatalogEntry
|
||||
compileSdk = "37"
|
||||
#noinspection UnusedVersionCatalogEntry
|
||||
|
||||
Reference in new issue
Block a user