mirror of
https://github.com/atsunatsu/Look4Sat.git
synced 2026-10-02 03:15:37 +00:00
Compare commits
24
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
70ada0d608 | ||
|
|
317f6d877f | ||
|
|
865fa9c6f2 | ||
|
|
a082abb72c | ||
|
|
a2e41a0c1a | ||
|
|
e1daf70f11 | ||
|
|
ca9f93c1ed | ||
|
|
42bd55e180 | ||
|
|
7401e1451b | ||
|
|
fa51fb2b44 | ||
|
|
75a251eb98 | ||
|
|
91b5269740 | ||
|
|
522cac25e8 | ||
|
|
732412deb2 | ||
|
|
30000a913e | ||
|
|
e7754a4c9e | ||
|
|
cf1e2b99b4 | ||
|
|
b295500d14 | ||
|
|
0163c7b217 | ||
|
|
85a4b9cfa3 | ||
|
|
c4c4dd682d | ||
|
|
62ad995aeb | ||
|
|
c8fdd2e93e | ||
|
|
dc013f920d |
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) {
|
||||
|
||||
+36
-18
@@ -270,22 +270,35 @@ class SettingsRepo(
|
||||
}
|
||||
|
||||
// Marked stations in unworked gridsquares, persisted as a single JSON
|
||||
// object: {"OL62":{"c":call,"t":epochMs}}. One mark per grid.
|
||||
// object: {"OL62":[{"c":call,"t":epochMs}, ...]}. Each grid holds an
|
||||
// ordered list (first = pinned/top). v14.1 stored a single object per
|
||||
// grid; reads transparently migrate that old shape to a one-element list.
|
||||
private val keyMarkedGridStations = "markedGridStations"
|
||||
|
||||
override fun getMarkedGridStations(): Map<String, com.rtbishop.look4sat.core.domain.model.MarkedStation> {
|
||||
override fun getMarkedGridStations(): Map<String, List<com.rtbishop.look4sat.core.domain.model.MarkedStation>> {
|
||||
val json = preferences.getString(keyMarkedGridStations, null).orEmpty()
|
||||
if (json.isBlank()) return emptyMap()
|
||||
return try {
|
||||
val root = org.json.JSONObject(json)
|
||||
val result = mutableMapOf<String, com.rtbishop.look4sat.core.domain.model.MarkedStation>()
|
||||
val result = mutableMapOf<String, List<com.rtbishop.look4sat.core.domain.model.MarkedStation>>()
|
||||
for (grid in root.keys()) {
|
||||
val o = root.optJSONObject(grid) ?: continue
|
||||
val call = o.optString("c").ifBlank { continue }
|
||||
result[grid] = com.rtbishop.look4sat.core.domain.model.MarkedStation(
|
||||
call = call,
|
||||
epochMs = o.optLong("t")
|
||||
)
|
||||
val value = root.opt(grid)
|
||||
val list = when (value) {
|
||||
// v14.2+ shape: JSON array of station objects.
|
||||
is org.json.JSONArray -> (0 until value.length()).mapNotNull { i ->
|
||||
val o = value.optJSONObject(i) ?: return@mapNotNull null
|
||||
val call = o.optString("c").ifBlank { return@mapNotNull null }
|
||||
com.rtbishop.look4sat.core.domain.model.MarkedStation(call = call, epochMs = o.optLong("t"))
|
||||
}
|
||||
// v14.1 legacy shape: a single station object per grid.
|
||||
is org.json.JSONObject -> {
|
||||
val call = value.optString("c").ifBlank { null }
|
||||
if (call == null) emptyList()
|
||||
else listOf(com.rtbishop.look4sat.core.domain.model.MarkedStation(call = call, epochMs = value.optLong("t")))
|
||||
}
|
||||
else -> emptyList()
|
||||
}
|
||||
if (list.isNotEmpty()) result[grid] = list
|
||||
}
|
||||
result
|
||||
} catch (_: Exception) {
|
||||
@@ -293,15 +306,18 @@ class SettingsRepo(
|
||||
}
|
||||
}
|
||||
|
||||
override fun setMarkedGridStations(stations: Map<String, com.rtbishop.look4sat.core.domain.model.MarkedStation>) {
|
||||
override fun setMarkedGridStations(stations: Map<String, List<com.rtbishop.look4sat.core.domain.model.MarkedStation>>) {
|
||||
val root = org.json.JSONObject()
|
||||
for ((grid, station) in stations) {
|
||||
root.put(
|
||||
grid,
|
||||
org.json.JSONObject()
|
||||
.put("c", station.call)
|
||||
.put("t", station.epochMs)
|
||||
)
|
||||
for ((grid, list) in stations) {
|
||||
val array = org.json.JSONArray()
|
||||
for (station in list) {
|
||||
array.put(
|
||||
org.json.JSONObject()
|
||||
.put("c", station.call)
|
||||
.put("t", station.epochMs)
|
||||
)
|
||||
}
|
||||
root.put(grid, array)
|
||||
}
|
||||
preferences.edit { putString(keyMarkedGridStations, root.toString()) }
|
||||
}
|
||||
@@ -479,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() }
|
||||
|
||||
+29
-3
@@ -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
|
||||
|
||||
@@ -313,9 +339,9 @@ private class FakeSettingsRepo(dataSources: DataSourcesSettings = defaultDataSou
|
||||
roamedGrids.value = grids
|
||||
}
|
||||
|
||||
override fun getMarkedGridStations(): Map<String, com.rtbishop.look4sat.core.domain.model.MarkedStation> = emptyMap()
|
||||
override fun getMarkedGridStations(): Map<String, List<com.rtbishop.look4sat.core.domain.model.MarkedStation>> = emptyMap()
|
||||
|
||||
override fun setMarkedGridStations(stations: Map<String, com.rtbishop.look4sat.core.domain.model.MarkedStation>) = Unit
|
||||
override fun setMarkedGridStations(stations: Map<String, List<com.rtbishop.look4sat.core.domain.model.MarkedStation>>) = Unit
|
||||
}
|
||||
|
||||
private fun defaultDataSourcesSettings(): DataSourcesSettings {
|
||||
|
||||
+171
-8
@@ -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
|
||||
@@ -189,8 +352,8 @@ private class FakeSettingsRepoForSearch : ISettingsRepo {
|
||||
override fun setWorkedGridQsos(qsos: Map<String, List<com.rtbishop.look4sat.core.domain.model.GridQso>>) = Unit
|
||||
override fun getRoamedGrids(): Set<String> = emptySet()
|
||||
override fun setRoamedGrids(grids: Set<String>) = Unit
|
||||
override fun getMarkedGridStations(): Map<String, com.rtbishop.look4sat.core.domain.model.MarkedStation> = emptyMap()
|
||||
override fun setMarkedGridStations(stations: Map<String, com.rtbishop.look4sat.core.domain.model.MarkedStation>) = Unit
|
||||
override fun getMarkedGridStations(): Map<String, List<com.rtbishop.look4sat.core.domain.model.MarkedStation>> = emptyMap()
|
||||
override fun setMarkedGridStations(stations: Map<String, List<com.rtbishop.look4sat.core.domain.model.MarkedStation>>) = Unit
|
||||
override fun updateLoTWSettings(settings: com.rtbishop.look4sat.core.domain.model.LoTWSettings) = Unit
|
||||
override fun getLastLotwSyncDate(): String = ""
|
||||
override fun setLastLotwSyncDate(date: 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
|
||||
)
|
||||
+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>
|
||||
}
|
||||
+4
-4
@@ -106,10 +106,10 @@ interface ISettingsRepo {
|
||||
fun getRoamedGrids(): Set<String>
|
||||
fun setRoamedGrids(grids: Set<String>)
|
||||
/** Stations the user marked as "want to contact" in unworked gridsquares
|
||||
* (grid -> mark). One mark per grid; cleared automatically when the grid
|
||||
* becomes worked. */
|
||||
fun getMarkedGridStations(): Map<String, com.rtbishop.look4sat.core.domain.model.MarkedStation>
|
||||
fun setMarkedGridStations(stations: Map<String, com.rtbishop.look4sat.core.domain.model.MarkedStation>)
|
||||
* (grid -> ordered list of marks; the first is the pinned/top one). The
|
||||
* whole grid clears automatically once it becomes worked. */
|
||||
fun getMarkedGridStations(): Map<String, List<com.rtbishop.look4sat.core.domain.model.MarkedStation>>
|
||||
fun setMarkedGridStations(stations: Map<String, List<com.rtbishop.look4sat.core.domain.model.MarkedStation>>)
|
||||
//endregion
|
||||
|
||||
//region # LoTW confirmed-grids settings
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
+3
-3
@@ -262,9 +262,9 @@ class LoTWGridSyncTest {
|
||||
override fun setWorkedGridQsos(qsos: Map<String, List<GridQso>>) { workedGridQsos = qsos }
|
||||
override fun getRoamedGrids(): Set<String> = roamedGrids
|
||||
override fun setRoamedGrids(grids: Set<String>) { roamedGrids = grids }
|
||||
private var markedGridStations: Map<String, MarkedStation> = emptyMap()
|
||||
override fun getMarkedGridStations(): Map<String, MarkedStation> = markedGridStations
|
||||
override fun setMarkedGridStations(stations: Map<String, MarkedStation>) { markedGridStations = stations }
|
||||
private var markedGridStations: Map<String, List<MarkedStation>> = emptyMap()
|
||||
override fun getMarkedGridStations(): Map<String, List<MarkedStation>> = markedGridStations
|
||||
override fun setMarkedGridStations(stations: Map<String, List<MarkedStation>>) { markedGridStations = stations }
|
||||
override fun getLastLotwSyncDate(): String = lastSyncDate
|
||||
override fun setLastLotwSyncDate(date: String) { lastSyncDate = date }
|
||||
override fun getLastLotwSyncCallsign(): String = lastSyncCallsign
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<!-- 冖 (秃宝盖): 顶横 + 两侧短竖, 笔画型 -->
|
||||
<path
|
||||
android:pathData="M5,4 L19,4 M5,4 L5,8.5 M19,4 L19,8.5"
|
||||
android:strokeColor="@android:color/white"
|
||||
android:strokeWidth="2"
|
||||
android:strokeLineCap="round"
|
||||
android:fillColor="#00000000" />
|
||||
<!-- 笔画型向上箭头 ↑: 竖杆 + 两斜线箭头头 -->
|
||||
<path
|
||||
android:pathData="M12,9 L12,20 M12,9 L7.5,14 M12,9 L16.5,14"
|
||||
android:strokeColor="@android:color/white"
|
||||
android:strokeWidth="2"
|
||||
android:strokeLineCap="round"
|
||||
android:fillColor="#00000000" />
|
||||
</vector>
|
||||
@@ -137,6 +137,7 @@
|
||||
<string name="grid_mark_confirm">标记</string>
|
||||
<string name="grid_mark_label">标记</string>
|
||||
<string name="grid_mark_delete">删除标记</string>
|
||||
<string name="grid_mark_pin">置顶</string>
|
||||
<string name="map_visible">日照中</string>
|
||||
|
||||
<!-- Settings screen -->
|
||||
@@ -307,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>
|
||||
|
||||
@@ -157,6 +157,7 @@
|
||||
<string name="grid_mark_confirm">Mark</string>
|
||||
<string name="grid_mark_label">Marked</string>
|
||||
<string name="grid_mark_delete">Remove mark</string>
|
||||
<string name="grid_mark_pin">Pin to top</string>
|
||||
<string name="map_visible">Visible</string>
|
||||
|
||||
<!-- Settings screen -->
|
||||
@@ -337,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>
|
||||
|
||||
+5
-4
@@ -93,9 +93,10 @@ class MaidenheadGridOverlay : Overlay() {
|
||||
var workedGrids: Set<String> = emptySet()
|
||||
|
||||
/** Marked gridsquares (4-char, uppercase) — unworked grids the user has
|
||||
* marked a station they want to contact in; drawn red, same alpha as the
|
||||
* green worked fill. Value carries the marked callsign for label mode. */
|
||||
var markedGrids: Map<String, com.rtbishop.look4sat.core.domain.model.MarkedStation> = emptyMap()
|
||||
* marked stations they want to contact in; drawn red, same alpha as the
|
||||
* green worked fill. Value is the ordered mark list (first = pinned);
|
||||
* label mode shows the FIRST marked callsign. */
|
||||
var markedGrids: Map<String, List<com.rtbishop.look4sat.core.domain.model.MarkedStation>> = emptyMap()
|
||||
|
||||
/** Grid-mode first-call labels: label worked (green) cells with the first
|
||||
* callsign worked in that grid instead of the Maidenhead code; non-worked
|
||||
@@ -449,7 +450,7 @@ class MaidenheadGridOverlay : Overlay() {
|
||||
label in workedGrids -> firstCallsByGrid[label]?.let { call ->
|
||||
canvas.drawText(call, (xLeft + xRight) / 2f, yCenter, firstCallPaint)
|
||||
}
|
||||
label in markedGrids -> markedGrids[label]?.let { station ->
|
||||
label in markedGrids -> markedGrids[label]?.firstOrNull()?.let { station ->
|
||||
canvas.drawText(station.call, (xLeft + xRight) / 2f, yCenter, firstCallPaint)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -429,13 +429,14 @@ private fun MapScreen(
|
||||
WorkedGridQsoDialog(
|
||||
grid = grid,
|
||||
qsos = uiState.workedGridQsos[grid].orEmpty().sortedBy { it.epochMs },
|
||||
marked = uiState.markedGrids[grid],
|
||||
marked = uiState.markedGrids[grid].orEmpty(),
|
||||
isUtc = uiState.isUtc,
|
||||
matchCalculating = matchCalculating,
|
||||
onDismiss = { selectedGrid = null },
|
||||
onMatch = { onMatchGrid(grid) },
|
||||
onMark = { call -> onAction(MapAction.SetMarkedStation(grid, call)) },
|
||||
onRemoveMark = { onAction(MapAction.RemoveMarkedStation(grid)) }
|
||||
onRemoveMark = { call -> onAction(MapAction.RemoveMarkedStation(grid, call)) },
|
||||
onPinMark = { call -> onAction(MapAction.PinMarkedStation(grid, call)) }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -446,13 +447,14 @@ private fun MapScreen(
|
||||
private fun WorkedGridQsoDialog(
|
||||
grid: String,
|
||||
qsos: List<com.rtbishop.look4sat.core.domain.model.GridQso>,
|
||||
marked: com.rtbishop.look4sat.core.domain.model.MarkedStation?,
|
||||
marked: List<com.rtbishop.look4sat.core.domain.model.MarkedStation>,
|
||||
isUtc: Boolean,
|
||||
matchCalculating: Boolean,
|
||||
onDismiss: () -> Unit,
|
||||
onMatch: () -> Unit,
|
||||
onMark: (String) -> Unit,
|
||||
onRemoveMark: () -> Unit
|
||||
onRemoveMark: (String) -> Unit,
|
||||
onPinMark: (String) -> Unit
|
||||
) {
|
||||
var showMarkInput by remember { mutableStateOf(false) }
|
||||
var callInput by remember { mutableStateOf("") }
|
||||
@@ -512,11 +514,20 @@ private fun WorkedGridQsoDialog(
|
||||
.heightIn(max = 380.dp)
|
||||
.verticalScroll(rememberScrollState())
|
||||
) {
|
||||
// A marked station shows first as a red reminder row: red
|
||||
// callsign, "标记" in the count column, the marking date in
|
||||
// the date column, and a small delete icon to remove it.
|
||||
if (marked != null) {
|
||||
MarkedStationRow(marked = marked, isUtc = isUtc, onRemoveMark = onRemoveMark)
|
||||
// Marked stations show first as red reminder rows: pale-yellow
|
||||
// callsign, "标记" in the count column, the marking date in the
|
||||
// date column, a small pin icon (first = pinned, grey; others
|
||||
// pale yellow) and a delete icon to remove the mark.
|
||||
val showPin = marked.size > 1
|
||||
marked.forEachIndexed { index, station ->
|
||||
MarkedStationRow(
|
||||
marked = station,
|
||||
isPinned = index == 0,
|
||||
showPin = showPin,
|
||||
isUtc = isUtc,
|
||||
onRemoveMark = { onRemoveMark(station.call) },
|
||||
onPinMark = { onPinMark(station.call) }
|
||||
)
|
||||
androidx.compose.material3.HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.3f))
|
||||
}
|
||||
if (qsos.isNotEmpty()) {
|
||||
@@ -535,7 +546,7 @@ private fun WorkedGridQsoDialog(
|
||||
if (qsos.isEmpty()) {
|
||||
TextButton(
|
||||
onClick = {
|
||||
callInput = marked?.call.orEmpty()
|
||||
callInput = ""
|
||||
showMarkInput = true
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
@@ -561,7 +572,7 @@ private fun WorkedGridQsoDialog(
|
||||
text = {
|
||||
androidx.compose.material3.OutlinedTextField(
|
||||
value = callInput,
|
||||
onValueChange = { callInput = it },
|
||||
onValueChange = { callInput = it.uppercase() },
|
||||
label = { Text(stringResource(R.string.grid_mark_callsign)) },
|
||||
singleLine = true
|
||||
)
|
||||
@@ -585,8 +596,11 @@ private fun WorkedGridQsoDialog(
|
||||
@Composable
|
||||
private fun MarkedStationRow(
|
||||
marked: com.rtbishop.look4sat.core.domain.model.MarkedStation,
|
||||
isPinned: Boolean,
|
||||
showPin: Boolean,
|
||||
isUtc: Boolean,
|
||||
onRemoveMark: () -> Unit
|
||||
onRemoveMark: () -> Unit,
|
||||
onPinMark: () -> Unit
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
@@ -596,18 +610,37 @@ private fun MarkedStationRow(
|
||||
text = marked.call,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace,
|
||||
color = ComposeColor(0xFFFF3B30),
|
||||
color = ComposeColor(0xFFFFE082),
|
||||
maxLines = 1,
|
||||
overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.grid_mark_label),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = androidx.compose.ui.text.style.TextAlign.Center,
|
||||
// "标记" column: pin icon (only when the grid has multiple marks) sits
|
||||
// left of the label; pinned (first) row's icon is grey, others pale
|
||||
// yellow — tapping moves that mark to the top.
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
) {
|
||||
if (showPin) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_pin),
|
||||
contentDescription = stringResource(R.string.grid_mark_pin),
|
||||
tint = if (isPinned) ComposeColor(0xFF9E9E9E) else ComposeColor(0xFFFFE082),
|
||||
modifier = Modifier
|
||||
.size(18.dp)
|
||||
.clickable(onClick = onPinMark)
|
||||
)
|
||||
Spacer(Modifier.width(2.dp))
|
||||
}
|
||||
Text(
|
||||
text = stringResource(R.string.grid_mark_label),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = androidx.compose.ui.text.style.TextAlign.Center
|
||||
)
|
||||
}
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.End,
|
||||
@@ -1056,7 +1089,7 @@ private fun setGridMode(
|
||||
centerOnStation: Boolean = false,
|
||||
showFirstCallLabels: Boolean = false,
|
||||
firstCallsByGrid: Map<String, String> = emptyMap(),
|
||||
markedGrids: Map<String, com.rtbishop.look4sat.core.domain.model.MarkedStation> = emptyMap()
|
||||
markedGrids: Map<String, List<com.rtbishop.look4sat.core.domain.model.MarkedStation>> = emptyMap()
|
||||
) {
|
||||
try {
|
||||
val gridOverlay = mapView.overlays[OVERLAY_GRID]
|
||||
|
||||
@@ -34,8 +34,9 @@ data class MapState(
|
||||
val workedGridQsos: Map<String, List<com.rtbishop.look4sat.core.domain.model.GridQso>> = emptyMap(),
|
||||
/** 4-char gridsquares the station operated from (LoTW MY_GRIDSQUARE) — blue stripes. */
|
||||
val roamedGrids: Set<String> = emptySet(),
|
||||
/** Stations the user marked in unworked gridsquares (grid -> mark) — red fill. */
|
||||
val markedGrids: Map<String, com.rtbishop.look4sat.core.domain.model.MarkedStation> = emptyMap(),
|
||||
/** Stations the user marked in unworked gridsquares (grid -> ordered list;
|
||||
* first = pinned/top) — red fill. */
|
||||
val markedGrids: Map<String, List<com.rtbishop.look4sat.core.domain.model.MarkedStation>> = emptyMap(),
|
||||
val stationPosition: GeoPos? = null,
|
||||
val orbitalPass: OrbitalPass,
|
||||
val track: List<List<GeoPos>>? = null,
|
||||
@@ -55,7 +56,8 @@ sealed interface MapAction {
|
||||
data class ToggleGridMode(val value: Boolean) : MapAction
|
||||
data class ToggleFirstCallLabels(val value: Boolean) : MapAction
|
||||
data class SetMarkedStation(val grid: String, val call: String) : MapAction
|
||||
data class RemoveMarkedStation(val grid: String) : MapAction
|
||||
data class RemoveMarkedStation(val grid: String, val call: String) : MapAction
|
||||
data class PinMarkedStation(val grid: String, val call: String) : MapAction
|
||||
data class SetVisible(val isVisible: Boolean) : MapAction
|
||||
}
|
||||
|
||||
|
||||
@@ -121,19 +121,37 @@ class MapViewModel(
|
||||
is MapAction.ToggleGridMode -> settingsRepo.updateOtherSettings { it.copy(stateOfMapGrid = action.value) }
|
||||
is MapAction.ToggleFirstCallLabels -> settingsRepo.updateOtherSettings { it.copy(stateOfMapFirstCall = action.value) }
|
||||
is MapAction.SetMarkedStation -> {
|
||||
val updated = settingsRepo.getMarkedGridStations() +
|
||||
(action.grid to com.rtbishop.look4sat.core.domain.model.MarkedStation(
|
||||
call = action.call.trim().uppercase(),
|
||||
epochMs = System.currentTimeMillis()
|
||||
))
|
||||
val call = action.call.trim().uppercase()
|
||||
val existing = settingsRepo.getMarkedGridStations()
|
||||
val current = existing[action.grid].orEmpty()
|
||||
// 重复呼号忽略: 同一网格已标记过的呼号不再重复添加.
|
||||
val updated = if (current.any { it.call == call }) existing
|
||||
else existing + (action.grid to current + com.rtbishop.look4sat.core.domain.model.MarkedStation(
|
||||
call = call,
|
||||
epochMs = System.currentTimeMillis()
|
||||
))
|
||||
settingsRepo.setMarkedGridStations(updated)
|
||||
_uiState.update { it.copy(markedGrids = updated) }
|
||||
}
|
||||
is MapAction.RemoveMarkedStation -> {
|
||||
val updated = settingsRepo.getMarkedGridStations() - action.grid
|
||||
val existing = settingsRepo.getMarkedGridStations()
|
||||
val current = existing[action.grid].orEmpty()
|
||||
val remaining = current.filterNot { it.call == action.call }
|
||||
val updated = if (remaining.isEmpty()) existing - action.grid
|
||||
else existing + (action.grid to remaining)
|
||||
settingsRepo.setMarkedGridStations(updated)
|
||||
_uiState.update { it.copy(markedGrids = updated) }
|
||||
}
|
||||
is MapAction.PinMarkedStation -> {
|
||||
val existing = settingsRepo.getMarkedGridStations()
|
||||
val current = existing[action.grid].orEmpty()
|
||||
val target = current.firstOrNull { it.call == action.call }
|
||||
if (target != null) {
|
||||
val updated = existing + (action.grid to listOf(target) + current.filterNot { it.call == action.call })
|
||||
settingsRepo.setMarkedGridStations(updated)
|
||||
_uiState.update { it.copy(markedGrids = updated) }
|
||||
}
|
||||
}
|
||||
is MapAction.SetVisible -> isScreenVisible.value = action.isVisible
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
+49
-5
@@ -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
|
||||
@@ -52,6 +53,13 @@ data class MutualUiState(
|
||||
val stationBGrid: String = "",
|
||||
val stationBMinElev: Double = 10.0,
|
||||
val hoursAhead: Int = 24,
|
||||
/** 历史过境回看窗口(小时), 跟随主页面 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,
|
||||
@@ -108,6 +116,12 @@ class MutualViewModel(
|
||||
_uiState.update { it.copy(isUtc = settings.stateOfUtc) }
|
||||
}
|
||||
}
|
||||
// 历史过境回看窗口跟随主页面 Passes 设置 (设置里改小时数, 匹配页同步生效)
|
||||
viewModelScope.launch {
|
||||
settingsRepo.passesSettings.collectLatest { settings ->
|
||||
_uiState.update { it.copy(hoursBefore = settings.hoursBefore) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun onStationALat(value: String) {
|
||||
@@ -192,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) }
|
||||
|
||||
/**
|
||||
@@ -255,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,
|
||||
@@ -276,14 +303,26 @@ class MutualViewModel(
|
||||
queryGeneration += 1
|
||||
|
||||
viewModelScope.launch {
|
||||
val time = System.currentTimeMillis()
|
||||
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
|
||||
val minElevA = state.stationAMinElev
|
||||
val minElevB = state.stationBMinElev
|
||||
val hours = state.hoursAhead
|
||||
|
||||
val results = try {
|
||||
withContext(computeDispatcher) {
|
||||
findMutualPasses(satellites, posA, posB, minElevA, minElevB, time, hours)
|
||||
findMutualPasses(filteredSatellites, posA, posB, minElevA, minElevB, time, hours, state.hoursBefore)
|
||||
}
|
||||
} catch (t: Throwable) {
|
||||
// Never leave the query stuck in "calculating" (which would also
|
||||
@@ -328,15 +367,20 @@ class MutualViewModel(
|
||||
satellites: List<OrbitalObject>,
|
||||
posA: GeoPos, posB: GeoPos,
|
||||
minElevADeg: Double, minElevBDeg: Double,
|
||||
time: Long, hours: Int
|
||||
time: Long, hours: Int, hoursBefore: Int
|
||||
): List<MutualPass> {
|
||||
val endTime = time + hours * 60L * 60L * 1000L
|
||||
// 终点始终是 now + hoursAhead; 起点已由调用方下移 hoursBefore 小时,
|
||||
// 因此这里的 endTime 要在起点之上补回 hoursBefore, 保证窗口 = [now-hb, now+h].
|
||||
val endTime = time + (hoursBefore + hours) * 60L * 60L * 1000L
|
||||
val sampleInterval = 5_000L
|
||||
|
||||
// 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)
|
||||
|
||||
@@ -115,6 +120,8 @@ class FakeSettingsRepo(initialPosition: GeoPos = GeoPos(23.13, 113.26)) : ISetti
|
||||
override fun setWorkedGridQsos(qsos: Map<String, List<GridQso>>) = TODO()
|
||||
override fun getRoamedGrids(): Set<String> = TODO()
|
||||
override fun setRoamedGrids(grids: Set<String>) = TODO()
|
||||
override fun getMarkedGridStations(): Map<String, List<com.rtbishop.look4sat.core.domain.model.MarkedStation>> = emptyMap()
|
||||
override fun setMarkedGridStations(stations: Map<String, List<com.rtbishop.look4sat.core.domain.model.MarkedStation>>) = Unit
|
||||
override fun updateLoTWSettings(settings: LoTWSettings) = TODO()
|
||||
override fun getLastLotwSyncDate(): String = ""
|
||||
override fun setLastLotwSyncDate(date: String) = Unit
|
||||
|
||||
+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 = "494"
|
||||
appVersionCode = "506"
|
||||
#noinspection UnusedVersionCatalogEntry
|
||||
appVersionName = "4.4.7-ba7opf.14"
|
||||
appVersionName = "4.4.7-ba7opf.15.9"
|
||||
#noinspection UnusedVersionCatalogEntry
|
||||
compileSdk = "37"
|
||||
#noinspection UnusedVersionCatalogEntry
|
||||
|
||||
Reference in new issue
Block a user