mirror of
https://github.com/atsunatsu/Look4Sat.git
synced 2026-10-02 03:15:37 +00:00
Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d33629f015 | ||
|
|
7081ffaa60 | ||
|
|
3b5b5fe627 | ||
|
|
ebe32ed153 | ||
|
|
091d3e867b | ||
|
|
26e50ddab5 |
No files matched your search
+73
-11
@@ -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,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,6 +110,7 @@ class SettingsRepo(
|
||||
private val keySatelliteEnabled = "satelliteEnabled"
|
||||
private val keyTransceiversEnabled = "transceiversEnabled"
|
||||
private val keySatnogsTleSourceMigration = "satnogsTleSourceMigration"
|
||||
private val keyAutoTleSourceMigration = "autoTleSourceMigration"
|
||||
private val separatorComma = ","
|
||||
private val separatorUrl = "\n"
|
||||
private val legacyCelestrakSatnogsUrl =
|
||||
@@ -210,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
|
||||
@@ -239,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)
|
||||
@@ -597,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)
|
||||
@@ -614,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
|
||||
}
|
||||
@@ -634,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
|
||||
|
||||
@@ -656,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
|
||||
}
|
||||
@@ -666,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() },
|
||||
@@ -727,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 =
|
||||
|
||||
+71
@@ -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)
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
+119
@@ -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) })
|
||||
}
|
||||
+31
@@ -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
|
||||
|
||||
+3
@@ -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)
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
[versions]
|
||||
#noinspection UnusedVersionCatalogEntry
|
||||
appVersionCode = "507"
|
||||
appVersionCode = "509"
|
||||
#noinspection UnusedVersionCatalogEntry
|
||||
appVersionName = "4.4.7-ba7opf.15.10"
|
||||
appVersionName = "4.4.7-ba7opf.16"
|
||||
#noinspection UnusedVersionCatalogEntry
|
||||
compileSdk = "37"
|
||||
#noinspection UnusedVersionCatalogEntry
|
||||
|
||||
Reference in new issue
Block a user