Compare commits

...
Author SHA1 Message Date
atsunatsu d33629f015 build: bump version to 4.4.7-ba7opf.16 (509) 2026-09-25 15:02:28 +08:00
atsunatsu 7081ffaa60 Merge remote-tracking branch 'fork/main' 2026-09-25 14:50:56 +08:00
atsunatsu 3b5b5fe627 Merge pull request #6 from Iu-yang1/feature/tianditu-autotle
feat(map): add TianDiTu switching and AutoTLE sources
2026-09-25 14:47:57 +08:00
Iu_yang1 ebe32ed153 feat(map): add TianDiTu and AutoTLE sources 2026-09-25 14:42:29 +08:00
atsunatsu 091d3e867b build: bump version to 4.4.7-ba7opf.15.11 (508) 2026-09-25 14:20:46 +08:00
atsunatsu 26e50ddab5 feat(map,lotw): multi-grid station locations (台址) in roamed stripes and operated-grid selector
A LoTW station location (台址) can span several grid squares: the report
emits <MY_VUCC_GRIDS> (comma-separated, MY_GRIDSQUARE often absent) on
those records, but the app parsed only <MY_GRIDSQUARE>, so every extra
grid of a multi-grid 台址 silently vanished from both the blue roamed
stripes and the operated-grid (VUCC) selector.

- GridQso gains myGrids (full own-grid set), myCallsign and stationKey
  (MY_* station snapshot); myGrid stays as the first grid for back-compat.
- parseConfirmedGridQsos parses MY_VUCC_GRIDS + the MY_* snapshot fields;
  parseRoamedGrids parses MY_VUCC_GRIDS too and uppercases (lowercase
  grids never matched the overlay labels).
- SettingsRepo persists mgs/mc/sk; old data falls back to single-grid sets.
- MapScreen groups the selector's 台址名单 by the 台址's full grid set:
  a multi-grid 台址 is one entry listing all its grids (e.g. 'OM60,PM01'),
  and selecting it fills every worked grid worked from any of them.
  Identical grid sets merge (VUCC-equivalent), so partial MY_* snapshots
  can't split one 台址 into duplicate entries. Own grid still never
  striped.

Verified against real reports: BH6RJD (807 SAT QSOs) yields 7 台址
including 4 multi-grid ones; BA7OPF's own data stays a single 'OL62'
entry. 28 LoTW parser tests pass (5 new).
2026-09-25 14:10:17 +08:00
atsunatsu 5f90eebab4 fix: correct AO-123 in hardcoded FM list to ASRTU-1 (61781)
User correction: AO-123 is ASRTU-1 (NORAD 61781, launched 2024-11-04,
in orbit), not Fox-1D (43137, re-entered 2024-02). Update the hardcoded
FM set and its tests to include ASRTU-1.
2026-09-24 12:57:25 +08:00
atsunatsu 70ada0d608 fix: revert AMSAT live data route; hardcode FM/Linear satellite lists
User decision: drop the AMSAT live-page fetch pipeline entirely (page
parsing, amateur whitelist, ISS module disambiguation, timeout fallbacks,
amSatListsVersion re-resolution) and hardcode the FM/Linear lists instead.

- Sources: remove amSatLiveUrls + amSatActiveUrls; add
  amSatFmCatnums = {SO-50 27607, ISS ZARYA 25544, AO-123 43137} and
  amSatLinearCatnums = {RS-44 44909, FO-29 24278, AO-7 7530,
  AO-73 39444, JO-97 43803}
- SelectionRepo: virtual types resolve FM/Linear from hardcoded sets;
  SSTV stays (amateur-only mode query + R/B debris exclusion)
- MutualViewModel: FM/Linear chips filter by the hardcoded sets
- DatabaseRepo/SettingsRepo/ISettingsRepo: AMSAT list fetch/persist
  removed; DataParser AMSAT parse helpers removed
- Tests: AMSAT pipeline tests dropped; selection/mutual tests re-targeted
  at the hardcoded lists
2026-09-24 12:41:52 +08:00
atsunatsu 317f6d877f build: bump version to 4.4.7-ba7opf.15.8 (505) 2026-09-24 11:38:21 +08:00
atsunatsu 865fa9c6f2 fix: AMSAT live page timeouts wiped FM/Linear lists; add fallback
Root cause of the recurring empty AMSAT Live filters: OkHttp defaults to a
10s read timeout and amsat.org's live pages take 7-10s+ from many
networks — every sync timed out, fmNames/linearNames stayed empty, and
(with no previous list, e.g. right after clearing data) the filters were
written as EMPTY. The stale pre-whitelist FM list survived only because
older syncs had occasionally succeeded and the failure path preserved it.

Fix:
1. OkHttp timeouts raised (connect 20s / read 30s / write 30s / call 60s)
   in MainContainer.provideRemoteSource so slow AMSAT pages actually load.
2. updateAmSatLiveLists gains a last-resort fallback: when the AMSAT page
   is down AND there is no previous list to clean, derive FM/Linear from
   the local transceivers (FM / SSB,CW,USB,LSB modes) intersected with
   the amateur whitelist, so a network timeout never wipes the filters.
   ISS module aliases stay excluded by the whitelist intersection.

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

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

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

Tests: remote update persists All ids; All type resolves to persisted
CelesTrak active ids; getTypesList excludes only Other.
2026-09-24 10:48:22 +08:00
29 changed files with 796 additions and 653 deletions

No files matched your search

