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 1eadea4f..5acaa580 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 @@ -45,7 +45,7 @@ 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): List @Query("SELECT COUNT(*) FROM radios") diff --git a/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/DatabaseRepo.kt b/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/DatabaseRepo.kt index 48dffeee..14393a65 100644 --- a/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/DatabaseRepo.kt +++ b/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/DatabaseRepo.kt @@ -115,12 +115,48 @@ class DatabaseRepo( localSource.insertRadios(importedRadios) } if (importedEntries.isNotEmpty()) localSource.insertEntries(importedEntries) + updateAmSatLiveLists() setUpdateSuccessful(System.currentTimeMillis()) } + /** + * Refresh the AMSAT Live FM/Linear satellite lists (transponders currently + * on the air). Best effort: any failure keeps the previous lists and never + * blocks the rest of the data update, so the mutual-match filter simply + * falls back to the last successful snapshot. + */ + private suspend fun updateAmSatLiveLists() = withContext(dispatcher) { + runCatching { + val entries = localSource.getEntriesList() // catnum -> name + val jobs = Sources.amSatLiveUrls.map { (type, url) -> + async { type to remoteSource.getNetworkStream(url) } + } + val results = jobs.awaitAll() + val fmNames = results.firstOrNull { it.first == "FM" }?.second?.stream + ?.let { dataParser.parseAmSatLivePage(it) }.orEmpty() + val linearNames = results.firstOrNull { it.first == "Linear" }?.second?.stream + ?.let { dataParser.parseAmSatLivePage(it) }.orEmpty() + val nameToCatnum = entries.associate { it.name.uppercase() to it.catnum } + fun resolve(names: List): Set = names.mapNotNull { name -> + val keys = dataParser.normalizeAmSatName(name) + nameToCatnum.entries.firstOrNull { (localName, _) -> + keys.any { key -> localName.contains(key) } + }?.value + }.toSet() + val fmCatnums = resolve(fmNames) + val linearCatnums = resolve(linearNames) + settingsRepo.setAmSatCatnums(fmCatnums, linearCatnums) + println("AMSAT live lists updated: FM=${fmCatnums.size}, Linear=${linearCatnums.size}") + }.onFailure { + // Keep the previous lists; the mutual filter stays on the last good snapshot. + println("AMSAT live lists update failed: $it") + } + } + override suspend fun clearAllData() = withContext(dispatcher) { localSource.deleteEntries() localSource.deleteRadios() + settingsRepo.setAmSatCatnums(emptySet(), emptySet()) setUpdateSuccessful(0L) } 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 f788849c..7b61e72d 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 @@ -65,6 +65,8 @@ class SatelliteRepo( override suspend fun getRadiosWithId(id: Int) = localStorage.getRadiosWithId(id) + override suspend fun getSatelliteIdsWithModes(modes: List) = localStorage.getIdsWithModes(modes) + override suspend fun initRepository() = withContext(dispatcher) { combine( settingsRepo.selectedIds, diff --git a/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/SettingsRepo.kt b/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/SettingsRepo.kt index b965c50c..7b042ef1 100644 --- a/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/SettingsRepo.kt +++ b/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/SettingsRepo.kt @@ -107,6 +107,8 @@ class SettingsRepo( private val keySatelliteEnabled = "satelliteEnabled" private val keyTransceiversEnabled = "transceiversEnabled" private val keySatnogsTleSourceMigration = "satnogsTleSourceMigration" + private val keyAmSatFmCatnums = "amSatFmCatnums" + private val keyAmSatLinearCatnums = "amSatLinearCatnums" private val separatorComma = "," private val separatorUrl = "\n" private val legacyCelestrakSatnogsUrl = @@ -745,6 +747,25 @@ class SettingsRepo( } //endregion + //region # AMSAT live-transponder lists + override fun getAmSatFmCatnums(): Set { + val raw = preferences.getString(keyAmSatFmCatnums, null) ?: return emptySet() + return raw.split(separatorComma).mapNotNull { it.toIntOrNull() }.toSet() + } + + override fun getAmSatLinearCatnums(): Set { + val raw = preferences.getString(keyAmSatLinearCatnums, null) ?: return emptySet() + return raw.split(separatorComma).mapNotNull { it.toIntOrNull() }.toSet() + } + + override fun setAmSatCatnums(fmCatnums: Set, linearCatnums: Set) { + preferences.edit { + putString(keyAmSatFmCatnums, fmCatnums.sorted().joinToString(separatorComma)) + putString(keyAmSatLinearCatnums, linearCatnums.sorted().joinToString(separatorComma)) + } + } + //endregion + //region # Radio control settings private val keyRadioControlEnabled = "radioControlEnabled" private val keyRadioModel = "radioModel" 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 b3d96db5..fdab26e9 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 @@ -269,6 +269,10 @@ private class FakeSettingsRepo(dataSources: DataSourcesSettings = defaultDataSou (dataSourcesStatus as? MutableStateFlow)?.value = status } + override fun getAmSatFmCatnums(): Set = emptySet() + override fun getAmSatLinearCatnums(): Set = emptySet() + override fun setAmSatCatnums(fmCatnums: Set, linearCatnums: Set) = Unit + override fun updateRadioControlSettings(settings: RadioControlSettings) = Unit override fun getSatelliteOffset(catnum: Int): String = "" 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 99d31474..d2bfbcca 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 @@ -65,4 +65,9 @@ interface ISatelliteRepo { /** Fetch radio transceivers for a satellite by its catalog number. */ suspend fun getRadiosWithId(id: Int): List + + /** 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): List } diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/ISettingsRepo.kt b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/ISettingsRepo.kt index cc80662a..8f2c0997 100644 --- a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/ISettingsRepo.kt +++ b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/ISettingsRepo.kt @@ -79,6 +79,14 @@ interface ISettingsRepo { fun updateDataSourcesStatus(status: Map) //endregion + //region # AMSAT live-transponder lists + /** Catalog numbers of satellites listed on AMSAT's "Live FM Satellites" page. */ + fun getAmSatFmCatnums(): Set + /** Catalog numbers of satellites listed on AMSAT's "Live Linear Satellites" page. */ + fun getAmSatLinearCatnums(): Set + fun setAmSatCatnums(fmCatnums: Set, linearCatnums: Set) + //endregion + //region # Radio control settings val radioControlSettings: StateFlow fun updateRadioControlSettings(settings: RadioControlSettings) 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 f9e37604..b2a919b4 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 @@ -55,4 +55,11 @@ object Sources { "SatNOGS" to "https://db.satnogs.org/api/transmitters/?format=json&status=active", "R4UAB" to "https://r4uab.ru/transmitters.json" ) + /** AMSAT "Live FM/Linear Satellites" pages: human-maintained lists of + * transponders currently on the air. Parsed into the mutual-match filter + * (satellites that actually carry a working FM-voice / linear repeater). */ + val amSatLiveUrls = mapOf( + "FM" to "https://www.amsat.org/live-fm-satellites/", + "Linear" to "https://www.amsat.org/live-linear-satellites/" + ) } diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/utility/DataParser.kt b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/utility/DataParser.kt index f82d2348..9e28a2c3 100644 --- a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/utility/DataParser.kt +++ b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/utility/DataParser.kt @@ -58,6 +58,82 @@ class DataParser(private val dispatcher: CoroutineDispatcher) { }.getOrDefault(emptyList()) } + /** + * Parse an AMSAT "Live FM/Linear Satellites" page into satellite names. + * The page is an HTML table whose first column is the satellite name, + * sometimes written as an alias list ("AO-91(RadFxSat / Fox-1B)") or a + * range ("TEVEL2-1 thru TEVEL2-9"). Returns the distinct expanded names. + */ + suspend fun parseAmSatLivePage(stream: InputStream): List = withContext(dispatcher) { + runCatching { + val html = stream.bufferedReader().readText() + // Grab table rows, take the first cell of each row, keep rows that + // look like satellite names (start with letters/digits, not a header). + val rows = Regex("]*>(.*?)", RegexOption.IGNORE_CASE) + .findAll(html) + .mapNotNull { match -> + val cells = Regex("]*>(.*?)", RegexOption.IGNORE_CASE) + .findAll(match.groupValues[1]) + .map { cell -> stripHtml(cell.groupValues[1]).trim() } + .toList() + cells.firstOrNull()?.takeIf { it.isNotBlank() && !it.equals("Satellite", ignoreCase = true) } + } + .toList() + rows.flatMap { expandNameRange(it) }.distinct() + }.getOrDefault(emptyList()) + } + + private fun stripHtml(raw: String): String = + raw.replace(Regex("<[^>]+>"), " ").replace(Regex("\\s+"), " ").trim() + + /** Expand "NAME1 thru NAME9" ranges (e.g. TEVEL2-1 thru TEVEL2-9). */ + private fun expandNameRange(name: String): List { + val m = Regex("^(.*?)(\\d+)\\s+thru\\s+(.*?)(\\d+)$", RegexOption.IGNORE_CASE).find(name) + if (m == null) return listOf(name) + val prefix1 = m.groupValues[1].trim() + val startNum = m.groupValues[2].toIntOrNull() ?: return listOf(name) + val endNum = m.groupValues[4].toIntOrNull() ?: return listOf(name) + val prefix2 = m.groupValues[3].trim() + if (startNum > endNum) return listOf(name) + // Suffix after the end number, if any (e.g. "TEVEL2-1 thru TEVEL2-9 "). + val suffix = name.substringAfterLast(m.groupValues[4]).trim() + return (startNum..endNum).map { "$prefix1$it$suffix" } + } + + /** + * Normalize a satellite name from the AMSAT live pages into lookup keys + * used to match against the local entries table: + * - primary key: the leading designator ("AO-91" from "AO-91 (RadFxSat / Fox-1B)") + * - alias keys: every bracketed alias, uppercased, digits preserved + * The local entry names are normalized the same way in [matchesAmSatName]. + */ + fun normalizeAmSatName(name: String): List { + val keys = mutableListOf() + // Primary: everything before the first '(' (or '['), then the first token. + val primary = name.substringBefore('(').substringBefore('[').trim() + primary.split(Regex("\\s+")).firstOrNull()?.takeIf { it.isNotBlank() }?.let { + keys += it.uppercase() + } + // Aliases inside parentheses / brackets: "RadFxSat / Fox-1B" -> two keys. + Regex("\\(([^)]*)\\)").findAll(name).forEach { m -> + m.groupValues[1].split('/', '|').forEach { alias -> + alias.trim().takeIf { it.isNotBlank() }?.let { keys += it.uppercase() } + } + } + Regex("\\[([^]]*)]").findAll(name).forEach { m -> + m.groupValues[1].split('/', '|').forEach { alias -> + alias.trim().takeIf { it.isNotBlank() }?.let { keys += it.uppercase() } + } + } + return keys.distinct() + } + + /** True if a local entry name matches any of the AMSAT normalized keys. */ + fun matchesAmSatName(localName: String, amSatKeys: List): Boolean { + val localUpper = localName.uppercase() + return amSatKeys.any { key -> localUpper.contains(key) } + } + private fun parseCSV(values: List): OrbitalData? = runCatching { val name = values[0] val timestamp = values[2] diff --git a/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/DataParserTest.kt b/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/DataParserTest.kt index 5d7a5588..90abcd82 100644 --- a/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/DataParserTest.kt +++ b/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/DataParserTest.kt @@ -239,4 +239,51 @@ class DataParserTest { // Matches the CSV test data epoch: 2021-11-16 → day 320 assert(dataParser.getDayOfYear(2021, 11, 16) == 320) } + + @Test + fun `AMSAT FM page parses satellite names and expands thru ranges`() = runTest(testDispatcher) { + val html = """ + + + + + +
SatelliteUplinkDownlinkComment
AO-91(RadFxSat / Fox-1B)435.250145.960
SO-50(SaudiSat-1C)145.850436.795
TEVEL2-1 thru TEVEL2-9145.970436.400
+ """.trimIndent().byteInputStream() + val names = dataParser.parseAmSatLivePage(html) + assert(names.contains("AO-91(RadFxSat / Fox-1B)")) + assert(names.contains("SO-50(SaudiSat-1C)")) + assert(names.contains("TEVEL2-1")) + assert(names.contains("TEVEL2-9")) + assert(names.size == 11) // 2 singles + 9 expanded TEVEL2 + } + + @Test + fun `AMSAT linear page keeps mode-suffixed names`() = runTest(testDispatcher) { + val html = """ + + + + +
SatelliteFrequenciesComment
AO-7 Mode B U/v Inverting AnalogUplink LSB
FO-29 (JAS-2)V/u Inverting AnalogUplink LSB
+ """.trimIndent().byteInputStream() + val names = dataParser.parseAmSatLivePage(html) + assert(names.contains("AO-7 Mode B U/v Inverting Analog")) + assert(names.contains("FO-29 (JAS-2)V/u Inverting Analog")) + } + + @Test + fun `AMSAT name normalization produces primary and alias keys`() { + assert(dataParser.normalizeAmSatName("AO-91(RadFxSat / Fox-1B)") == listOf("AO-91", "RADFXSAT", "FOX-1B")) + assert(dataParser.normalizeAmSatName("QO-100 (Es'hail-2/P4A) S/x") == listOf("QO-100", "ES'HAIL-2", "P4A")) + assert(dataParser.normalizeAmSatName("ISS") == listOf("ISS")) + assert(dataParser.matchesAmSatName("AO-91 (RADFXSAT)", listOf("AO-91", "RADFXSAT"))) + assert(!dataParser.matchesAmSatName("SO-50", listOf("AO-91"))) + } + + @Test + fun `AMSAT live page with no table returns empty`() = runTest(testDispatcher) { + val html = "No data".byteInputStream() + assert(dataParser.parseAmSatLivePage(html).isEmpty()) + } } diff --git a/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/repository/LoTWGridSyncTest.kt b/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/repository/LoTWGridSyncTest.kt index 78da21e5..ffb7bdcc 100644 --- a/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/repository/LoTWGridSyncTest.kt +++ b/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/repository/LoTWGridSyncTest.kt @@ -283,6 +283,9 @@ class LoTWGridSyncTest { override fun updateOtherSettings(transform: (OtherSettings) -> OtherSettings) = TODO() override fun updateDataSourcesSettings(settings: DataSourcesSettings) = TODO() override fun updateDataSourcesStatus(status: Map) = TODO() + override fun getAmSatFmCatnums(): Set = emptySet() + override fun getAmSatLinearCatnums(): Set = emptySet() + override fun setAmSatCatnums(fmCatnums: Set, linearCatnums: Set) = Unit override fun updateRadioControlSettings(settings: RadioControlSettings) = TODO() override fun getSatelliteOffset(catnum: Int): String = "" override fun setSatelliteOffset(catnum: Int, offset: String) = TODO() diff --git a/core/presentation/src/main/res/values-zh/strings.xml b/core/presentation/src/main/res/values-zh/strings.xml index 362b4177..712d2a40 100644 --- a/core/presentation/src/main/res/values-zh/strings.xml +++ b/core/presentation/src/main/res/values-zh/strings.xml @@ -308,6 +308,9 @@ 支持 4/6/8 位 Maidenhead 网格;也可直接输入经纬度 最小仰角:%d° 时间范围 + 转发器 + FM + 线性 计算中… 查询匹配 你:%d° diff --git a/core/presentation/src/main/res/values/strings.xml b/core/presentation/src/main/res/values/strings.xml index 16da41ab..c8f77def 100644 --- a/core/presentation/src/main/res/values/strings.xml +++ b/core/presentation/src/main/res/values/strings.xml @@ -338,6 +338,9 @@ Supports 4/6/8-char Maidenhead grid; or enter lat/lon directly Min elevation: %d° Time range + Transponders + FM + Linear Calculating… Query Match You: %d° diff --git a/feature/mutual/src/main/java/com/rtbishop/look4sat/feature/mutual/MutualScreen.kt b/feature/mutual/src/main/java/com/rtbishop/look4sat/feature/mutual/MutualScreen.kt index 01570d84..2a3b5b9b 100644 --- a/feature/mutual/src/main/java/com/rtbishop/look4sat/feature/mutual/MutualScreen.kt +++ b/feature/mutual/src/main/java/com/rtbishop/look4sat/feature/mutual/MutualScreen.kt @@ -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, 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 3e2bd135..408af795 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 @@ -55,6 +55,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 = emptyList(), val isCalculating: Boolean = false, val hasSearched: Boolean = false, @@ -201,6 +205,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 +270,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 +303,24 @@ class MutualViewModel( viewModelScope.launch { val now = System.currentTimeMillis() + // 转发器类型过滤(协程上下文): AMSAT Live 清单优先, 为空时回退数据库. + val amSatFm = settingsRepo.getAmSatFmCatnums() + val amSatLinear = settingsRepo.getAmSatLinearCatnums() + val filteredSatellites = if (amSatFm.isEmpty() && amSatLinear.isEmpty()) { + val filterModes = buildList { + if (state.filterFM) add("FM") + if (state.filterLinear) addAll(listOf("USB", "LSB", "CW", "SSB")) + } + val idsWithModes = satelliteRepo.getSatelliteIdsWithModes(filterModes) + if (idsWithModes.isEmpty()) satellites + else satellites.filter { it.data.catnum in idsWithModes } + } else { + val allowed = buildSet { + if (state.filterFM) addAll(amSatFm) + if (state.filterLinear) addAll(amSatLinear) + } + satellites.filter { it.data.catnum in allowed } + } // 历史回看: 查询窗口起点 = now - hoursBefore (跟随主页面 Passes 设置), // 终点 = now + hoursAhead 不变. val time = now - state.hoursBefore * 60L * 60L * 1000L @@ -295,7 +330,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 +385,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 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 88db0c56..fe0ee1f3 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 @@ -44,13 +44,18 @@ class FakeSatelliteRepo( override suspend fun getTrack(sat: OrbitalObject, pos: GeoPos, start: Long, end: Long): List = TODO() 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() } /** * 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), + val amSatFm: Set = emptySet(), + val amSatLinear: Set = emptySet() +) : ISettingsRepo { override val stationPosition = MutableStateFlow(initialPosition) @@ -103,6 +108,10 @@ class FakeSettingsRepo(initialPosition: GeoPos = GeoPos(23.13, 113.26)) : ISetti override fun updateDataSourcesStatus(status: Map) = TODO() + override fun getAmSatFmCatnums(): Set = amSatFm + override fun getAmSatLinearCatnums(): Set = amSatLinear + override fun setAmSatCatnums(fmCatnums: Set, linearCatnums: Set) = TODO() + override fun updateRadioControlSettings(settings: RadioControlSettings) = TODO() override fun getSatelliteOffset(catnum: Int): String = "" override fun setSatelliteOffset(catnum: Int, offset: String) = TODO() diff --git a/feature/mutual/src/test/java/com/rtbishop/look4sat/feature/mutual/MutualViewModelTest.kt b/feature/mutual/src/test/java/com/rtbishop/look4sat/feature/mutual/MutualViewModelTest.kt index a50db94f..72e328f1 100644 --- a/feature/mutual/src/test/java/com/rtbishop/look4sat/feature/mutual/MutualViewModelTest.kt +++ b/feature/mutual/src/test/java/com/rtbishop/look4sat/feature/mutual/MutualViewModelTest.kt @@ -31,10 +31,12 @@ class MutualViewModelTest { private fun TestScope.createVm( satellites: List = emptyList(), passes: List = emptyList(), - position: GeoPos = TestOrbits.GUANGZHOU + position: GeoPos = TestOrbits.GUANGZHOU, + amSatFm: Set = emptySet(), + amSatLinear: Set = emptySet() ): MutualViewModel = MutualViewModel( satelliteRepo = FakeSatelliteRepo(satellites, passes), - settingsRepo = FakeSettingsRepo(position), + settingsRepo = FakeSettingsRepo(position, amSatFm, amSatLinear), computeDispatcher = StandardTestDispatcher(mainDispatcherRule.dispatcher.scheduler) ) @@ -320,4 +322,67 @@ 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 `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()) + // Only ISS (25544) is on the AMSAT live FM list; ISS_VARIANT (25545) is not. + val vm = createVm( + satellites = listOf(TestOrbits.ISS, TestOrbits.ISS_VARIANT), + passes = windows, + amSatFm = setOf(25544) + ) + vm.onStationBGrid("OL62") + vm.onHoursAhead(24) + + queryAndSettle(vm) + + val state = vm.uiState.value + assertNull(state.errorMessage) + assertTrue(state.mutualPasses.isNotEmpty()) + assertTrue("only AMSAT-listed satellites should appear", state.mutualPasses.all { it.catNum == 25544 }) + } + + @Test + fun `AMSAT filter off for one type excludes its satellites`() = runTest(mainDispatcherRule.dispatcher.scheduler) { + val windows = TestOrbits.findPassWindows(hoursAhead = 24) + + TestOrbits.findPassWindows(sat = TestOrbits.ISS_VARIANT, hoursAhead = 24) + // FM filter off, Linear on: neither sat is linear-listed -> nothing matches. + val vm = createVm( + satellites = listOf(TestOrbits.ISS, TestOrbits.ISS_VARIANT), + passes = windows, + amSatFm = setOf(25544), + amSatLinear = setOf(25545) + ) + vm.onStationBGrid("OL62") + vm.onHoursAhead(24) + vm.onFilterFM(false) + + queryAndSettle(vm) + + val state = vm.uiState.value + assertNull(state.errorMessage) + assertTrue("only linear-listed satellites should appear", state.mutualPasses.all { it.catNum == 25545 }) + } } \ No newline at end of file