mirror of
https://github.com/atsunatsu/Look4Sat.git
synced 2026-10-04 03:37:21 +00:00
feat(map): tap worked grid at zoom>=7 to show per-grid satellite QSO dialog
- LoTW sync now stores per-QSO detail (call/time/sat/mode/bands) grouped by gridsquare (PROP_MODE=SAT only), single JSON blob in SharedPreferences - GridQso model derives band label (V/U, U/V, V/A...) from BAND_RX/BAND - Tap detection via osmdroid MapEventsOverlay; selected grid outlined green - Centered dialog: calls sorted by first-contact time, first-QSO summary, expandable call rows list subsequent QSOs in that grid; tap outside closes
This commit is contained in:
1 parent
9ed2aebc9d
commit
39d4498a54
14 files changed
+557
-14
No files matched your search
@@ -85,6 +85,144 @@ class LoTWRepository : ILoTWRepository {
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun fetchConfirmedGridQsos(
|
||||
callsign: String,
|
||||
password: String
|
||||
): Pair<Set<String>, Map<String, List<com.rtbishop.look4sat.core.domain.model.GridQso>>>? {
|
||||
// Single report fetch: grids are derived from the same body as the
|
||||
// per-QSO detail (avoids a second ARRL hit and keeps the two datasets
|
||||
// perfectly consistent).
|
||||
val body = fetchReportBody(callsign, password) ?: return null
|
||||
val grids = parseConfirmedGrids(body) ?: return null
|
||||
val qsos = parseConfirmedGridQsos(body) ?: return null
|
||||
return grids to qsos
|
||||
}
|
||||
|
||||
private fun fetchReportBody(callsign: String, password: String): String? {
|
||||
val call = callsign.trim().uppercase()
|
||||
val pwd = password.trim()
|
||||
if (call.isBlank() || pwd.isBlank()) return null
|
||||
val since = "2000-01-01"
|
||||
val query = buildString {
|
||||
append("login=").append(URLEncoder.encode(call, "UTF-8"))
|
||||
append("&password=").append(URLEncoder.encode(pwd, "UTF-8"))
|
||||
append("&qso_query=1&qso_qsl=yes&qso_qsldetail=yes&qso_mydetail=yes")
|
||||
append("&qso_qslsince=").append(URLEncoder.encode(since, "UTF-8"))
|
||||
}
|
||||
return try {
|
||||
val connection = URL("$BASE_URL?$query").openConnection() as HttpURLConnection
|
||||
connection.connectTimeout = 30_000
|
||||
connection.readTimeout = 120_000
|
||||
connection.requestMethod = "GET"
|
||||
connection.setRequestProperty("Accept-Encoding", "gzip")
|
||||
connection.instanceFollowRedirects = true
|
||||
val code = connection.responseCode
|
||||
if (code !in 200..299) {
|
||||
connection.disconnect()
|
||||
return null
|
||||
}
|
||||
val stream = connection.inputStream
|
||||
val body = ("gzip".equals(connection.contentEncoding, ignoreCase = true))
|
||||
.let { gz -> if (gz) java.util.zip.GZIPInputStream(stream) else stream }
|
||||
.bufferedReader().use { it.readText() }
|
||||
connection.disconnect()
|
||||
if (body.contains(" password=") && !body.startsWith("ARRL")) return null
|
||||
body
|
||||
} catch (e: Exception) {
|
||||
println("LoTWRepository fetch failure: $e")
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
internal fun parseConfirmedGridQsos(
|
||||
body: String
|
||||
): Map<String, List<com.rtbishop.look4sat.core.domain.model.GridQso>>? {
|
||||
if (!body.contains("<eoh>", ignoreCase = true)) return null
|
||||
// One ADIF record = fields up to <EOR>. We buffer the fields we care
|
||||
// about, then emit a GridQso on <EOR> when the record is a satellite
|
||||
// QSO (PROP_MODE=SAT) carrying a grid.
|
||||
val result = mutableMapOf<String, MutableList<com.rtbishop.look4sat.core.domain.model.GridQso>>()
|
||||
var propMode: String? = null
|
||||
var call = ""
|
||||
var qsoDate = ""
|
||||
var timeOn = ""
|
||||
var satName = ""
|
||||
var mode = ""
|
||||
var bandUp = ""
|
||||
var bandDown = ""
|
||||
val gridsInRecord = mutableListOf<String>()
|
||||
|
||||
fun emitRecord() {
|
||||
if (propMode != "SAT" || gridsInRecord.isEmpty()) return
|
||||
val epochMs = adifTimestampToEpoch(qsoDate, timeOn)
|
||||
val qso = com.rtbishop.look4sat.core.domain.model.GridQso(
|
||||
call = call, epochMs = epochMs, satName = satName,
|
||||
mode = mode, bandUp = bandUp, bandDown = bandDown
|
||||
)
|
||||
for (grid in gridsInRecord) {
|
||||
result.getOrPut(grid) { mutableListOf() }.add(qso)
|
||||
}
|
||||
}
|
||||
|
||||
fun resetRecord() {
|
||||
propMode = null; call = ""; qsoDate = ""; timeOn = ""
|
||||
satName = ""; mode = ""; bandUp = ""; bandDown = ""
|
||||
gridsInRecord.clear()
|
||||
}
|
||||
|
||||
for (raw in body.lineSequence()) {
|
||||
val line = raw.trim()
|
||||
when {
|
||||
line.equals("<EOR>", ignoreCase = true) -> {
|
||||
emitRecord()
|
||||
resetRecord()
|
||||
}
|
||||
line.startsWith("<PROP_MODE:") ->
|
||||
propMode = adifValue(line).uppercase()
|
||||
line.startsWith("<CALL:") ->
|
||||
call = adifValue(line).uppercase()
|
||||
line.startsWith("<QSO_DATE:") ->
|
||||
qsoDate = adifValue(line)
|
||||
line.startsWith("<TIME_ON:") ->
|
||||
timeOn = adifValue(line)
|
||||
line.startsWith("<SAT_NAME:") ->
|
||||
satName = adifValue(line)
|
||||
line.startsWith("<MODE:") ->
|
||||
mode = adifValue(line)
|
||||
line.startsWith("<BAND_RX:") ->
|
||||
bandUp = adifValue(line).uppercase()
|
||||
line.startsWith("<BAND:") && !line.startsWith("<BAND_RX:") ->
|
||||
bandDown = adifValue(line).uppercase()
|
||||
line.startsWith("<GRIDSQUARE:") || line.startsWith("<VUCC_GRIDS:") -> {
|
||||
// VUCC_GRIDS holds a comma-separated PAIR of grids
|
||||
// ("EN52en,EN53fa"); split and keep every 4-char field.
|
||||
adifValue(line).split(',').forEach { grid ->
|
||||
val field = grid.trim().uppercase()
|
||||
if (field.length >= 4) gridsInRecord.add(field.take(4))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/** ADIF field value: "<GRIDSQUARE:4>OL62" -> "OL62". */
|
||||
private fun adifValue(line: String): String =
|
||||
line.substringAfter('>').substringBefore("E<").trim()
|
||||
|
||||
/** "20260820" + "1130" (or "113000") -> UTC epoch ms; 0 when unparseable. */
|
||||
private fun adifTimestampToEpoch(date: String, time: String): Long = try {
|
||||
val d = date.trim()
|
||||
val t = time.trim().padEnd(6, '0').take(6)
|
||||
val fmt = java.time.format.DateTimeFormatterBuilder()
|
||||
.appendPattern("yyyyMMddHHmmss")
|
||||
.toFormatter()
|
||||
.withZone(java.time.ZoneOffset.UTC)
|
||||
java.time.Instant.from(fmt.parse(d + t)).toEpochMilli()
|
||||
} catch (_: Exception) {
|
||||
0L
|
||||
}
|
||||
|
||||
internal fun parseConfirmedGrids(body: String): Set<String>? {
|
||||
// LoTW answers with ADIF text; on bad credentials it returns a short error page
|
||||
// containing "password=?" or an <eoh>-less block. Treat anything without a header
|
||||
|
||||
@@ -179,6 +179,57 @@ class SettingsRepo(
|
||||
preferences.edit { putString(keyWorkedGrids, array.toString()) }
|
||||
}
|
||||
|
||||
// Confirmed satellite QSOs per worked gridsquare, persisted as a single JSON
|
||||
// object: {"OL62":[{"c":call,"t":epochMs,"s":sat,"m":mode,"bu":up,"bd":down}]}.
|
||||
private val keyWorkedGridQsos = "workedGridQsos"
|
||||
|
||||
override fun getWorkedGridQsos(): Map<String, List<com.rtbishop.look4sat.core.domain.model.GridQso>> {
|
||||
val json = preferences.getString(keyWorkedGridQsos, null).orEmpty()
|
||||
if (json.isBlank()) return emptyMap()
|
||||
return try {
|
||||
val root = org.json.JSONObject(json)
|
||||
val result = mutableMapOf<String, List<com.rtbishop.look4sat.core.domain.model.GridQso>>()
|
||||
for (grid in root.keys()) {
|
||||
val array = root.optJSONArray(grid) ?: continue
|
||||
val list = (0 until array.length()).mapNotNull { i ->
|
||||
val o = array.optJSONObject(i) ?: return@mapNotNull null
|
||||
com.rtbishop.look4sat.core.domain.model.GridQso(
|
||||
call = o.optString("c"),
|
||||
epochMs = o.optLong("t"),
|
||||
satName = o.optString("s"),
|
||||
mode = o.optString("m"),
|
||||
bandUp = o.optString("bu"),
|
||||
bandDown = o.optString("bd")
|
||||
)
|
||||
}
|
||||
if (list.isNotEmpty()) result[grid] = list
|
||||
}
|
||||
result
|
||||
} catch (_: Exception) {
|
||||
emptyMap()
|
||||
}
|
||||
}
|
||||
|
||||
override fun setWorkedGridQsos(qsos: Map<String, List<com.rtbishop.look4sat.core.domain.model.GridQso>>) {
|
||||
val root = org.json.JSONObject()
|
||||
for ((grid, list) in qsos) {
|
||||
val array = org.json.JSONArray()
|
||||
for (q in list) {
|
||||
array.put(
|
||||
org.json.JSONObject()
|
||||
.put("c", q.call)
|
||||
.put("t", q.epochMs)
|
||||
.put("s", q.satName)
|
||||
.put("m", q.mode)
|
||||
.put("bu", q.bandUp)
|
||||
.put("bd", q.bandDown)
|
||||
)
|
||||
}
|
||||
root.put(grid, array)
|
||||
}
|
||||
preferences.edit { putString(keyWorkedGridQsos, root.toString()) }
|
||||
}
|
||||
|
||||
// LoTW credentials (stored locally on the device only)
|
||||
private val keyLoTWCall = "lotwCallsign"
|
||||
private val keyLoTWPass = "lotwPassword"
|
||||
|
||||
@@ -19,6 +19,7 @@ package com.rtbishop.look4sat.core.data.repository
|
||||
|
||||
import com.rtbishop.look4sat.core.domain.model.DataSourcesSettings
|
||||
import com.rtbishop.look4sat.core.domain.model.DatabaseState
|
||||
import com.rtbishop.look4sat.core.domain.model.GridQso
|
||||
import com.rtbishop.look4sat.core.domain.model.OtherSettings
|
||||
import com.rtbishop.look4sat.core.domain.model.PassesSettings
|
||||
import com.rtbishop.look4sat.core.domain.model.RCSettings
|
||||
@@ -291,6 +292,10 @@ private class FakeSettingsRepo(dataSources: DataSourcesSettings = defaultDataSou
|
||||
MutableStateFlow(com.rtbishop.look4sat.core.domain.model.LoTWSettings())
|
||||
|
||||
override fun updateLoTWSettings(settings: com.rtbishop.look4sat.core.domain.model.LoTWSettings) = Unit
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
private fun defaultDataSourcesSettings(): DataSourcesSettings {
|
||||
|
||||
+52
@@ -65,4 +65,56 @@ class LoTWRepositoryTest {
|
||||
val satQso = "<PROP_MODE:3>SAT\n<SAT_NAME:5>AO-07\n<EOR>\n"
|
||||
assertEquals(emptySet<String>(), repo.parseConfirmedGrids(report(satQso)))
|
||||
}
|
||||
|
||||
// region parseConfirmedGridQsos
|
||||
|
||||
private val satQsoFull = "<CALL:5>A50QO\n" +
|
||||
"<QSO_DATE:8>20260820\n" +
|
||||
"<TIME_ON:6>113045\n" +
|
||||
"<PROP_MODE:3>SAT\n" +
|
||||
"<SAT_NAME:5>FO-29\n" +
|
||||
"<MODE:2>CW\n" +
|
||||
"<BAND:3>70CM\n" +
|
||||
"<BAND_RX:3>2M\n" +
|
||||
"<GRIDSQUARE:4>NL47\n" +
|
||||
"<EOR>\n"
|
||||
|
||||
@Test
|
||||
fun parseQsosExtractsSatelliteDetail() {
|
||||
val result = repo.parseConfirmedGridQsos(report(satQsoFull))!!
|
||||
val qsos = result["NL47"]!!
|
||||
assertEquals(1, qsos.size)
|
||||
val qso = qsos[0]
|
||||
assertEquals("A50QO", qso.call)
|
||||
assertEquals("FO-29", qso.satName)
|
||||
assertEquals("CW", qso.mode)
|
||||
assertEquals("2M", qso.bandUp)
|
||||
assertEquals("70CM", qso.bandDown)
|
||||
assertEquals("V/U", qso.bandLabel)
|
||||
assertEquals(2026, java.time.Instant.ofEpochMilli(qso.epochMs).atZone(java.time.ZoneOffset.UTC).year)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parseQsosExcludesGroundQsos() {
|
||||
val groundQso = "<CALL:6>BA7OPF\n<QSO_DATE:8>20260821\n<GRIDSQUARE:4>NL47\n<EOR>\n"
|
||||
val result = repo.parseConfirmedGridQsos(report(satQsoFull, groundQso))!!
|
||||
assertEquals(1, result["NL47"]!!.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parseQsosVuccPairFeedsBothGrids() {
|
||||
val qso = "<PROP_MODE:3>SAT\n<SAT_NAME:5>SO-50\n" +
|
||||
"<QSO_DATE:8>20260819\n<TIME_ON:4>1231\n" +
|
||||
"<VUCC_GRIDS:11>EN52en,EN53fa\n<EOR>\n"
|
||||
val result = repo.parseConfirmedGridQsos(report(qso))!!
|
||||
assertEquals(setOf("EN52", "EN53"), result.keys)
|
||||
assertEquals(result["EN52"]!!.first(), result["EN53"]!!.first())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parseQsosRejectsBodyWithoutEoh() {
|
||||
assertNull(repo.parseConfirmedGridQsos("<HTML>Username/password incorrect</HTML>"))
|
||||
}
|
||||
|
||||
// endregion
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
/*
|
||||
* 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.core.domain.model
|
||||
|
||||
/**
|
||||
* One confirmed satellite QSO as reported by LoTW, kept per worked gridsquare
|
||||
* so the map can show, for any worked grid, which callsigns were worked there
|
||||
* and when.
|
||||
*
|
||||
* @param call opposite station callsign (as logged by the opposite op)
|
||||
* @param epochMs QSO date+time in UTC milliseconds
|
||||
* @param satName satellite name (e.g. "FO-29")
|
||||
* @param mode ADIF mode (FM / CW / SSB ...)
|
||||
* @param bandUp uplink band as reported by LoTW (BAND_RX: "70CM", "2M", "10M"...)
|
||||
* @param bandDown downlink band (BAND: "2M", "70CM"...) — may be empty when
|
||||
* LoTW did not include it
|
||||
*/
|
||||
data class GridQso(
|
||||
val call: String,
|
||||
val epochMs: Long,
|
||||
val satName: String,
|
||||
val mode: String,
|
||||
val bandUp: String,
|
||||
val bandDown: String
|
||||
) {
|
||||
/** Short uplink/downlink band label ("U/V", "V/A"), or "" when unknown. */
|
||||
val bandLabel: String
|
||||
get() {
|
||||
val up = bandAbbrev(bandUp)
|
||||
val down = bandAbbrev(bandDown)
|
||||
return when {
|
||||
up.isNotEmpty() && down.isNotEmpty() -> "$up/$down"
|
||||
up.isNotEmpty() -> up
|
||||
else -> ""
|
||||
}
|
||||
}
|
||||
|
||||
private fun bandAbbrev(band: String): String = when (band.trim().uppercase()) {
|
||||
"70CM" -> "U"
|
||||
"2M" -> "V"
|
||||
"10M" -> "A"
|
||||
"6M" -> "V"
|
||||
"1.2G" -> "L"
|
||||
"2.4G" -> "S"
|
||||
"23CM" -> "L"
|
||||
"13CM" -> "S"
|
||||
else -> band.trim().uppercase()
|
||||
}
|
||||
}
|
||||
+10
@@ -25,4 +25,14 @@ interface ILoTWRepository {
|
||||
* Returns the 4-char grid set, or null on any failure (network / bad credentials).
|
||||
*/
|
||||
suspend fun fetchConfirmedGrids(callsign: String, password: String): Set<String>?
|
||||
|
||||
/**
|
||||
* Same report, but keeps the per-QSO detail of every confirmed satellite
|
||||
* QSO (call / time / satellite / mode / bands), grouped by worked 4-char
|
||||
* gridsquare. Returns null on any failure.
|
||||
*/
|
||||
suspend fun fetchConfirmedGridQsos(
|
||||
callsign: String,
|
||||
password: String
|
||||
): Pair<Set<String>, Map<String, List<com.rtbishop.look4sat.core.domain.model.GridQso>>>?
|
||||
}
|
||||
+3
@@ -99,6 +99,9 @@ interface ISettingsRepo {
|
||||
fun updateWavelogSettings(settings: WavelogSettings)
|
||||
fun getWorkedGrids(): Set<String>
|
||||
fun setWorkedGrids(grids: Set<String>)
|
||||
/** Confirmed satellite QSOs grouped by worked 4-char gridsquare (map tap detail). */
|
||||
fun getWorkedGridQsos(): Map<String, List<com.rtbishop.look4sat.core.domain.model.GridQso>>
|
||||
fun setWorkedGridQsos(qsos: Map<String, List<com.rtbishop.look4sat.core.domain.model.GridQso>>)
|
||||
//endregion
|
||||
|
||||
//region # LoTW confirmed-grids settings
|
||||
|
||||
@@ -118,6 +118,10 @@
|
||||
<string name="map_period">周期:% .0f min</string>
|
||||
<string name="map_velocity">速度:%.2f km/s</string>
|
||||
<string name="map_visibility">能见度:%s</string>
|
||||
<!-- Worked-grid QSO dialog -->
|
||||
<string name="grid_qso_calls_count">%d 个呼号 · %d 次通联</string>
|
||||
<string name="grid_qso_first">首通</string>
|
||||
<string name="grid_qso_no_sat">卫星未知</string>
|
||||
<string name="map_visible">日照中</string>
|
||||
|
||||
<!-- Settings screen -->
|
||||
|
||||
@@ -143,6 +143,10 @@
|
||||
<string name="map_period">Period: %.0f min</string>
|
||||
<string name="map_velocity">Velocity: %.2f km/s</string>
|
||||
<string name="map_visibility">Visibility: %s</string>
|
||||
<!-- Worked-grid QSO dialog -->
|
||||
<string name="grid_qso_calls_count">%d callsigns · %d QSOs</string>
|
||||
<string name="grid_qso_first">First</string>
|
||||
<string name="grid_qso_no_sat">satellite unknown</string>
|
||||
<string name="map_visible">Visible</string>
|
||||
|
||||
<!-- Settings screen -->
|
||||
|
||||
Reference in new issue
Block a user