feat(map): VUCC default chip, own-grid bold at all zooms, roamed-grid blue stripes

- Grid mode now opens with the VUCC award chip selected by default (re-asserted
  on every grid-mode entry; the user's in-mode chip choice is preserved).
- The station's own grid outline is now drawn at every zoom level: 2-char field
  border below the sub-square zoom, 4-char square border above it (was hidden
  at the default entry zoom), stroke widened to 6f.
- Grids the account operated from (LoTW <MY_GRIDSQUARE>, satellite QSOs only)
  are covered with GridMaster-style blue 45° zebra stripes. The stripe paint is
  fully opaque so a worked+roamed cell shows pure-blue stripes with green
  between them — the two colors never alpha-blend into a teal/green mix.
- LoTW sync now parses and persists the roamed set (LoTWResult.Success +
  SettingsRepo key 'roamedGrids'), plumbed through MapState/MapViewModel into
  MaidenheadGridOverlay. Unit tests cover MY_GRIDSQUARE parsing (SAT-only,
  GRIDSQUARE non-leak, 4-char truncation).
This commit is contained in:
atsunatsu committed 2026-09-11 20:27:46 +08:00
1 parent 84ba25c163
commit 1cc0bc770c
11 files changed
+232 -15

No files matched your search

@@ -47,8 +47,11 @@ class LoTWRepository : ILoTWRepository {
override suspend fun fetchConfirmedGrids(callsign: String, password: String): LoTWResult =
withContext(Dispatchers.IO) {
fetchReportBody(callsign, password).fold(
onSuccess = { body -> parseBoth(body)?.let { LoTWResult.Success(it.first, it.second) }
?: LoTWResult.RateLimited },
onSuccess = { body ->
parseBoth(body)?.let { (grids, _, roamed) ->
LoTWResult.Success(grids, emptyMap(), roamed)
} ?: LoTWResult.RateLimited
},
onFailure = { toResult(it) }
)
}
@@ -58,8 +61,11 @@ class LoTWRepository : ILoTWRepository {
password: String
): LoTWResult = withContext(Dispatchers.IO) {
fetchReportBody(callsign, password).fold(
onSuccess = { body -> parseBoth(body)?.let { LoTWResult.Success(it.first, it.second) }
?: LoTWResult.RateLimited },
onSuccess = { body ->
parseBoth(body)?.let { (grids, qsos, roamed) ->
LoTWResult.Success(grids, qsos, roamed)
} ?: LoTWResult.RateLimited
},
onFailure = { toResult(it) }
)
}
@@ -71,11 +77,14 @@ class LoTWRepository : ILoTWRepository {
else -> LoTWResult.NetworkError(e.message ?: e.javaClass.simpleName)
}
/** Single report fetch feeding both the grid set and the per-QSO detail. */
private fun parseBoth(body: String): Pair<Set<String>, Map<String, List<com.rtbishop.look4sat.core.domain.model.GridQso>>>? {
/** Single report fetch feeding the grid set, the per-QSO detail and the roamed set. */
private fun parseBoth(
body: String
): Triple<Set<String>, Map<String, List<com.rtbishop.look4sat.core.domain.model.GridQso>>, Set<String>>? {
val grids = parseConfirmedGrids(body) ?: return null
val qsos = parseConfirmedGridQsos(body) ?: return null
return grids to qsos
val roamed = parseRoamedGrids(body) ?: return null
return Triple(grids, qsos, roamed)
}
private fun fetchReportBody(callsign: String, password: String): Result<String> {
@@ -294,6 +303,39 @@ class LoTWRepository : ILoTWRepository {
return grids
}
/**
* Distinct 4-char gridsquares the account itself operated from, taken from
* ADIF <MY_GRIDSQUARE> of satellite QSO records only. This is the
* "roamed / activated" set shown as blue stripes on the map — the grids
* where the operator's own station was located during confirmed QSOs (a
* rover may log several distinct grids; the home grid is included too).
* Returns null when the body is not an ADIF report (mirrors the other parsers).
*/
internal fun parseRoamedGrids(body: String): Set<String>? {
if (!body.contains("<eoh>", ignoreCase = true)) return null
val grids = mutableSetOf<String>()
var propMode: String? = null
for (raw in body.lineSequence()) {
val line = raw.trim()
when {
line.equals("<EOR>", ignoreCase = true) -> propMode = null
line.startsWith("<PROP_MODE:") -> {
propMode = line.substringAfter('>').substringBefore("E<").trim().uppercase()
}
line.startsWith("<MY_GRIDSQUARE:") -> {
// MY_GRIDSQUARE must not be mistaken for GRIDSQUARE (the
// opposite station's grid) — only own-station grids count.
if (propMode == "SAT") {
val value = line.substringAfter('>').substringBefore("E<")
val field = value.trim().uppercase()
if (field.length >= 4) grids.add(field.take(4))
}
}
}
}
return grids
}
private companion object {
const val BASE_URL = "https://lotw.arrl.org/lotwuser/lotwreport.adi"
}
@@ -239,6 +239,29 @@ class SettingsRepo(
preferences.edit { putString(keyWorkedGridQsos, root.toString()) }
}
// Distinct 4-char gridsquares the account operated from (LoTW <MY_GRIDSQUARE>,
// satellite QSOs only). Stored as a JSONArray like workedGrids.
private val keyRoamedGrids = "roamedGrids"
override fun getRoamedGrids(): Set<String> {
val json = preferences.getString(keyRoamedGrids, null).orEmpty()
if (json.isBlank()) return emptySet()
return try {
val array = org.json.JSONArray(json)
(0 until array.length()).mapNotNull { i ->
array.optString(i).takeIf { it.isNotBlank() }
}.toSet()
} catch (_: Exception) {
emptySet()
}
}
override fun setRoamedGrids(grids: Set<String>) {
val array = org.json.JSONArray()
grids.sorted().forEach { array.put(it) }
preferences.edit { putString(keyRoamedGrids, array.toString()) }
}
// LoTW credentials (stored locally on the device only)
private val keyLoTWCall = "lotwCallsign"
private val keyLoTWPass = "lotwPassword"
@@ -296,6 +296,14 @@ private class FakeSettingsRepo(dataSources: DataSourcesSettings = defaultDataSou
override fun getWorkedGridQsos(): Map<String, List<com.rtbishop.look4sat.core.domain.model.GridQso>> = emptyMap()
override fun setWorkedGridQsos(qsos: Map<String, List<com.rtbishop.look4sat.core.domain.model.GridQso>>) = Unit
override fun getRoamedGrids(): Set<String> = emptySet()
private val roamedGrids = MutableStateFlow(emptySet<String>())
override fun setRoamedGrids(grids: Set<String>) {
roamedGrids.value = grids
}
}
private fun defaultDataSourcesSettings(): DataSourcesSettings {
@@ -130,6 +130,46 @@ class LoTWRepositoryTest {
// endregion
// region parseRoamedGrids (MY_GRIDSQUARE = grids the account operated from)
@Test
fun parseRoamedGridsCollectsOwnStationGrids() {
// Satellite QSOs carry MY_GRIDSQUARE = the grid the account itself
// operated from (the "roamed/activated" set shown as blue stripes).
val qso1 = "<CALL:6>BA7OPF\n<QSO_DATE:8>20260820\n<PROP_MODE:3>SAT\n<SAT_NAME:5>IO-86\n" +
"<MY_GRIDSQUARE:4>OL62\n<GRIDSQUARE:4>PM95\n<EOR>\n"
val qso2 = "<CALL:6>BA7OPF\n<QSO_DATE:8>20260901\n<PROP_MODE:3>SAT\n<SAT_NAME:5>IO-86\n" +
"<MY_GRIDSQUARE:4>OL72\n<GRIDSQUARE:4>NL47\n<EOR>\n"
assertEquals(setOf("OL62", "OL72"), repo.parseRoamedGrids(report(qso1, qso2)))
}
@Test
fun parseRoamedGridsIgnoresGroundQsos() {
val ground = "<CALL:6>BA7OPF\n<QSO_DATE:8>20260821\n<MY_GRIDSQUARE:4>OL72\n<EOR>\n"
assertEquals(emptySet<String>(), repo.parseRoamedGrids(report(ground)))
}
@Test
fun parseRoamedGridsDoesNotConfuseGridsquareWithMyGridsquare() {
// GRIDSQUARE (opposite station's grid) must never leak into the roamed set.
val qso = "<PROP_MODE:3>SAT\n<SAT_NAME:5>SO-50\n<MY_GRIDSQUARE:4>OL62\n" +
"<GRIDSQUARE:6>OM60IL\n<EOR>\n"
assertEquals(setOf("OL62"), repo.parseRoamedGrids(report(qso)))
}
@Test
fun parseRoamedGridsTruncatesSixCharToFour() {
val qso = "<PROP_MODE:3>SAT\n<SAT_NAME:5>IO-86\n<MY_GRIDSQUARE:6>OL62AA\n<EOR>\n"
assertEquals(setOf("OL62"), repo.parseRoamedGrids(report(qso)))
}
@Test
fun parseRoamedGridsRejectsBodyWithoutEoh() {
assertNull(repo.parseRoamedGrids("<HTML>Username/password incorrect</HTML>"))
}
// endregion
// region failure classification (fetchReportBody -> LoTWResult mapping)
@Test