@@ -74,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 {
@@ -177,7 +178,16 @@ class MainContainer(private val context: Context) : IMainContainer {
}
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 {
@@ -116,91 +116,12 @@ 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) }
} + async { "Active" to fetchAmSatActiveStream() }
val results = jobs.awaitAll()
val fmNames = results.firstOrNull { it.first == "FM" }?.second?.stream
?.let { dataParser.parseAmSatLivePage(it) }.orEmpty()
val linearNames = results.firstOrNull { it.first == "Linear" }?.second?.stream
?.let { dataParser.parseAmSatLivePage(it) }.orEmpty()
// Amateur whitelist: a failed fetch yields an empty result, which
// must NOT wipe out the last good snapshot. Fall back to the
// persisted whitelist, and only persist when this fetch actually
// returned a stream.
val activeStream = results.firstOrNull { it.first == "Active" }?.second?.stream
val activeCatnums = activeStream
?.let { dataParser.parseAmSatActiveCatnums(it) }
.orEmpty()
.ifEmpty { settingsRepo.getAmSatActiveCatnums() }
if (activeStream != null) settingsRepo.setAmSatActiveCatnums(activeCatnums)
val nameToCatnum = entries.associate { it.name.uppercase() to it.catnum }
// Resolve every matching local entry per AMSAT name. A single match
// is kept as-is (so satellites absent from the amateur whitelist,
// e.g. JO-97/TO-108, are never dropped). Only when several local
// entries share the name (ISS station modules ZARYA/UNITY/ZVEZDA/
// DESTINY/NAUKA) is the whitelist used to pick the primary one.
fun resolvePerName(name: String): Set<Int> {
val keys = dataParser.normalizeAmSatName(name)
val all = nameToCatnum.filter { (localName, _) ->
dataParser.matchesAmSatName(localName, keys)
}.values.toSet()
if (all.size <= 1) return all
val preferred = all.intersect(activeCatnums)
if (preferred.isNotEmpty()) return preferred
// Whitelist unavailable: keep only the primary entry (smallest
// catnum — ISS ZARYA=25544 is the smallest of its five module
// entries) instead of admitting every alias (DESTINY etc.).
return setOf(all.minOrNull() ?: all.first())
}
// A failed FM/Linear page fetch yields an empty list here; do NOT
// keep the stale list verbatim (it may predate the whitelist and
// still contain ISS module aliases like DESTINY). Instead re-run
// the multi-match disambiguation on the previous catnums via their
// local names, so the whitelist / smallest-catnum rules clean it.
fun cleanStaleList(previous: Set<Int>): Set<Int> =
previous.mapNotNull { catnum ->
nameToCatnum.entries.firstOrNull { it.value == catnum }?.key
}.flatMap { resolvePerName(it) }.toSet()
val fmCatnums = fmNames.flatMap { resolvePerName(it) }.toSet()
.ifEmpty { cleanStaleList(settingsRepo.getAmSatFmCatnums()) }
val linearCatnums = linearNames.flatMap { resolvePerName(it) }.toSet()
.ifEmpty { cleanStaleList(settingsRepo.getAmSatLinearCatnums()) }
settingsRepo.setAmSatCatnums(fmCatnums, linearCatnums)
println("AMSAT live lists updated: FM=${fmCatnums.size}, Linear=${linearCatnums.size}, Active=${activeCatnums.size}")
}.onFailure {
// Keep the previous lists; the mutual filter stays on the last good snapshot.
println("AMSAT live lists update failed: $it")
}
}
/** Try the amateur-whitelist sources in order and return the first
* NetworkResult that carried a stream (or null if all failed). */
private suspend fun fetchAmSatActiveStream(): NetworkResult? {
for (url in Sources.amSatActiveUrls) {
val result = remoteSource.getNetworkStream(url)
if (result.stream != null) return result
}
return null
}
override suspend fun clearAllData() = withContext(dispatcher) {
localSource.deleteEntries()
localSource.deleteRadios()
settingsRepo.setAmSatCatnums(emptySet(), emptySet())
setUpdateSuccessful(0L)
}
@@ -252,17 +252,40 @@ class LoTWRepository : ILoTWRepository {
var country: String? = null
var cqz: Int? = null
var state: String? = null
var myGrid: String? = null
// Own-station (台址) fields: MY_GRIDSQUARE + MY_VUCC_GRIDS give the
// full grid set of the station location; the remaining MY_* fields
// together identify the station location (one callsign can own
// several locations, and one location can span several grids).
var myGrids = mutableSetOf<String>()
var myCallsign: String? = null
var myDxcc: String? = null
var myState: String? = null
var myCq: String? = null
var myItu: String? = null
var myIota: String? = null
var myCountry: String? = null
val gridsInRecord = mutableListOf<String>()
fun emitRecord() {
if (propMode != "SAT" || gridsInRecord.isEmpty()) return
val epochMs = adifTimestampToEpoch(qsoDate, timeOn)
val stationKey = buildString {
append(myCallsign ?: "")
append('|').append(myDxcc ?: "")
append('|').append(myState ?: "")
append('|').append(myCq ?: "")
append('|').append(myItu ?: "")
append('|').append(myIota ?: "")
append('|').append(myCountry ?: "")
}.ifBlank { null }
val qso = com.rtbishop.look4sat.core.domain.model.GridQso(
call = call, epochMs = epochMs, satName = satName,
mode = mode, bandUp = bandUp, bandDown = bandDown,
dxcc = dxcc, country = country, cqz = cqz, state = state,
myGrid = myGrid
myGrid = myGrids.firstOrNull(),
myGrids = myGrids,
myCallsign = myCallsign,
stationKey = stationKey
)
for (grid in gridsInRecord) {
result.getOrPut(grid) { mutableListOf() }.add(qso)
@@ -273,7 +296,9 @@ class LoTWRepository : ILoTWRepository {
propMode = null; call = ""; qsoDate = ""; timeOn = ""
satName = ""; mode = ""; bandUp = ""; bandDown = ""
dxcc = null; country = null; cqz = null; state = null
myGrid = null
myGrids = mutableSetOf()
myCallsign = null; myDxcc = null; myState = null
myCq = null; myItu = null; myIota = null; myCountry = null
gridsInRecord.clear()
}
@@ -311,10 +336,40 @@ class LoTWRepository : ILoTWRepository {
line.startsWith("<MY_GRIDSQUARE:") -> {
// Own-station grid (must not be confused with GRIDSQUARE —
// the opposite station's grid). Recorded per QSO so awards
// can be counted per operated grid.
// can be counted per operated grid. A station location may
// also carry MY_VUCC_GRIDS (below) for multi-grid roaming.
val value = adifValue(line)
if (value.length >= 4) myGrid = value.take(4).uppercase()
if (value.length >= 4) myGrids.add(value.take(4).uppercase())
}
line.startsWith("<MY_VUCC_GRIDS:") -> {
// Own-station VUCC grids: a comma-separated list LoTW emits
// when one station location spans several grid squares
// (e.g. "OM60,OM50" with MY_GRIDSQUARE absent). Without
// this field the extra grids of a multi-grid 台址 silently
// vanished from both the blue stripes and the per-台址 VUCC
// count. Same split/truncate as VUCC_GRIDS below.
adifValue(line).split(',').forEach { grid ->
val field = grid.trim().uppercase()
if (field.length >= 4) myGrids.add(field.take(4))
}
}
// Remaining MY_* fields identify the station location (台址):
// grouped together they form the stationKey used by the map's
// operated-grid selector. One callsign can own several 台址.
line.startsWith("<STATION_CALLSIGN:") ->
myCallsign = adifValue(line).trim().uppercase().ifBlank { null }
line.startsWith("<MY_DXCC:") ->
myDxcc = adifValue(line).trim().ifBlank { null }
line.startsWith("<MY_STATE:") ->
myState = adifValue(line).trim().ifBlank { null }?.let { normalizeState(it) }
line.startsWith("<MY_CQ_ZONE:") ->
myCq = adifValue(line).trim().ifBlank { null }
line.startsWith("<MY_ITU_ZONE:") ->
myItu = adifValue(line).trim().ifBlank { null }
line.startsWith("<MY_IOTA:") ->
myIota = adifValue(line).trim().uppercase().ifBlank { null }
line.startsWith("<MY_COUNTRY:") ->
myCountry = adifValue(line).trim().ifBlank { null }
line.startsWith("<GRIDSQUARE:") || line.startsWith("<VUCC_GRIDS:") -> {
// VUCC_GRIDS holds a comma-separated list of grids
// ("EN52en,EN53fa"), up to four for contacts spanning
@@ -420,23 +475,30 @@ class LoTWRepository : ILoTWRepository {
// would silently drop every own grid on real reports; buffer the record
// and decide at <EOR> instead.
var propMode: String? = null
var myGrid: String? = null
var myGrids = mutableSetOf<String>()
for (raw in body.lineSequence()) {
val line = raw.trim()
when {
line.equals("<EOR>", ignoreCase = true) -> {
if (propMode == "SAT" && myGrid != null) grids.add(myGrid)
if (propMode == "SAT" && myGrids.isNotEmpty()) grids.addAll(myGrids)
propMode = null
myGrid = null
myGrids = mutableSetOf()
}
line.startsWith("<PROP_MODE:") -> {
propMode = adifValue(line).uppercase()
}
line.startsWith("<MY_GRIDSQUARE:") -> {
// MY_GRIDSQUARE must not be mistaken for GRIDSQUARE (the
// opposite station's grid) — only own-station grids count.
val value = adifValue(line)
if (value.length >= 4) myGrid = value.take(4)
if (value.length >= 4) myGrids.add(value.take(4).uppercase())
}
line.startsWith("<MY_VUCC_GRIDS:") -> {
// Multi-grid 台址: each field of MY_VUCC_GRIDS is a grid the
// station location covered; without this the extra grids
// never reached the blue stripes.
adifValue(line).split(',').forEach { grid ->
val field = grid.trim().uppercase()
if (field.length >= 4) myGrids.add(field.take(4))
}
}
}
}
@@ -25,7 +25,6 @@ import com.rtbishop.look4sat.core.domain.source.Sources
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.map
@@ -45,22 +44,19 @@ 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 live transponder data instead of SharedPreferences.
// The AMSAT list version counter re-triggers resolution when a background
// data sync rewrites the FM/Linear lists, so the UI reflects the new list
// without a restart or type toggle.
private val itemsWithTypes = combine(currentTypes, settingsRepo.amSatListsVersion) { types, _ -> types }
.flatMapLatest { types: List<String> ->
val catnumSet: Set<Int>? = if (types.isEmpty()) {
null // null = no filtering
} else {
val ids = resolveTypeIds(types)
if (ids.isEmpty()) emptySet() else ids.toHashSet()
}
currentItems.map { items ->
if (catnumSet == null) items else items.filter { it.catnum in catnumSet }
}
// 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 = resolveTypeIds(types)
if (ids.isEmpty()) emptySet() else ids.toHashSet()
}
currentItems.map { items ->
if (catnumSet == null) items else items.filter { it.catnum in catnumSet }
}
}
private val itemsWithQuery = currentQuery.flatMapLatest { query ->
itemsWithTypes.map { items -> filterByQuery(items, query) }
@@ -70,15 +66,17 @@ class SelectionRepo(
/**
* Resolves a list of type names to satellite catnums. The three virtual
* transponder/activity types are resolved from live data; all other types
* come from the per-type ID lists persisted by [ISettingsRepo].
* 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(settingsRepo.getAmSatFmCatnums())
Sources.virtualTypeNames[1] -> idsSet.addAll(settingsRepo.getAmSatLinearCatnums())
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
@@ -102,7 +100,10 @@ class SelectionRepo(
override fun getTypesList() = buildList {
// 三个转发器/活动虚拟类型排在最前.
addAll(Sources.virtualTypeNames)
addAll(Sources.satelliteDataUrls.keys.sorted().toMutableList().apply { removeAt(0) })
// 所有真实 TLE 源类型均可选(含 "All" = CelesTrak active);只排除
// "Other"(空 URL 占位,无数据)。不要用 removeAt(0) —— 字母序第一个
// 是 "All"/"ARISS" 这类真实源,删掉会让用户勾不到它们。
addAll(Sources.satelliteDataUrls.keys.sorted().filterNot { it == "Other" })
}
override suspend fun getEntriesFlow() = withContext(dispatcher) {
@@ -25,6 +25,7 @@ import androidx.core.location.LocationListenerCompat
import androidx.core.location.LocationManagerCompat
import com.rtbishop.look4sat.core.domain.model.DataSourcesSettings
import com.rtbishop.look4sat.core.domain.model.DatabaseState
import com.rtbishop.look4sat.core.domain.model.MapSource
import com.rtbishop.look4sat.core.domain.model.OtherSettings
import com.rtbishop.look4sat.core.domain.model.PassesSettings
import com.rtbishop.look4sat.core.domain.model.RCSettings
@@ -98,6 +99,8 @@ class SettingsRepo(
private val keySstvMode = "sstvMode"
private val keyLowElevation = "lowElevation"
private val keyHighElevation = "highElevation"
private val keyMapSource = "mapSource"
private val keyTiandituKey = "tiandituKey"
private val keyUseCustomTle = "useCustomTle"
private val keyUseCustomTransceivers = "useCustomTransceivers"
private val keyTleUrl = "tleUrl"
@@ -107,9 +110,7 @@ class SettingsRepo(
private val keySatelliteEnabled = "satelliteEnabled"
private val keyTransceiversEnabled = "transceiversEnabled"
private val keySatnogsTleSourceMigration = "satnogsTleSourceMigration"
private val keyAmSatFmCatnums = "amSatFmCatnums"
private val keyAmSatLinearCatnums = "amSatLinearCatnums"
private val keyAmSatActiveCatnums = "amSatActiveCatnums"
private val keyAutoTleSourceMigration = "autoTleSourceMigration"
private val separatorComma = ","
private val separatorUrl = "\n"
private val legacyCelestrakSatnogsUrl =
@@ -213,7 +214,20 @@ class SettingsRepo(
cqz = o.optInt("cq", 0).takeIf { it > 0 },
state = o.optString("st").ifBlank { null },
// Own-grid field: absent in pre-myGrid data -> null.
myGrid = o.optString("mg").ifBlank { null }
myGrid = o.optString("mg").ifBlank { null },
// Multi-grid 台址 fields (v4.4.7-ba7opf.16+): "mgs" is
// the full grid set (MY_GRIDSQUARE + MY_VUCC_GRIDS);
// pre-multi-grid data has only "mg" and falls back to a
// single-element set so every consumer sees myGrids.
myGrids = o.optJSONArray("mgs")?.let { arr ->
(0 until arr.length()).mapNotNull { i ->
arr.optString(i).takeIf { it.isNotBlank() }
}.toSet()
}?.takeIf { it.isNotEmpty() }
?: o.optString("mg").ifBlank { null }?.let { setOf(it) }
?: emptySet(),
myCallsign = o.optString("mc").ifBlank { null },
stationKey = o.optString("sk").ifBlank { null }
)
}
if (list.isNotEmpty()) result[grid] = list
@@ -242,6 +256,9 @@ class SettingsRepo(
.put("cq", q.cqz ?: 0)
.put("st", q.state ?: "")
.put("mg", q.myGrid ?: "")
.put("mgs", org.json.JSONArray(q.myGrids.sorted()))
.put("mc", q.myCallsign ?: "")
.put("sk", q.stationKey ?: "")
)
}
root.put(grid, array)
@@ -498,7 +515,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) }
}
@@ -598,7 +617,12 @@ class SettingsRepo(
override fun updateOtherSettings(transform: (OtherSettings) -> OtherSettings) {
_otherSettings.update { current ->
val new = transform(current)
val new = transform(current).let {
it.copy(
mapSource = MapSource.normalize(it.mapSource),
tiandituKey = it.tiandituKey.trim()
)
}
preferences.edit {
putBoolean(keyStateOfAutoUpdate, new.stateOfAutoUpdate)
putBoolean(keyStateOfAutoLotwSync, new.stateOfAutoLotwSync)
@@ -615,6 +639,8 @@ class SettingsRepo(
putString(keySstvMode, new.sstvMode)
putLong(keyLowElevation, new.lowElevation.toRawBits())
putLong(keyHighElevation, new.highElevation.toRawBits())
putString(keyMapSource, new.mapSource)
putString(keyTiandituKey, new.tiandituKey)
}
new
}
@@ -635,7 +661,9 @@ class SettingsRepo(
shouldSeeWhatsNew = preferences.getBoolean(keyShouldSeeWhatsNew, true),
sstvMode = preferences.getString(keySstvMode, null) ?: "Auto",
lowElevation = Double.fromBits(preferences.getLong(keyLowElevation, 15.0.toRawBits())),
highElevation = Double.fromBits(preferences.getLong(keyHighElevation, 45.0.toRawBits()))
highElevation = Double.fromBits(preferences.getLong(keyHighElevation, 45.0.toRawBits())),
mapSource = MapSource.normalize(preferences.getString(keyMapSource, null).orEmpty()),
tiandituKey = preferences.getString(keyTiandituKey, null).orEmpty().trim()
)
//endregion
@@ -657,6 +685,7 @@ class SettingsRepo(
putString(keySatelliteEnabled, normalized.satelliteEnabled.joinToString(separatorComma))
putString(keyTransceiversEnabled, normalized.transceiversEnabled.joinToString(separatorComma))
putBoolean(keySatnogsTleSourceMigration, true)
putBoolean(keyAutoTleSourceMigration, true)
}
_dataSourcesSettings.value = normalized
}
@@ -667,7 +696,7 @@ class SettingsRepo(
defaultUrls = Sources.satelliteDataUrls.values.filter { it.isNotBlank() },
legacyEnabledKey = keyUseCustomTle,
legacyUrlKey = keyTleUrl
).migrateSatnogsTleSource(),
).migrateSatnogsTleSource().migrateAutoTleSources(),
transceiversUrls = getDataSourceUrls(
key = keyTransceiversUrls,
defaultUrls = Sources.transceiversDataUrls.values.filter { it.isNotBlank() },
@@ -728,6 +757,28 @@ class SettingsRepo(
return migrated
}
private fun List<String>.migrateAutoTleSources(): List<String> {
if (preferences.getBoolean(keyAutoTleSourceMigration, false)) return this
val autoTleUrls = listOfNotNull(
Sources.satelliteDataUrls["BI4PYM AutoTLE (GitHub)"],
Sources.satelliteDataUrls["BI4PYM AutoTLE (Mirror)"]
).filter { it.isNotBlank() }
val missingUrls = autoTleUrls.filterNot { containsSourceUrl(it) }
if (missingUrls.isEmpty()) {
preferences.edit { putBoolean(keyAutoTleSourceMigration, true) }
return this
}
val migrated = this + missingUrls
val enabled = alignFlags(migrated, readEnabledFlags(keySatelliteEnabled))
preferences.edit {
putString(keySatelliteUrls, migrated.joinToString(separatorUrl))
putString(keySatelliteEnabled, enabled.joinToString(separatorComma))
putBoolean(keyAutoTleSourceMigration, true)
}
return migrated
}
private fun List<String>.containsSourceUrl(url: String): Boolean = any { isSameSourceUrl(it, url) }
private fun isSameSourceUrl(first: String, second: String): Boolean =
@@ -748,40 +799,6 @@ class SettingsRepo(
}
//endregion
//region # AMSAT live-transponder lists
private val _amSatListsVersion = MutableStateFlow(0)
override val amSatListsVersion: StateFlow<Int> = _amSatListsVersion
override fun getAmSatFmCatnums(): Set<Int> {
val raw = preferences.getString(keyAmSatFmCatnums, null) ?: return emptySet()
return raw.split(separatorComma).mapNotNull { it.toIntOrNull() }.toSet()
}
override fun getAmSatLinearCatnums(): Set<Int> {
val raw = preferences.getString(keyAmSatLinearCatnums, null) ?: return emptySet()
return raw.split(separatorComma).mapNotNull { it.toIntOrNull() }.toSet()
}
override fun setAmSatCatnums(fmCatnums: Set<Int>, linearCatnums: Set<Int>) {
preferences.edit {
putString(keyAmSatFmCatnums, fmCatnums.sorted().joinToString(separatorComma))
putString(keyAmSatLinearCatnums, linearCatnums.sorted().joinToString(separatorComma))
}
_amSatListsVersion.update { it + 1 }
}
override fun getAmSatActiveCatnums(): Set<Int> {
val raw = preferences.getString(keyAmSatActiveCatnums, null) ?: return emptySet()
return raw.split(separatorComma).mapNotNull { it.toIntOrNull() }.toSet()
}
override fun setAmSatActiveCatnums(catnums: Set<Int>) {
preferences.edit {
putString(keyAmSatActiveCatnums, catnums.sorted().joinToString(separatorComma))
}
}
//endregion
//region # Radio control settings
private val keyRadioControlEnabled = "radioControlEnabled"
private val keyRadioModel = "radioModel"
@@ -127,6 +127,29 @@ class DatabaseRepoTest {
assertEquals(listOf(98248), settingsRepo.satelliteTypeIdsByType["SatNOGS"])
}
@Test
fun `remote update persists All source type ids`() = runTest(dispatcher) {
// Regression: "All" (CelesTrak active) is a real TLE source type whose
// ids must be persisted on sync — the old `if (type == "All") return`
// silently dropped them, making the "All" filter resolve to nothing.
val allUrl = Sources.satelliteDataUrls.getValue("All")
val localSource = FakeLocalSource()
val remoteSource = FakeRemoteSource().apply {
networkStreams[allUrl] = { validCsvStream() }
}
val settingsRepo = FakeSettingsRepo(
dataSources = DataSourcesSettings(
satelliteUrls = listOf(allUrl),
transceiversUrls = emptyList()
)
)
val repository = DatabaseRepo(dispatcher, dataParser, localSource, remoteSource, settingsRepo)
repository.updateFromRemote()
assertEquals(listOf(25544), settingsRepo.satelliteTypeIdsByType["All"])
}
private fun validCsvStream(): InputStream = """
OBJECT_NAME,OBJECT_ID,EPOCH,MEAN_MOTION,ECCENTRICITY,INCLINATION,RA_OF_ASC_NODE,ARG_OF_PERICENTER,MEAN_ANOMALY,EPHEMERIS_TYPE,CLASSIFICATION_TYPE,NORAD_CAT_ID,ELEMENT_SET_NO,REV_AT_EPOCH,BSTAR,MEAN_MOTION_DOT,MEAN_MOTION_DDOT
ISS (ZARYA),1998-067A,2021-11-16T12:28:09.322176,15.48582035,.0004694,51.6447,309.4881,203.6966,299.8876,0,U,25544,999,31220,.31985E-4,.1288E-4,0
@@ -143,102 +166,6 @@ class DatabaseRepoTest {
1 98248U 26237.16675926 .00015724 00000-0 97477-3 0 00013
2 98248 097.5373 310.9694 0011309 278.1232 340.7230 15.09766181000012
""".trimIndent().byteInputStream()
@Test
fun `amsat fm list disambiguates ISS modules via persisted whitelist when active csv fetch fails`() =
runTest(dispatcher) {
val satnogsUrl = Sources.satelliteDataUrls.getValue("SatNOGS")
val localSource = FakeLocalSource()
val remoteSource = FakeRemoteSource().apply {
networkStreams[satnogsUrl] = { issModulesCsvStream() }
networkStreams[Sources.amSatLiveUrls.getValue("FM")] = { amsatFmPageStream() }
// Amateur-whitelist URLs intentionally NOT registered -> fetch fails.
}
val settingsRepo = FakeSettingsRepo(
dataSources = DataSourcesSettings(
satelliteUrls = listOf(satnogsUrl),
transceiversUrls = emptyList()
)
).apply { amSatActive = setOf(25544) } // whitelist persisted by an earlier good sync
val repository = DatabaseRepo(dispatcher, dataParser, localSource, remoteSource, settingsRepo)
repository.updateFromRemote()
// Whitelist fetch fails on both sources, but the persisted whitelist
// still disambiguates: only primary ISS (ZARYA) survives,
// ISS (DESTINY) = 26700 must NOT enter the FM list.
assertTrue(25544 in settingsRepo.amSatFm)
assertTrue(26700 !in settingsRepo.amSatFm)
}
@Test
fun `amsat fm list keeps only smallest catnum when whitelist never available`() = runTest(dispatcher) {
val satnogsUrl = Sources.satelliteDataUrls.getValue("SatNOGS")
val localSource = FakeLocalSource()
val remoteSource = FakeRemoteSource().apply {
networkStreams[satnogsUrl] = { issModulesCsvStream() }
networkStreams[Sources.amSatLiveUrls.getValue("FM")] = { amsatFmPageStream() }
}
val settingsRepo = FakeSettingsRepo(
dataSources = DataSourcesSettings(
satelliteUrls = listOf(satnogsUrl),
transceiversUrls = emptyList()
)
) // no persisted whitelist at all
val repository = DatabaseRepo(dispatcher, dataParser, localSource, remoteSource, settingsRepo)
repository.updateFromRemote()
// Without any whitelist, the multi-match collapse keeps the primary
// entry (smallest catnum = ISS ZARYA 25544), never every module alias.
assertEquals(setOf(25544), settingsRepo.amSatFm)
}
@Test
fun `amsat fm list cleans stale ISS module aliases when fm page fetch fails`() = runTest(dispatcher) {
val satnogsUrl = Sources.satelliteDataUrls.getValue("SatNOGS")
val localSource = FakeLocalSource()
val remoteSource = FakeRemoteSource().apply {
networkStreams[satnogsUrl] = { issModulesCsvStream() }
// FM page NOT registered -> 404 -> empty parse.
}
val settingsRepo = FakeSettingsRepo(
dataSources = DataSourcesSettings(
satelliteUrls = listOf(satnogsUrl),
transceiversUrls = emptyList()
)
).apply {
// Pre-whitelist era stale list: ALL five ISS module entries.
amSatFm = setOf(25544, 25575, 26400, 26700, 49044)
}
val repository = DatabaseRepo(dispatcher, dataParser, localSource, remoteSource, settingsRepo)
repository.updateFromRemote()
// The stale list is not kept verbatim: with the FM page down, the
// previous catnums are re-disambiguated via local names, collapsing
// the five ISS modules to the primary ZARYA (25544).
assertEquals(setOf(25544), settingsRepo.amSatFm)
}
private fun issModulesCsvStream(): InputStream = """
OBJECT_NAME,OBJECT_ID,EPOCH,MEAN_MOTION,ECCENTRICITY,INCLINATION,RA_OF_ASC_NODE,ARG_OF_PERICENTER,MEAN_ANOMALY,EPHEMERIS_TYPE,CLASSIFICATION_TYPE,NORAD_CAT_ID,ELEMENT_SET_NO,REV_AT_EPOCH,BSTAR,MEAN_MOTION_DOT,MEAN_MOTION_DDOT
ISS (ZARYA),1998-067A,2021-11-16T12:28:09.322176,15.48582035,.0004694,51.6447,309.4881,203.6966,299.8876,0,U,25544,999,31220,.31985E-4,.1288E-4,0
ISS (UNITY),1998-067B,2021-11-16T12:28:09.322176,15.48582035,.0004694,51.6447,309.4881,203.6966,299.8876,0,U,25575,999,31220,.31985E-4,.1288E-4,0
ISS (ZVEZDA),1998-067C,2021-11-16T12:28:09.322176,15.48582035,.0004694,51.6447,309.4881,203.6966,299.8876,0,U,26400,999,31220,.31985E-4,.1288E-4,0
ISS (DESTINY),1998-067D,2021-11-16T12:28:09.322176,15.48582035,.0004694,51.6447,309.4881,203.6966,299.8876,0,U,26700,999,31220,.31985E-4,.1288E-4,0
ISS (NAUKA),1998-067E,2021-11-16T12:28:09.322176,15.48582035,.0004694,51.6447,309.4881,203.6966,299.8876,0,U,49044,999,31220,.31985E-4,.1288E-4,0
""".trimIndent().byteInputStream()
private fun amsatFmPageStream(): InputStream = """
<table class="has-background">
<thead><tr><th>Satellite</th><th>Uplink</th><th>Downlink</th><th>Comment</th></tr></thead>
<tbody>
<tr><td>ISS</td><td>145.990 MHz</td><td>437.800 MHz</td><td></td></tr>
<tr><td>AO-91(RadFxSat / Fox-1B)</td><td>435.250 MHz</td><td>145.960 MHz</td><td></td></tr>
</tbody>
</table>
""".trimIndent().byteInputStream()
}
private class FakeRemoteSource : IRemoteSource {
@@ -368,22 +295,6 @@ private class FakeSettingsRepo(dataSources: DataSourcesSettings = defaultDataSou
(dataSourcesStatus as? MutableStateFlow)?.value = status
}
var amSatFm: Set<Int> = emptySet()
var amSatLinear: Set<Int> = emptySet()
var amSatActive: Set<Int> = emptySet()
override val amSatListsVersion: StateFlow<Int> = MutableStateFlow(0)
override fun getAmSatFmCatnums(): Set<Int> = amSatFm
override fun getAmSatLinearCatnums(): Set<Int> = amSatLinear
override fun setAmSatCatnums(fmCatnums: Set<Int>, linearCatnums: Set<Int>) {
amSatFm = fmCatnums
amSatLinear = linearCatnums
}
override fun getAmSatActiveCatnums(): Set<Int> = amSatActive
override fun setAmSatActiveCatnums(catnums: Set<Int>) {
amSatActive = catnums
}
override fun updateRadioControlSettings(settings: RadioControlSettings) = Unit
override fun getSatelliteOffset(catnum: Int): String = ""
@@ -169,6 +169,53 @@ class LoTWRepositoryTest {
assertTrue(repo.parseConfirmedGridQsos(report(qso1, ground))!!.values.all { it.all { q -> q.myGrid == "OL62" } })
}
@Test
fun parseQsosCapturesMyVuccGridsMultiGridStation() {
// A station location (台址) can span several grids: LoTW emits
// <MY_VUCC_GRIDS> (comma-separated) with MY_GRIDSQUARE absent. Both
// grids must land in myGrids, and myGrid (back-compat) = the first.
val qso = "<CALL:5>A50QO\n<QSO_DATE:8>20260820\n<TIME_ON:4>1130\n" +
"<PROP_MODE:3>SAT\n<SAT_NAME:5>RS-44\n<MODE:2>CW\n" +
"<MY_VUCC_GRIDS:11>OM60,OM50\n<GRIDSQUARE:4>NL47\n<EOR>\n"
val result = repo.parseConfirmedGridQsos(report(qso))!!
val parsed = result["NL47"]!!.first()
assertEquals(setOf("OM60", "OM50"), parsed.myGrids)
assertEquals("OM60", parsed.myGrid)
// 6-char fields are truncated to 4 like every other grid path.
val six = "<PROP_MODE:3>SAT\n<SAT_NAME:5>RS-44\n" +
"<MY_VUCC_GRIDS:17>OM60IL70,OM50MA20\n<GRIDSQUARE:4>OK48\n<EOR>\n"
assertEquals(setOf("OM60", "OM50"), repo.parseConfirmedGridQsos(report(six))!!["OK48"]!!.first().myGrids)
}
@Test
fun parseQsosBuildsStationKeyFromMyFields() {
// 台址 identity = the MY_* station snapshot (callsign + dxcc + state +
// CQ/ITU + IOTA + country). A record with the full snapshot yields the
// stable key the operated-grid selector groups by.
val qso = "<CALL:5>A50QO\n<QSO_DATE:8>20260820\n<TIME_ON:4>1130\n" +
"<PROP_MODE:3>SAT\n<SAT_NAME:5>RS-44\n" +
"<STATION_CALLSIGN:6>BH6RJD\n<MY_DXCC:3>318\n<MY_STATE:11>JS // Jiangsu\n" +
"<MY_CQ_ZONE:2>24\n<MY_ITU_ZONE:2>44\n<MY_COUNTRY:5>CHINA\n" +
"<GRIDSQUARE:4>NL47\n<EOR>\n"
val parsed = repo.parseConfirmedGridQsos(report(qso))!!["NL47"]!!.first()
assertEquals("BH6RJD", parsed.myCallsign)
assertEquals("BH6RJD|318|JS|24|44||CHINA", parsed.stationKey)
}
@Test
fun parseQsosStationKeyDistinguishesLocationsUnderSameCallsign() {
// One callsign can own several 台址 (e.g. BH6RJD with a Hubei location
// and a Zhejiang location); different MY_STATE -> different stationKey.
val hb = "<CALL:5>A50QO\n<QSO_DATE:8>20260820\n<TIME_ON:4>1130\n" +
"<PROP_MODE:3>SAT\n<SAT_NAME:5>RS-44\n" +
"<STATION_CALLSIGN:6>BH6RJD\n<MY_STATE:11>HB // Hubei\n<GRIDSQUARE:4>NL47\n<EOR>\n"
val zj = "<CALL:5>BG7ZFK\n<QSO_DATE:8>20260819\n<TIME_ON:4>1130\n" +
"<PROP_MODE:3>SAT\n<SAT_NAME:5>SO-50\n" +
"<STATION_CALLSIGN:6>BH6RJD\n<MY_STATE:11>ZJ // Zhejiang\n<GRIDSQUARE:4>OK48\n<EOR>\n"
val result = repo.parseConfirmedGridQsos(report(hb, zj))!!
assertTrue(result["NL47"]!!.first().stationKey != result["OK48"]!!.first().stationKey)
}
// endregion
// region parseRoamedGrids (MY_GRIDSQUARE = grids the account operated from)
@@ -224,6 +271,30 @@ class LoTWRepositoryTest {
assertNull(repo.parseRoamedGrids("<HTML>Username/password incorrect</HTML>"))
}
@Test
fun parseRoamedGridsCollectsMyVuccGrids() {
// Multi-grid 台址: MY_VUCC_GRIDS carries the extra grids (MY_GRIDSQUARE
// may be absent); every field must reach the roamed set for the stripes.
val qso = "<PROP_MODE:3>SAT\n<SAT_NAME:5>RS-44\n" +
"<MY_VUCC_GRIDS:11>OM60,OM50\n<GRIDSQUARE:4>NL47\n<EOR>\n"
assertEquals(setOf("OM60", "OM50"), repo.parseRoamedGrids(report(qso)))
// Combined with MY_GRIDSQUARE the union is kept.
val both = "<PROP_MODE:3>SAT\n<SAT_NAME:5>RS-44\n" +
"<MY_GRIDSQUARE:4>OL62\n<MY_VUCC_GRIDS:11>OM60,OM50\n<GRIDSQUARE:4>NL47\n<EOR>\n"
assertEquals(setOf("OL62", "OM60", "OM50"), repo.parseRoamedGrids(report(both)))
}
@Test
fun parseRoamedGridsUppercasesLowercaseMyGrid() {
// LoTW emits the <eor> tag lowercase, so field CASE is not guaranteed:
// a lowercase MY_GRIDSQUARE must still match the overlay's uppercase
// labels (regression: take(4) without .uppercase() silently dropped
// stripes for lowercase grids).
val qso = "<PROP_MODE:3>SAT\n<SAT_NAME:5>IO-86\n" +
"<MY_GRIDSQUARE:4>ol62\n<GRIDSQUARE:4>PM95\n<EOR>\n"
assertEquals(setOf("OL62"), repo.parseRoamedGrids(report(qso)))
}
// endregion
// region failure classification (fetchReportBody -> LoTWResult mapping)
@@ -113,14 +113,24 @@ class SelectionRepoSearchTest {
}
@Test
fun `FM virtual type shows only AMSAT FM list satellites`() = runTest {
val repo = createRepo(
items = sampleItems,
amSatFm = setOf(25544, 39444)
)
fun `FM virtual type shows only hardcoded FM satellites`() = runTest {
// AO-123 ASRTU-1 (61781) is on the hardcoded FM list; a random
// satellite (99999) is not.
val items = sampleItems + SatItem(catnum = 61781, name = "ASRTU-1")
val repo = createRepo(items)
repo.setTypes(listOf("AMSAT Live FM"))
val results = repo.getEntriesFlow().first()
assertEquals(listOf(25544, 39444), results.map { it.catnum })
assertEquals(setOf(25544, 61781), results.map { it.catnum }.toSet())
}
@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
@@ -140,8 +150,7 @@ class SelectionRepoSearchTest {
// only catnums the DAO would have filtered to Amateur service (43803).
val repo = createRepo(
items = sampleItems,
sstvIds = listOf(43803),
amSatActive = setOf(43803)
sstvIds = listOf(43803)
)
repo.setTypes(listOf("Live SSTV"))
val results = repo.getEntriesFlow().first()
@@ -155,8 +164,7 @@ class SelectionRepoSearchTest {
val items = sampleItems + SatItem(catnum = 60239, name = "ARIANE 6 R/B")
val repo = createRepo(
items = items,
sstvIds = listOf(43803, 60239),
amSatActive = setOf(43803, 60239)
sstvIds = listOf(43803, 60239)
)
repo.setTypes(listOf("Live SSTV"))
val results = repo.getEntriesFlow().first()
@@ -167,8 +175,6 @@ class SelectionRepoSearchTest {
fun `multiple virtual types union their satellites`() = runTest {
val repo = createRepo(
items = sampleItems,
amSatFm = setOf(25544),
amSatLinear = setOf(7530),
sstvIds = listOf(43803)
)
repo.setTypes(listOf("AMSAT Live FM", "Live SSTV"))
@@ -178,7 +184,7 @@ class SelectionRepoSearchTest {
@Test
fun `clearing types restores full list`() = runTest {
val repo = createRepo(items = sampleItems, amSatFm = setOf(25544))
val repo = createRepo(sampleItems)
repo.setTypes(listOf("AMSAT Live FM"))
assertTrue(repo.getEntriesFlow().first().isNotEmpty())
repo.setTypes(emptyList())
@@ -186,23 +192,24 @@ class SelectionRepoSearchTest {
}
@Test
fun `virtual type with empty AMSAT list shows empty not everything`() = runTest {
// 根因2回归: AMSAT 清单未同步(空)时, 选虚拟类型应显示空列表, 而非全部卫星.
val repo = createRepo(items = sampleItems, amSatFm = emptySet())
fun `virtual type with no matching satellites shows empty not everything`() = runTest {
// 硬编码 FM 列表 (27607/25544/61781) 里没有样本库中的卫星时, 选虚拟
// 类型应显示空列表, 而非全部卫星.
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,
amSatFm = setOf(25544),
amSatLinear = setOf(7530)
)
val repo = createRepo(items = sampleItems)
repo.setTypes(listOf("AMSAT Live FM", "AMSAT Live Linear"))
val results = repo.getEntriesFlow().first()
assertEquals(setOf(25544, 7530), results.map { it.catnum }.toSet())
assertEquals(
setOf(25544, 7530, 39444, 43803),
results.map { it.catnum }.toSet()
)
}
@Test
@@ -218,39 +225,52 @@ class SelectionRepoSearchTest {
}
@Test
fun `amsat list version bump re-resolves FM filter after data sync`() = runTest {
// ISS module aliases: dirty pre-sync FM list contains both ZARYA and DESTINY.
val items = listOf(
SatItem(25544, "ISS (ZARYA)"),
SatItem(26700, "ISS (DESTINY)")
)
val fake = FakeSettingsRepoForSearch(amSatFm = setOf(25544, 26700))
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(items),
localSource = FakeLocalSourceForSearch(sampleItems),
settingsRepo = fake
)
repo.setTypes(listOf("AMSAT Live FM"))
assertEquals(setOf(25544, 26700), repo.getEntriesFlow().first().map { it.catnum }.toSet())
repo.setTypes(listOf("All"))
val results = repo.getEntriesFlow().first()
assertEquals(sampleItems.map { it.catnum }.toSet(), results.map { it.catnum }.toSet())
}
// Simulate a background data sync: lists rewritten + version bumped.
fake.setAmSatCatnums(fmCatnums = setOf(25544), linearCatnums = emptySet())
@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)
}
// The FM filter must reflect the new list WITHOUT a type toggle or restart.
assertEquals(listOf(25544), repo.getEntriesFlow().first().map { it.catnum })
@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>,
amSatFm: Set<Int> = emptySet(),
amSatLinear: Set<Int> = emptySet(),
sstvIds: List<Int> = emptyList(),
amSatActive: Set<Int> = emptySet()
sstvIds: List<Int> = emptyList()
): ISelectionRepo {
return SelectionRepo(
dispatcher = Dispatchers.Unconfined,
localSource = FakeLocalSourceForSearch(items, sstvIds),
settingsRepo = FakeSettingsRepoForSearch(amSatFm, amSatLinear, amSatActive)
settingsRepo = FakeSettingsRepoForSearch()
)
}
}
@@ -274,11 +294,8 @@ private class FakeLocalSourceForSearch(
override suspend fun deleteRadios() = Unit
}
private class FakeSettingsRepoForSearch(
var amSatFm: Set<Int> = emptySet(),
var amSatLinear: Set<Int> = emptySet(),
private var amSatActive: Set<Int> = emptySet()
) : ISettingsRepo {
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())
@@ -313,23 +330,17 @@ private class FakeSettingsRepoForSearch(
override fun setStationPosition(latitude: Double, longitude: Double, altitude: Double): Boolean = true
override fun setStationPosition(): Boolean = true
override fun setStationPosition(locator: String): Boolean = true
override fun getSatelliteTypesIds(types: List<String>): List<Int> = emptyList()
override fun setSatelliteTypeIds(type: String, ids: List<Int>) = Unit
override fun getSatelliteTypesIds(types: List<String>): List<Int> =
types.flatMap { typeIds[it].orEmpty() }.distinct()
override fun setSatelliteTypeIds(type: String, ids: List<Int>) {
typeIds[type] = ids
}
override fun updateDatabaseState(state: DatabaseState) = Unit
override fun updateRCSettings(settings: RCSettings) = Unit
override fun updateOtherSettings(transform: (OtherSettings) -> OtherSettings) = Unit
override fun updateDataSourcesSettings(settings: DataSourcesSettings) = Unit
override fun updateDataSourcesStatus(status: Map<String, Int>) = Unit
override fun getAmSatFmCatnums(): Set<Int> = amSatFm
override fun getAmSatLinearCatnums(): Set<Int> = amSatLinear
override val amSatListsVersion: StateFlow<Int> = MutableStateFlow(0)
override fun setAmSatCatnums(fmCatnums: Set<Int>, linearCatnums: Set<Int>) {
amSatFm = fmCatnums
amSatLinear = linearCatnums
(amSatListsVersion as MutableStateFlow<Int>).value++
}
override fun getAmSatActiveCatnums(): Set<Int> = amSatActive
override fun setAmSatActiveCatnums(catnums: Set<Int>) { amSatActive = catnums }
override fun updateRadioControlSettings(settings: RadioControlSettings) = Unit
override fun getSatelliteOffset(catnum: Int): String = ""
override fun setSatelliteOffset(catnum: Int, offset: String) = Unit
@@ -49,8 +49,27 @@ data class GridQso(
val country: String? = null,
val cqz: Int? = null,
val state: String? = null,
/** 4-char grid the station itself operated from (ADIF MY_GRIDSQUARE). */
val myGrid: String? = null
/** 4-char grid the station itself operated from (ADIF MY_GRIDSQUARE).
* Kept as the first entry of [myGrids] for backward compatibility with
* consumers that predate multi-grid station locations. */
val myGrid: String? = null,
/** Every 4-char grid the station operated from for this QSO — ADIF
* <MY_GRIDSQUARE> plus every field of <MY_VUCC_GRIDS> (a comma-separated
* list LoTW emits when one station location roams across several grid
* squares; MY_GRIDSQUARE may be absent on those records). A station
* location (台址) can legitimately span multiple grids, and the map's
* stripes and the operated-grid selector must show all of them. */
val myGrids: Set<String> = emptySet(),
/** Callsign of the station location this QSO was logged under (ADIF
* <STATION_CALLSIGN>; e.g. "BH6RJD", "BH6RJD/P"). NOT the 台址 identity
* on its own — one callsign can have several station locations. */
val myCallsign: String? = null,
/** Stable key of the station location (台址) this QSO was logged under,
* derived from the MY_* station fields (STATION_CALLSIGN + MY_DXCC +
* MY_STATE + MY_CQ_ZONE + MY_ITU_ZONE + MY_IOTA + MY_COUNTRY), so the
* operated-grid selector can group QSOs per 台址 and show each 台址 with
* its full grid set. Null for data synced before this field existed. */
val stationKey: String? = null
) {
/** Short uplink/downlink band label ("U/V", "V/A"), or "" when unknown. */
val bandLabel: String
@@ -75,9 +75,23 @@ data class OtherSettings(
* ephemeris auto-update; only effective after at least one manual sync). */
val stateOfAutoLotwSync: Boolean = true,
/** 指南针手动航向偏置(度, -180..180): 雷达方位 = 传感器方位 + 磁偏角 + 该偏置. */
val compassOffsetDegrees: Float = 0f
val compassOffsetDegrees: Float = 0f,
val mapSource: String = MapSource.OSM,
val tiandituKey: String = ""
)
object MapSource {
const val OSM = "osm"
const val TIANDITU_VECTOR = "tianditu_vector"
const val TIANDITU_IMAGE = "tianditu_image"
private const val LEGACY_TIANDITU = "tianditu"
fun normalize(source: String): String = when (source) {
OSM, TIANDITU_VECTOR, TIANDITU_IMAGE -> source
LEGACY_TIANDITU -> TIANDITU_VECTOR
else -> OSM
}
}
data class DataSourcesSettings(
val satelliteUrls: List<String>,
val transceiversUrls: List<String>,
@@ -79,23 +79,6 @@ interface ISettingsRepo {
fun updateDataSourcesStatus(status: Map<String, Int>)
//endregion
//region # AMSAT live-transponder lists
/** Catalog numbers of satellites listed on AMSAT's "Live FM Satellites" page. */
fun getAmSatFmCatnums(): Set<Int>
/** Catalog numbers of satellites listed on AMSAT's "Live Linear Satellites" page. */
fun getAmSatLinearCatnums(): Set<Int>
fun setAmSatCatnums(fmCatnums: Set<Int>, linearCatnums: Set<Int>)
/** Catalog numbers of active *amateur* satellites from AMSAT's
* active-transponder table (whitelist for the virtual type filters). */
fun getAmSatActiveCatnums(): Set<Int>
fun setAmSatActiveCatnums(catnums: Set<Int>)
/** Monotonic version counter, incremented every time the AMSAT live
* FM/Linear lists are rewritten. Consumers that resolve the virtual
* types (SelectionRepo) should re-resolve on change so a background
* data sync is reflected in the UI without a restart. */
val amSatListsVersion: StateFlow<Int>
//endregion
//region # Radio control settings
val radioControlSettings: StateFlow<RadioControlSettings>
fun updateRadioControlSettings(settings: RadioControlSettings)
@@ -21,6 +21,8 @@ object Sources {
val satelliteDataUrls = mapOf(
"LAPAN-A2" to "https://www.kaggle.com/api/v1/datasets/download/muazamnugroho/lapan-a2-satellite-two-line-element-tle-dataset/LAPAN-A2_TLE_latest.txt",
"R4UAB" to "https://r4uab.ru/satonline.txt",
"BI4PYM AutoTLE (GitHub)" to "https://raw.githubusercontent.com/BI4PYM/AutoTLE/refs/heads/master/AutoTLE.txt",
"BI4PYM AutoTLE (Mirror)" to "https://autotle.bi4pym.cn/AutoTLE.txt",
"All" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=active&FORMAT=csv",
"SatNOGS" to "https://db.satnogs.org/api/tle/?format=3le",
"Amateur" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=amateur&FORMAT=csv",
@@ -55,31 +57,25 @@ 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/"
)
/**
* Hardcoded AMSAT Live FM satellites (NORAD catnums):
* SO-50 (27607), ISS ZARYA (25544), AO-123 ASRTU-1 (61781).
* 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, 61781)
/** Machine-readable AMSAT active-transponder table (palewire mirror of
* amsat.org's active-frequency pages): one CSV row per active amateur
* transponder with its NORAD catnum. Used as the authoritative whitelist
* of *amateur* satellites, so the virtual filters never match
* non-amateur debris (Ariane 6 R/B), retired weather sats (TIROS), or
* ISS station-module aliases (ISS (DESTINY) etc.). Primary source plus
* a jsDelivr CDN mirror: raw.githubusercontent.com is unreachable on
* some networks (e.g. mainland China mobile), and a failed whitelist
* fetch would silently disable the module disambiguation. */
val amSatActiveUrls = listOf(
"https://raw.githubusercontent.com/palewire/amateur-satellite-database/main/data/amsat-active-frequencies.csv",
"https://cdn.jsdelivr.net/gh/palewire/amateur-satellite-database@main/data/amsat-active-frequencies.csv"
)
/**
* 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 (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. */
* 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")
}
@@ -40,21 +40,6 @@ class DataParser(private val dispatcher: CoroutineDispatcher) {
}
}
/** Parse AMSAT's active-transponder CSV (palewire mirror) and return the
* set of NORAD catnums of active *amateur* satellites. Header row has a
* norad_id column; some rows carry alphanumeric IDs (Bluebird A0241 etc.)
* which are skipped. */
suspend fun parseAmSatActiveCatnums(stream: InputStream): Set<Int> = withContext(dispatcher) {
stream.bufferedReader().useLines { lines ->
val header = lines.firstOrNull() ?: return@useLines emptySet()
val noradIdx = header.split(",").indexOfFirst { it.trim().equals("norad_id", true) }
if (noradIdx < 0) return@useLines emptySet()
lines.mapNotNull { line ->
line.split(",").getOrNull(noradIdx)?.trim()?.toIntOrNull()
}.toSet()
}
}
suspend fun parseTLEStream(stream: InputStream): List<OrbitalData> = withContext(dispatcher) {
stream.bufferedReader().readLines()
.chunked(3)
@@ -73,101 +58,6 @@ 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<String> = 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("<tr[^>]*>(.*?)</tr>", RegexOption.IGNORE_CASE)
.findAll(html)
.mapNotNull { match ->
val cells = Regex("<t[dh][^>]*>(.*?)</t[dh]>", 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<String> {
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<String> {
val keys = mutableListOf<String>()
// 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.
* Token-based exact match (split on spaces/brackets/slashes), so a key
* like "ISS" does not substring-match "AISSAT-1" — the key must equal a
* whole name token (case-insensitive). OSCAR designators are expanded so
* that "AO-7" also matches local names spelled "OSCAR 7" / "AMSAT-OSCAR 7"
* (the form used by CelesTrak/SatNOGS TLE sources). */
fun matchesAmSatName(localName: String, amSatKeys: List<String>): Boolean {
val localUpper = localName.uppercase()
val localTokens = localUpper
.split(Regex("[\\s()\\[\\]/]+"))
.filter { it.isNotBlank() }
.toSet()
return amSatKeys.any { key ->
val k = key.uppercase()
if (k in localTokens) return@any true
// "AO-7" -> also match local "OSCAR 7" or "AMSAT-OSCAR 7".
val oscar = Regex("^([A-Z]{1,3})-(\\d+)$").find(k)
if (oscar != null) {
val num = oscar.groupValues[2]
if ("OSCAR $num" in localUpper || "AMSAT-OSCAR $num" in localUpper) return@any true
}
false
}
}
private fun parseCSV(values: List<String>): OrbitalData? = runCatching {
val name = values[0]
val timestamp = values[2]
@@ -239,60 +239,4 @@ 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 = """
<html><body><table>
<tr><th>Satellite</th><th>Uplink</th><th>Downlink</th><th>Comment</th></tr>
<tr><td>AO-91(RadFxSat / Fox-1B)</td><td>435.250</td><td>145.960</td></tr>
<tr><td>SO-50(SaudiSat-1C)</td><td>145.850</td><td>436.795</td></tr>
<tr><td>TEVEL2-1 thru TEVEL2-9</td><td>145.970</td><td>436.400</td></tr>
</table></body></html>
""".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 = """
<table>
<tr><th>Satellite</th><th>Frequencies</th><th>Comment</th></tr>
<tr><td>AO-7 Mode B U/v Inverting Analog</td><td>Uplink LSB</td></tr>
<tr><td>FO-29 (JAS-2)V/u Inverting Analog</td><td>Uplink LSB</td></tr>
</table>
""".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")))
// Regression: "ISS" must not substring-match "AISSAT-1" (token match only).
assert(dataParser.matchesAmSatName("ISS (ZARYA)", listOf("ISS")))
assert(!dataParser.matchesAmSatName("AISSAT-1", listOf("ISS")))
assert(!dataParser.matchesAmSatName("TESS", listOf("ISS")))
// OSCAR designators match local names spelled "OSCAR N"/"AMSAT-OSCAR N".
assert(dataParser.matchesAmSatName("OSCAR 7", listOf("AO-7")))
assert(dataParser.matchesAmSatName("AMSAT-OSCAR 7", listOf("AO-7")))
assert(dataParser.matchesAmSatName("CATSAT", listOf("CATSAT")))
assert(dataParser.matchesAmSatName("RS-44 & BREEZE-KM R/B", listOf("RS-44")))
}
@Test
fun `AMSAT live page with no table returns empty`() = runTest(testDispatcher) {
val html = "<html><body>No data</body></html>".byteInputStream()
assert(dataParser.parseAmSatLivePage(html).isEmpty())
}
}
@@ -283,12 +283,6 @@ class LoTWGridSyncTest {
override fun updateOtherSettings(transform: (OtherSettings) -> OtherSettings) = TODO()
override fun updateDataSourcesSettings(settings: DataSourcesSettings) = TODO()
override fun updateDataSourcesStatus(status: Map<String, Int>) = TODO()
override fun getAmSatFmCatnums(): Set<Int> = emptySet()
override fun getAmSatLinearCatnums(): Set<Int> = emptySet()
override val amSatListsVersion: StateFlow<Int> = MutableStateFlow(0)
override fun setAmSatCatnums(fmCatnums: Set<Int>, linearCatnums: Set<Int>) = Unit
override fun getAmSatActiveCatnums(): Set<Int> = emptySet()
override fun setAmSatActiveCatnums(catnums: Set<Int>) = Unit
override fun updateRadioControlSettings(settings: RadioControlSettings) = TODO()
override fun getSatelliteOffset(catnum: Int): String = ""
override fun setSatelliteOffset(catnum: Int, offset: String) = TODO()
@@ -88,7 +88,7 @@
<!-- Map screen -->
<string name="map_prev">Önceki</string>
<string name="map_next">Sonraki</string>
<string name="map_copyright" translatable="false">© OpenStreetMap contributors</string>
<string name="map_copyright_osm" translatable="false">© OpenStreetMap contributors</string>
<string name="map_azimuth">Azimut: %.1f°</string>
<string name="map_elevation">Yükseklik açısı: %.1f°</string>
<string name="map_altitude">İrtifa: %.0f km</string>
@@ -209,7 +209,8 @@
\n• Alexandru Csete (Gpredict)
\n• Dr T.S. Kelso (Celestrak)
\n• Libre Space Foundation (SatNOGS)
\n• BG7NTA BG5JSU (CW decoding feature)</string>
\n• BG7NTA BG5JSU (CW decoding feature)
\n• BH2VSQ BG5JSU (TianDiTu rendering support)</string>
<string name="prefs_outro_license">Bu uygulama herhangi bir garanti sunmaz</string>
</resources>
@@ -247,6 +247,15 @@
<string name="rc_enable_switch">启用 CAT 控制</string>
<string name="prefs_other_title">其他设置</string>
<string name="prefs_map_title">地图设置</string>
<string name="prefs_map_configure">配置地图</string>
<string name="prefs_map_source_title">地图来源</string>
<string name="prefs_map_source_osm">离线 OSM 地图</string>
<string name="prefs_map_source_tianditu_vector">天地图(矢量)</string>
<string name="prefs_map_source_tianditu_image">天地图(影像)</string>
<string name="prefs_map_tianditu_key">天地图访问密钥</string>
<string name="prefs_map_tianditu_key_help">使用天地图前,需先在天地图开发者控制台申请服务端应用 Key。</string>
<string name="prefs_map_tianditu_key_required">选择天地图需要填写访问密钥;保存密钥前将继续使用离线 OSM 地图。</string>
<string name="prefs_other_switch_utc">以 UTC 时间显示</string>
<string name="prefs_other_switch_update">启用卫星数据自动更新</string>
<string name="prefs_other_switch_lotw_sync">自动同步 LoTW 已确认网格</string>
@@ -282,7 +291,8 @@
\n• Dr T.S. Kelso (Celestrak)
\n• Libre Space Foundation (SatNOGS)
\n• xdsopl 和 Robot36 贡献者!
\n• BG7NTA BG5JSU(CW解码功能)</string>
\n• BG7NTA BG5JSU(CW解码功能)
\n• BH2VSQ BG5JSU(天地图渲染支持)</string>
<string name="prefs_outro_license">该应用程序不提供任何保修.</string>
<!-- 匹配页 Mutual match page -->
@@ -128,7 +128,8 @@
<!-- Map screen -->
<string name="map_prev">Prev</string>
<string name="map_next">Next</string>
<string name="map_copyright" translatable="false">© OpenStreetMap contributors</string>
<string name="map_copyright_osm" translatable="false">© OpenStreetMap contributors</string>
<string name="map_copyright_tianditu" translatable="false">© Tianditu</string>
<string name="map_grid_mode">Grid mode</string>
<string name="map_satellite_mode">Satellite mode</string>
<string name="map_first_call_mode">First call</string>
@@ -276,6 +277,15 @@
<string name="prefs_net_perm_error">Check your network permission</string>
<string name="prefs_other_title">Other settings</string>
<string name="prefs_map_title">Map settings</string>
<string name="prefs_map_configure">Configure map</string>
<string name="prefs_map_source_title">Map source</string>
<string name="prefs_map_source_osm">Offline OSM</string>
<string name="prefs_map_source_tianditu_vector">TianDiTu vector</string>
<string name="prefs_map_source_tianditu_image">TianDiTu imagery</string>
<string name="prefs_map_tianditu_key">TianDiTu access key</string>
<string name="prefs_map_tianditu_key_help">Request a server-side application key from the TianDiTu developer console before using TianDiTu maps.</string>
<string name="prefs_map_tianditu_key_required">A TianDiTu key is required. Offline OSM remains active until it is entered.</string>
<string name="prefs_other_switch_utc">Show pass time in UTC</string>
<string name="prefs_other_switch_update">Enable automatic data update</string>
<string name="prefs_other_switch_lotw_sync">Auto-sync LoTW confirmed grids</string>
@@ -312,6 +322,7 @@
\n* Libre Space Foundation (SatNOGS)
\n* xdsopl and Robot36 contributors!
\n* BG7NTA BG5JSU (CW decoding feature)
\n* BH2VSQ BG5JSU (TianDiTu rendering support)
</string>
<string name="prefs_outro_license">The app comes with no warranty</string>
@@ -85,6 +85,7 @@ import androidx.lifecycle.viewmodel.compose.viewModel
import com.rtbishop.look4sat.core.domain.model.AwardCalculator
import com.rtbishop.look4sat.core.domain.model.AwardProgress
import com.rtbishop.look4sat.core.domain.model.AwardType
import com.rtbishop.look4sat.core.domain.model.MapSource
import com.rtbishop.look4sat.core.domain.predict.GeoPos
import com.rtbishop.look4sat.core.domain.predict.OrbitalObject
import com.rtbishop.look4sat.core.domain.predict.OrbitalPos
@@ -96,31 +97,40 @@ import com.rtbishop.look4sat.core.presentation.TimerRow
import com.rtbishop.look4sat.core.presentation.TopBar
import com.rtbishop.look4sat.core.presentation.isVerticalLayout
import com.rtbishop.look4sat.core.presentation.layoutPadding
import org.osmdroid.tileprovider.MapTileProviderBasic
import org.osmdroid.tileprovider.tilesource.OnlineTileSourceBase
import org.osmdroid.tileprovider.tilesource.XYTileSource
import org.osmdroid.events.DelayedMapListener
import org.osmdroid.events.MapListener
import org.osmdroid.events.ScrollEvent
import org.osmdroid.events.ZoomEvent
import org.osmdroid.util.GeoPoint
import org.osmdroid.util.MapTileIndex
import org.osmdroid.views.CustomZoomButtonsController
import org.osmdroid.views.MapView
import org.osmdroid.views.overlay.FolderOverlay
import org.osmdroid.views.overlay.Marker
import org.osmdroid.views.overlay.Polyline
import org.osmdroid.views.overlay.TilesOverlay
import java.net.URLEncoder
// Overlay indices
private const val OVERLAY_GRID = 0
private const val OVERLAY_STATION = 1
private const val OVERLAY_TRACK = 2
private const val OVERLAY_FOOTPRINT = 3
private const val OVERLAY_POSITIONS = 4
private const val OVERLAY_TERMINATOR = 5
private const val OVERLAY_SUN = 6
private const val OVERLAY_MOON = 7
private const val OVERLAY_COUNT = 8
private const val OVERLAY_TDT_LABELS = 0
private const val OVERLAY_GRID = 1
private const val OVERLAY_STATION = 2
private const val OVERLAY_TRACK = 3
private const val OVERLAY_FOOTPRINT = 4
private const val OVERLAY_POSITIONS = 5
private const val OVERLAY_TERMINATOR = 6
private const val OVERLAY_SUN = 7
private const val OVERLAY_MOON = 8
private const val OVERLAY_COUNT = 9
private val minLat = MapView.getTileSystem().minLatitude
private val maxLat = MapView.getTileSystem().maxLatitude
private val offlineTileSource = XYTileSource("tiles", 0, 6, 256, ".webp", emptyArray<String>())
private const val OFFLINE_MAX_ZOOM = 7.0
private const val TIANDITU_MAX_ZOOM = 18.0
private val trackPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
strokeWidth = 3f
style = Paint.Style.STROKE
@@ -209,6 +219,8 @@ private fun MapScreen(
val rotateMod = Modifier.rotate(180f)
val timeString = uiState.mapData?.aosTime ?: "00:00:00"
val isTimeAos = uiState.mapData?.isTimeAos ?: true
val usesTianditu = tiandituLayers(uiState.mapSource) != null && uiState.tiandituKey.isNotBlank()
val copyrightResId = if (usesTianditu) R.string.map_copyright_tianditu else R.string.map_copyright_osm
// Tapped worked grid -> centered QSO dialog. Local UI state: the map is the
// only consumer and it resets when leaving the page.
@@ -216,19 +228,6 @@ private fun MapScreen(
// Selected award filter. Lives in an Activity-scoped ViewModel so it
// survives page switches; defaults to VUCC only once per process (cold start).
var selectedAward by mapFilterViewModel.selectedAward
// Six-award progress derived from the confirmed QSO store; recomputed when
// the store changes (LoTW/Wavelog sync).
// Per operated-grid VUCC breakdown: myGrid -> set of worked grids worked from it.
val vuccByMyGrid: Map<String, Set<String>> = remember(uiState.workedGridQsos) {
val m = mutableMapOf<String, MutableSet<String>>()
for ((grid, qsos) in uiState.workedGridQsos) {
for (q in qsos) {
val mg = q.myGrid ?: continue
m.getOrPut(mg) { mutableSetOf() }.add(grid)
}
}
m
}
// First callsign worked in each grid (earliest QSO by time), used by the
// "首通呼号" label mode — derived from the same QSO store as the worked fills.
val firstCallsByGrid: Map<String, String> = remember(uiState.workedGridQsos) {
@@ -236,26 +235,47 @@ private fun MapScreen(
qsos.minByOrNull { it.epochMs }?.let { grid to it.call }
}.toMap()
}
// Selected operated grid for VUCC counting; defaults to the grid with the
// most worked grids. Null when the store carries no per-QSO myGrid data
// (requires a LoTW resync) — then VUCC falls back to the global count.
var selectedMyGrid by remember(vuccByMyGrid) {
mutableStateOf(vuccByMyGrid.maxByOrNull { it.value.size }?.key)
// 台址名单: QSOs grouped by the 台址's GRID SET. A station location (台址)
// can span several grids (MY_GRIDSQUARE + MY_VUCC_GRIDS), and QSOs whose
// location covers the same grid set belong to the same 台址; groups with an
// identical grid set merge (they are VUCC-equivalent — the MY_* snapshot
// can differ between records of the same physical 台址 when LoTW omits
// optional fields). Data synced before multi-grid support falls back to
// one group per grid, preserving the old selector exactly.
val stationGroups: List<StationGroup> = remember(uiState.workedGridQsos) {
val byGridSet = LinkedHashMap<String, Pair<Set<String>, MutableSet<String>>>()
for ((grid, qsos) in uiState.workedGridQsos) {
for (q in qsos) {
val gs = q.myGrids.ifEmpty { q.myGrid?.let { setOf(it) }.orEmpty() }
if (gs.isEmpty()) continue
val key = gs.sorted().joinToString(",")
byGridSet.getOrPut(key) { gs to mutableSetOf() }.second.add(grid)
}
}
byGridSet.map { (key, v) -> StationGroup(id = key, grids = v.first, worked = v.second) }
.sortedByDescending { it.workedCount }
}
val awardProgress: List<AwardProgress> = remember(uiState.workedGridQsos, vuccByMyGrid, selectedMyGrid) {
val vuccGrids = selectedMyGrid?.let { vuccByMyGrid[it].orEmpty() }
// Selected 台址 for VUCC counting; defaults to the group with the most
// worked grids. Null = "All" (every 台址 combined). Empty when the store
// carries no per-QSO myGrid data (requires a LoTW resync) — the selector
// is hidden then and VUCC falls back to the global count.
var selectedStationId by remember(stationGroups) {
mutableStateOf(stationGroups.maxByOrNull { it.workedCount }?.id)
}
val awardProgress: List<AwardProgress> = remember(uiState.workedGridQsos, stationGroups, selectedStationId) {
val base = AwardCalculator.calculate(uiState.workedGridQsos)
if (vuccGrids == null) base
val worked = stationGroups.firstOrNull { it.id == selectedStationId }?.worked
if (worked == null) base
else base.map { p ->
if (p.type == AwardType.VUCC) p.copy(workedKeys = vuccGrids, count = vuccGrids.size) else p
if (p.type == AwardType.VUCC) p.copy(workedKeys = worked, count = worked.size) else p
}
}
// Worked grids drawn on the map: filtered by the selected operated grid
// (null = all operated grids). Drives both the green fills and the tap
// listener below, so switching the selector changes which cells are green.
val workedGrids = remember(uiState.workedGrids, vuccByMyGrid, selectedMyGrid) {
if (selectedMyGrid == null) uiState.workedGrids
else vuccByMyGrid[selectedMyGrid] ?: emptySet()
// Worked grids drawn on the map: filtered by the selected 台址 (null = all).
// Drives both the green fills and the tap listener below, so switching the
// selector changes which cells are green.
val workedGrids = remember(uiState.workedGrids, stationGroups, selectedStationId) {
if (selectedStationId == null) uiState.workedGrids
else stationGroups.firstOrNull { it.id == selectedStationId }?.worked ?: emptySet()
}
val isGridMode = uiState.isGridMode
// True when this composition restored a saved viewport. Only grid mode
@@ -300,6 +320,9 @@ private fun MapScreen(
val firstPos = uiState.track?.firstOrNull()?.firstOrNull() ?: return@LaunchedEffect
mapView.controller.animateTo(GeoPoint(firstPos.latitude, firstPos.longitude))
}
LaunchedEffect(uiState.mapSource, uiState.tiandituKey) {
configureTileSources(mapView, uiState.mapSource, uiState.tiandituKey)
}
Column(modifier = Modifier.layoutPadding(), verticalArrangement = Arrangement.spacedBy(6.dp)) {
val isVertical = isVerticalLayout()
if (isGridMode) {
@@ -382,17 +405,18 @@ private fun MapScreen(
}
uiState.mapData?.let { mapData ->
if (!uiState.isGridMode) {
if (isVertical) MapDataCard(mapData) else MapDataCards(mapData)
if (isVertical) MapDataCard(mapData, copyrightResId)
else MapDataCards(mapData, copyrightResId)
}
}
// Top-left: operated-grid selector for VUCC counting (grid mode only).
// Top-left: 台址 selector for VUCC counting (grid mode only).
// Shown whenever per-grid QSO data exists; "All" (null) is available.
if (uiState.isGridMode && selectedAward == AwardType.VUCC && vuccByMyGrid.isNotEmpty()) {
if (uiState.isGridMode && selectedAward == AwardType.VUCC && stationGroups.isNotEmpty()) {
VuccGridSelector(
options = vuccByMyGrid,
stations = stationGroups,
allCount = uiState.workedGrids.size,
selected = selectedMyGrid,
onSelect = { selectedMyGrid = it },
selected = selectedStationId,
onSelect = { selectedStationId = it },
modifier = Modifier
.align(Alignment.TopStart)
.padding(8.dp)
@@ -761,11 +785,11 @@ private fun formatTime(epochMs: Long, isUtc: Boolean): String {
// region Map data composables
@Composable
private fun MapDataCard(data: MapData) {
private fun MapDataCard(data: MapData, copyrightResId: Int) {
val textColor = MaterialTheme.colorScheme.primary
val cardColors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerLowest)
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(text = stringResource(R.string.map_copyright), fontSize = 14.sp)
Text(text = stringResource(copyrightResId), fontSize = 14.sp)
Card(colors = cardColors) {
Column(modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)) {
MapDataRow(
@@ -803,7 +827,7 @@ private fun MapDataRow(
}
@Composable
private fun MapDataCards(data: MapData) {
private fun MapDataCards(data: MapData, copyrightResId: Int) {
val cardColors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerLowest)
val paddingMod = Modifier
.padding(horizontal = 8.dp, vertical = 4.dp)
@@ -830,7 +854,7 @@ private fun MapDataCards(data: MapData) {
}
}
Text(
text = stringResource(R.string.map_copyright),
text = stringResource(copyrightResId),
fontSize = 14.sp,
modifier = Modifier.align(Alignment.BottomCenter)
)
@@ -881,22 +905,42 @@ private fun AwardChipsRow(
}
/**
* Compact pill toggle for switching between satellite view and grid mode,
* One 台址 shown by the operated-grid selector. A 台址 is a station location
* and can span SEVERAL grids (MY_GRIDSQUARE + MY_VUCC_GRIDS); groups are keyed
* by the 台址's full grid set, so a multi-grid 台址 is one entry listing all
* its grids. Data synced before multi-grid support falls back to one group
* per grid.
*/
private data class StationGroup(
/** Grid-set key (sorted 4-char grids joined by ","). */
val id: String,
/** Full grid set of this 台址 (1..n 4-char grids). */
val grids: Set<String>,
/** Distinct worked grids worked under this 台址 (any of its grids). */
val worked: Set<String>
) {
val workedCount: Int get() = worked.size
/** Selector label: the grids themselves — "OL62" for a single-grid 台址,
* "OM60,PM01" for a multi-grid one (matches how 台址 are described). */
val label: String get() = grids.sorted().joinToString(",")
}
/** Compact pill toggle for switching between satellite view and grid mode,
* floated over the map's top-right corner. The label reflects the active
* mode ("Grid mode" when ON, "Satellite mode" when OFF). Semi-transparent
* background keeps the map readable underneath.
*/
* background keeps the map readable underneath. */
@Composable
private fun VuccGridSelector(
options: Map<String, Set<String>>,
stations: List<StationGroup>,
allCount: Int,
selected: String?,
onSelect: (String?) -> Unit,
modifier: Modifier = Modifier
) {
var expanded by remember { mutableStateOf(false) }
// Most-worked grid first; selection is made on tap. "All" sits last.
val sorted = remember(options) { options.entries.sortedByDescending { it.value.size } }
// Most-worked 台址 first; selection is made on tap. "All" sits last.
val sorted = remember(stations) { stations.sortedByDescending { it.workedCount } }
val selectedLabel = stations.firstOrNull { it.id == selected }?.label
Surface(
color = ComposeColor.Black.copy(alpha = 0.45f),
shape = RoundedCornerShape(8.dp),
@@ -914,8 +958,8 @@ private fun VuccGridSelector(
.padding(start = 10.dp, end = 6.dp, top = 4.dp, bottom = 4.dp)
) {
Text(
text = if (selected == null) "All ($allCount)"
else "$selected (${options[selected]?.size ?: 0})",
text = if (selectedLabel == null) "All ($allCount)"
else "$selectedLabel (${stations.firstOrNull { it.id == selected }?.workedCount ?: 0})",
color = ComposeColor.White,
fontSize = 12.sp,
fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace
@@ -929,20 +973,20 @@ private fun VuccGridSelector(
}
if (expanded) {
androidx.compose.material3.HorizontalDivider(color = ComposeColor.White.copy(alpha = 0.2f))
sorted.forEach { (grid, grids) ->
sorted.forEach { station ->
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.clickable {
onSelect(grid)
onSelect(station.id)
expanded = false
}
.padding(horizontal = 10.dp, vertical = 3.dp)
) {
Text(
text = "$grid (${grids.size})",
color = if (grid == selected) MaterialTheme.colorScheme.primary else ComposeColor.White,
text = "${station.label} (${station.workedCount})",
color = if (station.id == selected) MaterialTheme.colorScheme.primary else ComposeColor.White,
fontSize = 12.sp,
fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace
)
@@ -1045,6 +1089,100 @@ private fun FirstCallToggle(
// endregion
// region Tile sources
private var configuredTileMapView: MapView? = null
private var configuredTileSource: String? = null
private fun configureTileSources(mapView: MapView, mapSource: String, tiandituKey: String) {
val key = tiandituKey.trim()
val layers = tiandituLayers(mapSource)
val sourceKey = if (layers == null || key.isBlank()) MapSource.OSM else "${layers.base}:$key"
if (configuredTileMapView === mapView && configuredTileSource == sourceKey) return
configuredTileMapView = mapView
configuredTileSource = sourceKey
(mapView.overlays.getOrNull(OVERLAY_TDT_LABELS) as? TilesOverlay)?.onDetach(mapView)
if (layers == null || key.isBlank()) {
mapView.setUseDataConnection(false)
mapView.setTileSource(offlineTileSource)
mapView.maxZoomLevel = OFFLINE_MAX_ZOOM
mapView.overlayManager.tilesOverlay.applyTileOverlayDefaults(createColorFilter())
mapView.overlays[OVERLAY_TDT_LABELS] = FolderOverlay()
requestTileRefresh(mapView)
return
}
mapView.setUseDataConnection(true)
mapView.setTileSource(TiandituTileSource(layer = layers.base, key = key))
mapView.maxZoomLevel = TIANDITU_MAX_ZOOM
mapView.overlayManager.tilesOverlay.applyTileOverlayDefaults(null)
mapView.overlays[OVERLAY_TDT_LABELS] = TilesOverlay(
MapTileProviderBasic(mapView.context, TiandituTileSource(layer = layers.label, key = key)),
mapView.context
).apply {
applyTileOverlayDefaults(null)
setUseDataConnection(true)
}
requestTileRefresh(mapView)
}
private data class TiandituLayers(val base: String, val label: String)
private fun tiandituLayers(mapSource: String): TiandituLayers? = when (MapSource.normalize(mapSource)) {
MapSource.TIANDITU_VECTOR -> TiandituLayers(base = "vec", label = "cva")
MapSource.TIANDITU_IMAGE -> TiandituLayers(base = "img", label = "cia")
else -> null
}
private fun TilesOverlay.applyTileOverlayDefaults(colorFilter: ColorMatrixColorFilter?) {
setColorFilter(colorFilter)
setLoadingBackgroundColor(Color.TRANSPARENT)
setLoadingLineColor(Color.TRANSPARENT)
}
private fun requestTileRefresh(mapView: MapView) {
mapView.post {
mapView.requestLayout()
mapView.invalidate()
}
}
private fun buildTiandituWmtsUrl(
baseUrl: String,
layer: String,
zoom: Int,
row: Int,
col: Int,
encodedKey: String
): String = "$baseUrl?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0&LAYER=$layer" +
"&STYLE=default&TILEMATRIXSET=w&FORMAT=tiles&TILEMATRIX=$zoom&TILEROW=$row&TILECOL=$col&tk=$encodedKey"
private class TiandituTileSource(
private val layer: String,
key: String
) : OnlineTileSourceBase(
"tianditu-wmts-https-$layer",
1,
TIANDITU_MAX_ZOOM.toInt(),
256,
".png",
Array(8) { index -> "https://t$index.tianditu.gov.cn/${layer}_w/wmts" },
"Tianditu"
) {
private val encodedKey = URLEncoder.encode(key, Charsets.UTF_8.name())
override fun getTileURLString(pMapTileIndex: Long): String = buildTiandituWmtsUrl(
baseUrl = getBaseUrl(),
layer = layer,
zoom = MapTileIndex.getZoom(pMapTileIndex),
row = MapTileIndex.getY(pMapTileIndex),
col = MapTileIndex.getX(pMapTileIndex),
encodedKey = encodedKey
)
}
// endregion
// region Map overlay helpers
/**
@@ -1463,7 +1601,6 @@ private fun rememberMapViewWithLifecycle(
mapFilterViewModel: MapFilterViewModel,
restoreViewport: Boolean
): MapView {
val tileSource = XYTileSource("tiles", 0, 6, 256, ".webp", emptyArray<String>())
val context = LocalContext.current
val isVertical = isVerticalLayout()
val mapView = remember {
@@ -1475,9 +1612,9 @@ private fun rememberMapViewWithLifecycle(
// draws a full-height artifact line whenever its X range crosses
// the screen at low zoom. The map must never repeat vertically.
setVerticalMapRepetitionEnabled(false)
setTileSource(tileSource)
setTileSource(offlineTileSource)
minZoomLevel = getMinZoom(resources.displayMetrics.heightPixels, isVertical)
maxZoomLevel = 7.0
maxZoomLevel = OFFLINE_MAX_ZOOM
// Restore the viewport the user left on the previous visit, but only
// in grid mode: satellite mode intentionally keeps its original
// behavior (default center, then follows the selected satellite).
@@ -1522,6 +1659,8 @@ private fun clearMapCaches() {
lastPositions = null
lastAction = null
lastMapView = null
configuredTileMapView = null
configuredTileSource = null
}
@Composable
@@ -21,11 +21,14 @@ import com.rtbishop.look4sat.core.domain.predict.GeoPos
import com.rtbishop.look4sat.core.domain.predict.OrbitalObject
import com.rtbishop.look4sat.core.domain.predict.OrbitalPass
import com.rtbishop.look4sat.core.domain.predict.OrbitalPos
import com.rtbishop.look4sat.core.domain.model.MapSource
data class MapState(
val mapData: MapData? = null,
val isLightUi: Boolean = false,
val isUtc: Boolean = false,
val mapSource: String = MapSource.OSM,
val tiandituKey: String = "",
val isGridMode: Boolean = false,
/** Grid mode: label worked (green) cells with the first callsign worked in
* that grid instead of the grid code; non-worked cells get no label. */
@@ -63,6 +63,8 @@ class MapViewModel(
MapState(
isLightUi = settingsRepo.otherSettings.value.stateOfLightTheme,
isUtc = settingsRepo.otherSettings.value.stateOfUtc,
mapSource = settingsRepo.otherSettings.value.mapSource,
tiandituKey = settingsRepo.otherSettings.value.tiandituKey,
orbitalPass = defaultPass
)
)
@@ -82,6 +84,8 @@ class MapViewModel(
_uiState.update {
it.copy(
isUtc = settings.stateOfUtc,
mapSource = settings.mapSource,
tiandituKey = settings.tiandituKey,
isGridMode = settings.stateOfMapGrid,
showFirstCallLabels = settings.stateOfMapFirstCall
)
@@ -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
@@ -303,27 +304,15 @@ 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()) {
// 回退到数据库模式过滤: 只用带上行频率的转发器记录, 排除纯下行
// 信标/遥测(如 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.getSatelliteIdsWithModesAndUplink(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 }
// 转发器类型筛选: 使用硬编码 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
@@ -54,9 +54,7 @@ class FakeSatelliteRepo(
* state; everything else the ViewModel never touches fails loudly.
*/
class FakeSettingsRepo(
initialPosition: GeoPos = GeoPos(23.13, 113.26),
val amSatFm: Set<Int> = emptySet(),
val amSatLinear: Set<Int> = emptySet()
initialPosition: GeoPos = GeoPos(23.13, 113.26)
) : ISettingsRepo {
override val stationPosition = MutableStateFlow(initialPosition)
@@ -110,13 +108,6 @@ class FakeSettingsRepo(
override fun updateDataSourcesStatus(status: Map<String, Int>) = TODO()
override fun getAmSatFmCatnums(): Set<Int> = amSatFm
override fun getAmSatLinearCatnums(): Set<Int> = amSatLinear
override val amSatListsVersion: StateFlow<Int> = MutableStateFlow(0)
override fun setAmSatCatnums(fmCatnums: Set<Int>, linearCatnums: Set<Int>) = TODO()
override fun getAmSatActiveCatnums(): Set<Int> = emptySet()
override fun setAmSatActiveCatnums(catnums: Set<Int>) = TODO()
override fun updateRadioControlSettings(settings: RadioControlSettings) = TODO()
override fun getSatelliteOffset(catnum: Int): String = ""
override fun setSatelliteOffset(catnum: Int, offset: String) = TODO()
@@ -31,12 +31,10 @@ class MutualViewModelTest {
private fun TestScope.createVm(
satellites: List<OrbitalObject> = emptyList(),
passes: List<OrbitalPass> = emptyList(),
position: GeoPos = TestOrbits.GUANGZHOU,
amSatFm: Set<Int> = emptySet(),
amSatLinear: Set<Int> = emptySet()
position: GeoPos = TestOrbits.GUANGZHOU
): MutualViewModel = MutualViewModel(
satelliteRepo = FakeSatelliteRepo(satellites, passes),
settingsRepo = FakeSettingsRepo(position, amSatFm, amSatLinear),
settingsRepo = FakeSettingsRepo(position),
computeDispatcher = StandardTestDispatcher(mainDispatcherRule.dispatcher.scheduler)
)
@@ -343,15 +341,15 @@ class MutualViewModelTest {
}
@Test
fun `AMSAT filter restricts results to listed catnums`() = runTest(mainDispatcherRule.dispatcher.scheduler) {
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())
// Only ISS (25544) is on the AMSAT live FM list; ISS_VARIANT (25545) is not.
// 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,
amSatFm = setOf(25544)
passes = windows
)
vm.onStationBGrid("OL62")
vm.onHoursAhead(24)
@@ -361,19 +359,18 @@ class MutualViewModelTest {
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 })
assertTrue("only hardcoded FM-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) {
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)
// FM filter off, Linear on: neither sat is linear-listed -> nothing matches.
// 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,
amSatFm = setOf(25544),
amSatLinear = setOf(25545)
passes = windows
)
vm.onStationBGrid("OL62")
vm.onHoursAhead(24)
@@ -382,7 +379,7 @@ class MutualViewModelTest {
queryAndSettle(vm)
val state = vm.uiState.value
assertNull(state.errorMessage)
assertTrue("only linear-listed satellites should appear", state.mutualPasses.all { it.catNum == 25545 })
// 空结果会设置 "No mutual passes found" 提示, 这是正常行为而非筛选错误.
assertTrue(state.mutualPasses.isEmpty())
}
}
@@ -0,0 +1,119 @@
/*
* Look4Sat. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2019-2026 Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.feature.settings
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.FilterChip
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp
import com.rtbishop.look4sat.core.domain.model.MapSource
import com.rtbishop.look4sat.core.domain.model.OtherSettings
import com.rtbishop.look4sat.core.presentation.R
import com.rtbishop.look4sat.core.presentation.SharedDialog
@Composable
fun MapSettingsDialog(
settings: OtherSettings,
onDismiss: () -> Unit,
onSave: (mapSource: String, tiandituKey: String) -> Unit
) {
var selectedSource by rememberSaveable {
mutableStateOf(MapSource.normalize(settings.mapSource))
}
var tiandituKey by rememberSaveable { mutableStateOf(settings.tiandituKey) }
SharedDialog(
title = stringResource(R.string.prefs_map_title),
onDismissRequest = onDismiss,
onCancel = onDismiss,
onAccept = { onSave(selectedSource, tiandituKey) }
) { padding ->
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.padding(horizontal = padding)
) {
Text(
text = stringResource(R.string.prefs_map_source_title),
color = MaterialTheme.colorScheme.primary
)
FlowRow(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
MapSourceChip(
selected = selectedSource == MapSource.OSM,
label = stringResource(R.string.prefs_map_source_osm),
onClick = { selectedSource = MapSource.OSM }
)
MapSourceChip(
selected = selectedSource == MapSource.TIANDITU_VECTOR,
label = stringResource(R.string.prefs_map_source_tianditu_vector),
onClick = { selectedSource = MapSource.TIANDITU_VECTOR }
)
MapSourceChip(
selected = selectedSource == MapSource.TIANDITU_IMAGE,
label = stringResource(R.string.prefs_map_source_tianditu_image),
onClick = { selectedSource = MapSource.TIANDITU_IMAGE }
)
}
Text(
text = stringResource(R.string.prefs_map_tianditu_key_help),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
OutlinedTextField(
value = tiandituKey,
onValueChange = { tiandituKey = it },
label = { Text(stringResource(R.string.prefs_map_tianditu_key)) },
visualTransformation = PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Password,
imeAction = ImeAction.Done
),
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
if (selectedSource != MapSource.OSM && tiandituKey.isBlank()) {
Text(
text = stringResource(R.string.prefs_map_tianditu_key_required),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error
)
}
}
}
}
@Composable
private fun MapSourceChip(selected: Boolean, label: String, onClick: () -> Unit) {
FilterChip(selected = selected, onClick = onClick, label = { Text(label) })
}
@@ -99,6 +99,7 @@ fun SettingsDestination() {
@Composable
private fun SettingsScreen(uiState: SettingsState, onAction: (SettingsAction) -> Unit) {
var showUpdateChecker by rememberSaveable { mutableStateOf(false) }
var showMapSettings by rememberSaveable { mutableStateOf(false) }
if (showUpdateChecker) {
UpdateCheckerScreen(
currentVersion = uiState.appVersionName,
@@ -129,6 +130,17 @@ private fun SettingsScreen(uiState: SettingsState, onAction: (SettingsAction) ->
}
)
if (showMapSettings) {
MapSettingsDialog(
settings = uiState.otherSettings,
onDismiss = { showMapSettings = false },
onSave = { mapSource, tiandituKey ->
onAction(SettingsAction.UpdateMapSettings(mapSource, tiandituKey))
showMapSettings = false
}
)
}
// Dialogs
if (dialogs.position) {
PositionDialog(
@@ -368,6 +380,7 @@ private fun SettingsScreen(uiState: SettingsState, onAction: (SettingsAction) ->
onAction = onAction
)
}
item { MapSettingsCard(onClick = { showMapSettings = true }) }
item { CardCredits() }
item(span = { GridItemSpan(maxLineSpan) }) {
CardButton(
@@ -558,6 +571,24 @@ private fun OtherCardPreview() = MainTheme {
OtherCard(settings = values, onCompassCalibration = {}, onAction = {})
}
@Composable
private fun MapSettingsCard(onClick: () -> Unit) {
ElevatedCard(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(horizontal = 8.dp, vertical = 6.dp)) {
Text(
text = stringResource(R.string.prefs_map_title),
color = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.height(6.dp))
CardButton(
onClick = onClick,
text = stringResource(R.string.prefs_map_configure),
modifier = Modifier.fillMaxWidth()
)
}
}
}
@Composable
private fun OtherCard(
settings: OtherSettings,
@@ -108,6 +108,7 @@ sealed interface SettingsAction {
data class SetCompassOffset(val degrees: Float) : SettingsAction
data class ToggleLightTheme(val value: Boolean) : SettingsAction
data class ToggleNightMode(val value: Boolean) : SettingsAction
data class UpdateMapSettings(val mapSource: String, val tiandituKey: String) : SettingsAction
// Remote control
data class UpdateRC(val settings: RCSettings) : SettingsAction
@@ -186,6 +186,9 @@ class SettingsViewModel(
}
is SettingsAction.ToggleLightTheme -> settingsRepo.updateOtherSettings { it.copy(stateOfLightTheme = action.value) }
is SettingsAction.ToggleNightMode -> settingsRepo.updateOtherSettings { it.copy(stateOfNightMode = action.value) }
is SettingsAction.UpdateMapSettings -> settingsRepo.updateOtherSettings {
it.copy(mapSource = action.mapSource, tiandituKey = action.tiandituKey)
}
// Remote control & data sources
is SettingsAction.UpdateRC -> settingsRepo.updateRCSettings(action.settings)
is SettingsAction.UpdateRadioControl -> settingsRepo.updateRadioControlSettings(action.settings)
+2 -2
View File
@@ -1,8 +1,8 @@
[versions]
#noinspection UnusedVersionCatalogEntry
appVersionCode = "502"
appVersionCode = "509"
#noinspection UnusedVersionCatalogEntry
appVersionName = "4.4.7-ba7opf.15.5"
appVersionName = "4.4.7-ba7opf.16"
#noinspection UnusedVersionCatalogEntry
compileSdk = "37"
#noinspection UnusedVersionCatalogEntry