From cf1e2b99b412a5f94c36be39ce31a6a093fab848 Mon Sep 17 00:00:00 2001 From: atsunatsu Date: Thu, 24 Sep 2026 00:51:13 +0800 Subject: [PATCH] fix: transponder filters - mutual exclusion and beacon-free fallback Two correctness fixes for the FM/Linear/SSTV filters: 1. Satellite type picker: virtual transponder types (AMSAT Live FM, AMSAT Live Linear, Live SSTV) are now mutually exclusive with the regular TLE-source types - ticking a virtual type clears Amateur/ CubeSat/etc and vice versa, so selecting 'AMSAT Live FM' no longer unions in hundreds of non-transponder satellites. Virtual types still combine with each other. Also: an empty virtual-type resolve now yields an empty list instead of falling back to 'show all'. 2. Mutual-match fallback: when the AMSAT lists are empty (first run / sync failure) the DB mode filter used downlinkMode alone, so any downlink-only beacon sharing a mode label was misclassified (ISS/IO-86 CW beacons into Linear, FO-29's FM Digitalker into FM). New getIdsWithModesAndUplink() requires uplinkLow IS NOT NULL, matching real transponders only. SSTV filter keeps the old query (SSTV sats are often downlink-only). Shared virtual-type names moved to Sources.virtualTypeNames. --- .../core/data/database/Look4SatDao.kt | 6 ++++++ .../core/data/repository/SatelliteRepo.kt | 2 ++ .../core/data/repository/SelectionRepo.kt | 10 ++++----- .../look4sat/core/data/source/LocalSource.kt | 1 + .../core/data/repository/DatabaseRepoTest.kt | 1 + .../repository/SelectionRepoSearchTest.kt | 21 +++++++++++++++++++ .../core/domain/repository/ISatelliteRepo.kt | 5 +++++ .../core/domain/source/ILocalSource.kt | 1 + .../look4sat/core/domain/source/Sources.kt | 6 ++++++ .../feature/mutual/MutualViewModel.kt | 5 ++++- .../rtbishop/look4sat/feature/mutual/Fakes.kt | 1 + .../feature/satellites/SatellitesDialog.kt | 14 ++++++++++++- 12 files changed, 66 insertions(+), 7 deletions(-) diff --git a/core/data/src/main/java/com/rtbishop/look4sat/core/data/database/Look4SatDao.kt b/core/data/src/main/java/com/rtbishop/look4sat/core/data/database/Look4SatDao.kt index 5acaa580..f982982c 100644 --- a/core/data/src/main/java/com/rtbishop/look4sat/core/data/database/Look4SatDao.kt +++ b/core/data/src/main/java/com/rtbishop/look4sat/core/data/database/Look4SatDao.kt @@ -48,6 +48,12 @@ interface Look4SatDao { @Query("SELECT catnum FROM radios WHERE downlinkMode IN (:modes) AND isAlive = 1") suspend fun getIdsWithModes(modes: List): List + /** 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): List + @Query("SELECT COUNT(*) FROM radios") suspend fun getRadiosTotal(): Int diff --git a/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/SatelliteRepo.kt b/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/SatelliteRepo.kt index 7b61e72d..d1da96ee 100644 --- a/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/SatelliteRepo.kt +++ b/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/SatelliteRepo.kt @@ -67,6 +67,8 @@ class SatelliteRepo( override suspend fun getSatelliteIdsWithModes(modes: List) = localStorage.getIdsWithModes(modes) + override suspend fun getSatelliteIdsWithModesAndUplink(modes: List) = localStorage.getIdsWithModesAndUplink(modes) + override suspend fun initRepository() = withContext(dispatcher) { combine( settingsRepo.selectedIds, diff --git a/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/SelectionRepo.kt b/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/SelectionRepo.kt index 30c160b2..db9e340a 100644 --- a/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/SelectionRepo.kt +++ b/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/SelectionRepo.kt @@ -50,7 +50,7 @@ class SelectionRepo( null // null = no filtering } else { val ids = resolveTypeIds(types) - if (ids.isEmpty()) null else ids.toHashSet() + if (ids.isEmpty()) emptySet() else ids.toHashSet() } currentItems.map { items -> if (catnumSet == null) items else items.filter { it.catnum in catnumSet } @@ -72,9 +72,9 @@ class SelectionRepo( val idsSet = mutableSetOf() types.forEach { type -> when (type) { - "AMSAT Live FM" -> idsSet.addAll(settingsRepo.getAmSatFmCatnums()) - "AMSAT Live Linear" -> idsSet.addAll(settingsRepo.getAmSatLinearCatnums()) - "Live SSTV" -> idsSet.addAll(localSource.getIdsWithModes(listOf("SSTV"))) + Sources.virtualTypeNames[0] -> idsSet.addAll(settingsRepo.getAmSatFmCatnums()) + Sources.virtualTypeNames[1] -> idsSet.addAll(settingsRepo.getAmSatLinearCatnums()) + Sources.virtualTypeNames[2] -> idsSet.addAll(localSource.getIdsWithModes(listOf("SSTV"))) else -> idsSet.addAll(settingsRepo.getSatelliteTypesIds(listOf(type))) } } @@ -83,7 +83,7 @@ class SelectionRepo( override fun getTypesList() = buildList { // 三个转发器/活动虚拟类型排在最前. - addAll(listOf("AMSAT Live FM", "AMSAT Live Linear", "Live SSTV")) + addAll(Sources.virtualTypeNames) addAll(Sources.satelliteDataUrls.keys.sorted().toMutableList().apply { removeAt(0) }) } diff --git a/core/data/src/main/java/com/rtbishop/look4sat/core/data/source/LocalSource.kt b/core/data/src/main/java/com/rtbishop/look4sat/core/data/source/LocalSource.kt index afe43a1b..ab37366a 100644 --- a/core/data/src/main/java/com/rtbishop/look4sat/core/data/source/LocalSource.kt +++ b/core/data/src/main/java/com/rtbishop/look4sat/core/data/source/LocalSource.kt @@ -48,6 +48,7 @@ class LocalSource(private val look4SatDao: Look4SatDao) : ILocalSource { override suspend fun deleteEntries() = look4SatDao.deleteEntries() override suspend fun getIdsWithModes(modes: List) = look4SatDao.getIdsWithModes(modes) + override suspend fun getIdsWithModesAndUplink(modes: List) = look4SatDao.getIdsWithModesAndUplink(modes) private fun FrameworkEntry.toDomain() = OrbitalData( this.name, this.epoch, this.meanmo, this.eccn, this.incl, diff --git a/core/data/src/test/java/com/rtbishop/look4sat/core/data/repository/DatabaseRepoTest.kt b/core/data/src/test/java/com/rtbishop/look4sat/core/data/repository/DatabaseRepoTest.kt index fdab26e9..3c6de2d1 100644 --- a/core/data/src/test/java/com/rtbishop/look4sat/core/data/repository/DatabaseRepoTest.kt +++ b/core/data/src/test/java/com/rtbishop/look4sat/core/data/repository/DatabaseRepoTest.kt @@ -180,6 +180,7 @@ private class FakeLocalSource : ILocalSource { } override suspend fun getIdsWithModes(modes: List): List = emptyList() + override suspend fun getIdsWithModesAndUplink(modes: List): List = emptyList() override suspend fun getRadiosTotal(): Int = insertedRadios.size diff --git a/core/data/src/test/java/com/rtbishop/look4sat/core/data/repository/SelectionRepoSearchTest.kt b/core/data/src/test/java/com/rtbishop/look4sat/core/data/repository/SelectionRepoSearchTest.kt index 0a9daedb..bd4e6696 100644 --- a/core/data/src/test/java/com/rtbishop/look4sat/core/data/repository/SelectionRepoSearchTest.kt +++ b/core/data/src/test/java/com/rtbishop/look4sat/core/data/repository/SelectionRepoSearchTest.kt @@ -156,6 +156,26 @@ class SelectionRepoSearchTest { assertEquals(sampleItems.size, repo.getEntriesFlow().first().size) } + @Test + fun `virtual type with empty AMSAT list shows empty not everything`() = runTest { + // 根因2回归: AMSAT 清单未同步(空)时, 选虚拟类型应显示空列表, 而非全部卫星. + val repo = createRepo(items = sampleItems, amSatFm = emptySet()) + 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, + amSatFm = setOf(25544), + amSatLinear = setOf(7530) + ) + repo.setTypes(listOf("AMSAT Live FM", "AMSAT Live Linear")) + val results = repo.getEntriesFlow().first() + assertEquals(setOf(25544, 7530), results.map { it.catnum }.toSet()) + } + @Test fun `types list has virtual transponder types first and keeps All`() { val repo = createRepo(sampleItems) @@ -192,6 +212,7 @@ private class FakeLocalSourceForSearch( override suspend fun insertEntries(entries: List) = Unit override suspend fun deleteEntries() = Unit override suspend fun getIdsWithModes(modes: List): List = sstvIds + override suspend fun getIdsWithModesAndUplink(modes: List): List = sstvIds override suspend fun getRadiosTotal(): Int = 0 override suspend fun getRadiosWithId(id: Int): List = emptyList() override suspend fun insertRadios(radios: List, isCustom: Boolean) = Unit diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/ISatelliteRepo.kt b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/ISatelliteRepo.kt index d2bfbcca..f8aba7a5 100644 --- a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/ISatelliteRepo.kt +++ b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/ISatelliteRepo.kt @@ -70,4 +70,9 @@ interface ISatelliteRepo { * Empty modes -> empty result; used to filter the mutual-match satellite * set to transponder satellites (FM voice / linear). */ suspend fun getSatelliteIdsWithModes(modes: List): List + + /** 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): List } diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/source/ILocalSource.kt b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/source/ILocalSource.kt index 246c09f0..2ee4fbc5 100644 --- a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/source/ILocalSource.kt +++ b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/source/ILocalSource.kt @@ -29,6 +29,7 @@ interface ILocalSource { suspend fun insertEntries(entries: List) suspend fun deleteEntries() suspend fun getIdsWithModes(modes: List): List + suspend fun getIdsWithModesAndUplink(modes: List): List suspend fun getRadiosTotal(): Int suspend fun getRadiosWithId(id: Int): List suspend fun insertRadios(radios: List, isCustom: Boolean = false) diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/source/Sources.kt b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/source/Sources.kt index b2a919b4..e17e876d 100644 --- a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/source/Sources.kt +++ b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/source/Sources.kt @@ -62,4 +62,10 @@ object Sources { "FM" to "https://www.amsat.org/live-fm-satellites/", "Linear" to "https://www.amsat.org/live-linear-satellites/" ) + + /** Virtual satellite-selection types: transponder/activity filters shown + * at the top of the type picker. They resolve to live lists (AMSAT pages + * or mode=SSTV radios) instead of persisted per-type IDs, and are + * mutually exclusive with the regular TLE-source types in the picker. */ + val virtualTypeNames = listOf("AMSAT Live FM", "AMSAT Live Linear", "Live SSTV") } diff --git a/feature/mutual/src/main/java/com/rtbishop/look4sat/feature/mutual/MutualViewModel.kt b/feature/mutual/src/main/java/com/rtbishop/look4sat/feature/mutual/MutualViewModel.kt index 408af795..88ef2157 100644 --- a/feature/mutual/src/main/java/com/rtbishop/look4sat/feature/mutual/MutualViewModel.kt +++ b/feature/mutual/src/main/java/com/rtbishop/look4sat/feature/mutual/MutualViewModel.kt @@ -307,11 +307,14 @@ class MutualViewModel( val amSatFm = settingsRepo.getAmSatFmCatnums() val amSatLinear = settingsRepo.getAmSatLinearCatnums() val filteredSatellites = if (amSatFm.isEmpty() && amSatLinear.isEmpty()) { + // 回退到数据库模式过滤: 只用带上行频率的转发器记录, 排除纯下行 + // 信标/遥测(如 CW 信标、FM 语音合成信标), 避免 ISS/IO-86/FO-29 等 + // 因共享模式标签(CW/FM)被误判进错误类别. val filterModes = buildList { if (state.filterFM) add("FM") if (state.filterLinear) addAll(listOf("USB", "LSB", "CW", "SSB")) } - val idsWithModes = satelliteRepo.getSatelliteIdsWithModes(filterModes) + val idsWithModes = satelliteRepo.getSatelliteIdsWithModesAndUplink(filterModes) if (idsWithModes.isEmpty()) satellites else satellites.filter { it.data.catnum in idsWithModes } } else { diff --git a/feature/mutual/src/test/java/com/rtbishop/look4sat/feature/mutual/Fakes.kt b/feature/mutual/src/test/java/com/rtbishop/look4sat/feature/mutual/Fakes.kt index fe0ee1f3..066867f2 100644 --- a/feature/mutual/src/test/java/com/rtbishop/look4sat/feature/mutual/Fakes.kt +++ b/feature/mutual/src/test/java/com/rtbishop/look4sat/feature/mutual/Fakes.kt @@ -45,6 +45,7 @@ class FakeSatelliteRepo( override suspend fun getRadios(sat: OrbitalObject, pos: GeoPos, radios: List, time: Long): List = TODO() override suspend fun getRadiosWithId(id: Int): List = TODO() override suspend fun getSatelliteIdsWithModes(modes: List): List = emptyList() + override suspend fun getSatelliteIdsWithModesAndUplink(modes: List): List = emptyList() } /** diff --git a/feature/satellites/src/main/java/com/rtbishop/look4sat/feature/satellites/SatellitesDialog.kt b/feature/satellites/src/main/java/com/rtbishop/look4sat/feature/satellites/SatellitesDialog.kt index 025fd100..3c42f492 100644 --- a/feature/satellites/src/main/java/com/rtbishop/look4sat/feature/satellites/SatellitesDialog.kt +++ b/feature/satellites/src/main/java/com/rtbishop/look4sat/feature/satellites/SatellitesDialog.kt @@ -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, types: List, cancel: () -> Unit, accept: (List) -> 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) {