mirror of
https://github.com/atsunatsu/Look4Sat.git
synced 2026-10-02 03:15:37 +00:00
Compare commits
36
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
36d091a7e1 | ||
|
|
20143cf096 | ||
|
|
a64f9c80b9 | ||
|
|
db0320f521 | ||
|
|
adc9ac008b | ||
|
|
4a33d1b19a | ||
|
|
cce9209b3d | ||
|
|
237e4cb27a | ||
|
|
cd3eded77c | ||
|
|
7d7f8f1eda | ||
|
|
11b46c6710 | ||
|
|
da1facac64 | ||
|
|
cb1f341ed9 | ||
|
|
c2e496c406 | ||
|
|
7afce7ee68 | ||
|
|
86048ee1fd | ||
|
|
42a6ffa4c9 | ||
|
|
cf1d8e6539 | ||
|
|
ab8b072f1f | ||
|
|
1d4036e3d0 | ||
|
|
ab22244f86 | ||
|
|
3b0874d707 | ||
|
|
2c5b1479f1 | ||
|
|
39d4498a54 | ||
|
|
9ed2aebc9d | ||
|
|
72d5e13acf | ||
|
|
736dc23aae | ||
|
|
052ba79c94 | ||
|
|
cb648caf65 | ||
|
|
05170541ed | ||
|
|
e75d2dc410 | ||
|
|
d55a07bc26 | ||
|
|
c372386a77 | ||
|
|
de6ade025c | ||
|
|
2eb6d6cd5b | ||
|
|
c09f718aae |
No files matched your search
+237
-39
@@ -18,11 +18,15 @@
|
||||
package com.rtbishop.look4sat.core.data.repository
|
||||
|
||||
import com.rtbishop.look4sat.core.domain.repository.ILoTWRepository
|
||||
import com.rtbishop.look4sat.core.domain.repository.LoTWResult
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.SocketTimeoutException
|
||||
import java.net.URL
|
||||
import java.net.URLEncoder
|
||||
import java.io.IOException
|
||||
import javax.net.ssl.SSLException
|
||||
|
||||
/**
|
||||
* Fetches confirmed gridsquares directly from ARRL LoTW via the official report endpoint:
|
||||
@@ -34,47 +38,222 @@ import java.net.URLEncoder
|
||||
* value; VUCC_GRIDS ("EN52en,EN53fa") also yields 4-char fields. All values are
|
||||
* truncated/expanded to the 4-char form used by the map overlay.
|
||||
*
|
||||
* ARRL rate-limits the report endpoint (roughly once per hour per account), which is
|
||||
* fine for a manual sync button.
|
||||
* ARRL rate-limits the report endpoint: one download in progress per user id, and
|
||||
* frequent full-report pulls get refused. Failures are reported with an explicit
|
||||
* cause (see LoTWResult) so the UI can tell the user what to do next.
|
||||
*/
|
||||
class LoTWRepository : ILoTWRepository {
|
||||
|
||||
override suspend fun fetchConfirmedGrids(callsign: String, password: String): Set<String>? =
|
||||
override suspend fun fetchConfirmedGrids(callsign: String, password: String): LoTWResult =
|
||||
withContext(Dispatchers.IO) {
|
||||
val call = callsign.trim().uppercase()
|
||||
val pwd = password.trim()
|
||||
if (call.isBlank() || pwd.isBlank()) return@withContext null
|
||||
// qso_qslsince with an early date forces a FULL confirmed-QSL report.
|
||||
// Without it, LoTW applies a "system supplied default" since-date and
|
||||
// only returns confirmations newer than the account's last query —
|
||||
// subsequent syncs would return an empty/incremental report.
|
||||
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"))
|
||||
}
|
||||
try {
|
||||
val connection = URL("$BASE_URL?$query").openConnection() as HttpURLConnection
|
||||
connection.connectTimeout = 15_000
|
||||
connection.readTimeout = 60_000
|
||||
connection.requestMethod = "GET"
|
||||
val code = connection.responseCode
|
||||
if (code !in 200..299) {
|
||||
connection.disconnect()
|
||||
return@withContext null
|
||||
}
|
||||
val body = connection.inputStream.bufferedReader().use { it.readText() }
|
||||
fetchReportBody(callsign, password).fold(
|
||||
onSuccess = { body -> parseBoth(body)?.let { LoTWResult.Success(it.first, it.second) }
|
||||
?: LoTWResult.RateLimited },
|
||||
onFailure = { toResult(it) }
|
||||
)
|
||||
}
|
||||
|
||||
override suspend fun fetchConfirmedGridQsos(
|
||||
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 },
|
||||
onFailure = { toResult(it) }
|
||||
)
|
||||
}
|
||||
|
||||
internal fun toResult(e: Throwable): LoTWResult = when (e) {
|
||||
is CredentialsException -> LoTWResult.BadCredentials
|
||||
is RateLimitException -> LoTWResult.RateLimited
|
||||
is TimeoutException -> LoTWResult.Timeout
|
||||
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>>>? {
|
||||
val grids = parseConfirmedGrids(body) ?: return null
|
||||
val qsos = parseConfirmedGridQsos(body) ?: return null
|
||||
return grids to qsos
|
||||
}
|
||||
|
||||
private fun fetchReportBody(callsign: String, password: String): Result<String> {
|
||||
val call = callsign.trim().uppercase()
|
||||
val pwd = password.trim()
|
||||
if (call.isBlank() || pwd.isBlank()) return Result.failure(IOException("empty credentials"))
|
||||
// qso_qslsince with an early date forces a FULL confirmed-QSL report.
|
||||
// Without it, LoTW applies a "system supplied default" since-date and
|
||||
// only returns confirmations newer than the account's last query —
|
||||
// subsequent syncs would return an empty/incremental report.
|
||||
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
|
||||
// ARRL can be slow to accept connections from mobile networks
|
||||
// (long TLS handshakes across the Pacific, occasional server-side
|
||||
// queueing). 30s connect + 120s read gives the request enough
|
||||
// headroom; the sync button stays disabled meanwhile so users
|
||||
// see progress rather than a hung dialog.
|
||||
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()
|
||||
if (body.contains(" password=") && !body.startsWith("ARRL")) return@withContext null
|
||||
parseConfirmedGrids(body)
|
||||
} catch (e: Exception) {
|
||||
println("LoTWRepository fetch failure: $e")
|
||||
null
|
||||
// Observed in the wild (CQRLOG #2422): LoTW answers a throttled
|
||||
// report pull with HTTP 503 "Page request limit".
|
||||
return if (code == 503 || code == 429) Result.failure(RateLimitException())
|
||||
else Result.failure(IOException("HTTP $code"))
|
||||
}
|
||||
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()
|
||||
when {
|
||||
// Login failure: HTTP 200 + HTML login page with the error text.
|
||||
body.contains("incorrect", ignoreCase = true) &&
|
||||
body.contains("assword", ignoreCase = true) -> Result.failure(CredentialsException())
|
||||
// Any other non-ADIF body: rate-limit refusal / server error page.
|
||||
!body.contains("<eoh>", ignoreCase = true) -> Result.failure(RateLimitException())
|
||||
else -> Result.success(body)
|
||||
}
|
||||
} catch (e: SocketTimeoutException) {
|
||||
Result.failure(TimeoutException(e.message ?: "timed out"))
|
||||
} catch (e: SSLException) {
|
||||
Result.failure(IOException("TLS: ${e.message ?: "handshake failed"}"))
|
||||
} catch (e: Exception) {
|
||||
println("LoTWRepository fetch failure: $e")
|
||||
Result.failure(IOException(e.message ?: e.javaClass.simpleName))
|
||||
}
|
||||
}
|
||||
|
||||
internal class CredentialsException : Exception("bad callsign/password")
|
||||
internal class RateLimitException : Exception("report refused (rate limit / server error)")
|
||||
internal class TimeoutException(message: String) : Exception(message)
|
||||
|
||||
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 = ""
|
||||
var dxcc: Int? = null
|
||||
var country: String? = null
|
||||
var cqz: Int? = null
|
||||
var state: String? = null
|
||||
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,
|
||||
dxcc = dxcc, country = country, cqz = cqz, state = state
|
||||
)
|
||||
for (grid in gridsInRecord) {
|
||||
result.getOrPut(grid) { mutableListOf() }.add(qso)
|
||||
}
|
||||
}
|
||||
|
||||
fun resetRecord() {
|
||||
propMode = null; call = ""; qsoDate = ""; timeOn = ""
|
||||
satName = ""; mode = ""; bandUp = ""; bandDown = ""
|
||||
dxcc = null; country = null; cqz = null; state = null
|
||||
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("<DXCC:") ->
|
||||
dxcc = adifValue(line).toIntOrNull()
|
||||
line.startsWith("<COUNTRY:") ->
|
||||
country = adifValue(line).ifBlank { null }
|
||||
line.startsWith("<CQZ:") ->
|
||||
cqz = adifValue(line).toIntOrNull()
|
||||
line.startsWith("<STATE:") ->
|
||||
state = adifValue(line).trim().ifBlank { null }?.let { normalizeState(it) }
|
||||
line.startsWith("<GRIDSQUARE:") || line.startsWith("<VUCC_GRIDS:") -> {
|
||||
// VUCC_GRIDS holds a comma-separated list of grids
|
||||
// ("EN52en,EN53fa"), up to four for contacts spanning
|
||||
// several squares; 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()
|
||||
|
||||
/**
|
||||
* LoTW returns STATE as "CODE // NAME" (e.g. "HB // Hubei" for China,
|
||||
* "34 // Tottori-ken" for Japan, "CA // California" for the US). The award
|
||||
* statistics match on the short CODE only (China 2-letter province pinyin,
|
||||
* Japan 2-digit prefecture, US 2-letter state), so strip the " // NAME"
|
||||
* suffix here once, storing the clean code for every downstream consumer
|
||||
* (persistence, AwardCalculator).
|
||||
*/
|
||||
private fun normalizeState(raw: String): String = raw.substringBefore(" // ").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
|
||||
@@ -84,14 +263,33 @@ class LoTWRepository : ILoTWRepository {
|
||||
// (LoTW writes it lowercase). Match case-insensitively to be safe.
|
||||
if (!body.contains("<eoh>", ignoreCase = true)) return null
|
||||
val grids = mutableSetOf<String>()
|
||||
// ADIF fields of one QSO record span multiple lines and are terminated by
|
||||
// <EOR>. Satellite QSOs carry <PROP_MODE:3>SAT (plus <SAT_NAME>); ground
|
||||
// QSOs omit it. Grid fields (GRIDSQUARE / VUCC_GRIDS) must only be
|
||||
// collected for records whose PROP_MODE is SAT, otherwise the map mixes
|
||||
// in terrestrial contacts.
|
||||
var propMode: String? = null
|
||||
for (raw in body.lineSequence()) {
|
||||
val line = raw.trim()
|
||||
val value = when {
|
||||
line.startsWith("<GRIDSQUARE:") -> line.substringAfter('>')
|
||||
line.startsWith("<VUCC_GRIDS:") -> line.substringAfter('>')
|
||||
else -> continue
|
||||
}.substringBefore("E<").trim().uppercase()
|
||||
if (value.length >= 4) grids.add(value.take(4))
|
||||
when {
|
||||
line.equals("<EOR>", ignoreCase = true) -> propMode = null
|
||||
line.startsWith("<PROP_MODE:") -> {
|
||||
propMode = line.substringAfter('>').substringBefore("E<").trim().uppercase()
|
||||
}
|
||||
line.startsWith("<GRIDSQUARE:") || line.startsWith("<VUCC_GRIDS:") -> {
|
||||
if (propMode == "SAT") {
|
||||
// VUCC_GRIDS holds a comma-separated PAIR of grids
|
||||
// ("EN52en,EN53fa") for contacts spanning two squares —
|
||||
// split on ',' and take the 4-char field of each, or the
|
||||
// second grid is silently dropped.
|
||||
val value = line.substringAfter('>').substringBefore("E<")
|
||||
value.split(',').forEach { grid ->
|
||||
val field = grid.trim().uppercase()
|
||||
if (field.length >= 4) grids.add(field.take(4))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return grids
|
||||
}
|
||||
|
||||
@@ -179,6 +179,66 @@ 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"),
|
||||
// New award fields: absent in pre-award data -> null.
|
||||
dxcc = o.optInt("dx", 0).takeIf { it > 0 },
|
||||
country = o.optString("cty").ifBlank { null },
|
||||
cqz = o.optInt("cq", 0).takeIf { it > 0 },
|
||||
state = o.optString("st").ifBlank { null }
|
||||
)
|
||||
}
|
||||
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)
|
||||
.put("dx", q.dxcc ?: 0)
|
||||
.put("cty", q.country ?: "")
|
||||
.put("cq", q.cqz ?: 0)
|
||||
.put("st", q.state ?: "")
|
||||
)
|
||||
}
|
||||
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"
|
||||
|
||||
+6
-4
@@ -66,16 +66,18 @@ class UpdateRepository(
|
||||
.replace("<", "<").replace(">", ">").replace("&", "&")
|
||||
.replace(""", "\"").replace("'", "'")
|
||||
.trim()
|
||||
// The release APK asset follows the fixed naming scheme used by the build:
|
||||
// Look4Sat-<tag without leading v>-release.apk. When the page was fetched
|
||||
// The release APK asset naming: Look4Sat-<tag without leading v>.apk
|
||||
// (since v4.4.6-ba7opf.9.9 the releases are uploaded with the bare
|
||||
// versioned filename, no "-release" suffix). When the page was fetched
|
||||
// through an accelerator mirror (https://<mirror>/https://github.com/...),
|
||||
// download through the same mirror — raw github.com is unreachable on the
|
||||
// networks that needed the mirror in the first place.
|
||||
val mirrorPrefix = sourceUrl.substringBefore("https://github.com")
|
||||
val apkName = "Look4Sat-${tag.removePrefix("v")}.apk"
|
||||
val apkUrl = if (mirrorPrefix.isEmpty()) {
|
||||
"$DOWNLOAD_BASE_URL/$tag/Look4Sat-${tag.removePrefix("v")}-release.apk"
|
||||
"$DOWNLOAD_BASE_URL/$tag/$apkName"
|
||||
} else {
|
||||
"${mirrorPrefix}https://github.com/atsunatsu/Look4Sat/releases/download/$tag/Look4Sat-${tag.removePrefix("v")}-release.apk"
|
||||
"${mirrorPrefix}https://github.com/atsunatsu/Look4Sat/releases/download/$tag/$apkName"
|
||||
}
|
||||
return LatestRelease(
|
||||
versionTag = tag,
|
||||
|
||||
+27
-1
@@ -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
|
||||
@@ -214,7 +215,10 @@ private class FakeSettingsRepo(dataSources: DataSourcesSettings = defaultDataSou
|
||||
)
|
||||
|
||||
override val otherSettings: StateFlow<OtherSettings> = MutableStateFlow(
|
||||
OtherSettings(false, false, false, false, false, false, false, false)
|
||||
OtherSettings(
|
||||
false, false, false, false, false, false, false,
|
||||
shouldSeeWarning = false, shouldSeeWhatsNew = false
|
||||
)
|
||||
)
|
||||
|
||||
override val dataSourcesSettings: MutableStateFlow<DataSourcesSettings> = MutableStateFlow(dataSources)
|
||||
@@ -270,6 +274,28 @@ private class FakeSettingsRepo(dataSources: DataSourcesSettings = defaultDataSou
|
||||
override fun getAmSatCallsign(): String = ""
|
||||
|
||||
override fun setAmSatCallsign(callsign: String) = Unit
|
||||
|
||||
override val wavelogSettings: StateFlow<com.rtbishop.look4sat.core.domain.model.WavelogSettings> =
|
||||
MutableStateFlow(com.rtbishop.look4sat.core.domain.model.WavelogSettings())
|
||||
|
||||
override fun updateWavelogSettings(settings: com.rtbishop.look4sat.core.domain.model.WavelogSettings) = Unit
|
||||
|
||||
override fun getWorkedGrids(): Set<String> = emptySet()
|
||||
|
||||
private val workedGrids = MutableStateFlow(emptySet<String>())
|
||||
|
||||
override fun setWorkedGrids(grids: Set<String>) {
|
||||
workedGrids.value = grids
|
||||
}
|
||||
|
||||
override val lotwSettings: StateFlow<com.rtbishop.look4sat.core.domain.model.LoTWSettings> =
|
||||
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 {
|
||||
|
||||
+216
@@ -0,0 +1,216 @@
|
||||
package com.rtbishop.look4sat.core.data.repository
|
||||
|
||||
import com.rtbishop.look4sat.core.domain.repository.LoTWResult
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
|
||||
class LoTWRepositoryTest {
|
||||
|
||||
private val repo = LoTWRepository()
|
||||
|
||||
private fun report(vararg records: String): String =
|
||||
buildString {
|
||||
append("<ADIF_VERS:5>3.1.4\n")
|
||||
append("<PROGRAMID:8>look4sat\n")
|
||||
append("<eoh>\n")
|
||||
records.forEach { append(it).append("\n") }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parseRejectsBodyWithoutEoh() {
|
||||
assertNull(repo.parseConfirmedGrids("<HTML>Username/password incorrect</HTML>"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parseCollectsOnlySatelliteGrids() {
|
||||
// Satellite QSO: PROP_MODE SAT, confirmed, opponent grid.
|
||||
val satQso = "<CALL:6>BA7OPF\n" +
|
||||
"<QSO_DATE:8>20260820\n" +
|
||||
"<PROP_MODE:3>SAT\n" +
|
||||
"<SAT_NAME:5>FO-29\n" +
|
||||
"<GRIDSQUARE:4>NL47\n" +
|
||||
"<EOR>\n"
|
||||
// Ground QSO: same grid but no PROP_MODE -> must be excluded.
|
||||
val groundQso = "<CALL:6>BA7OPF\n" +
|
||||
"<QSO_DATE:8>20260821\n" +
|
||||
"<GRIDSQUARE:4>NL47\n" +
|
||||
"<EOR>\n"
|
||||
val result = repo.parseConfirmedGrids(report(satQso, groundQso))
|
||||
assertEquals(setOf("NL47"), result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parseSupportsVuccGridPairsAndSixCharGrids() {
|
||||
val satQso = "<PROP_MODE:3>SAT\n" +
|
||||
"<SAT_NAME:5>SO-50\n" +
|
||||
"<GRIDSQUARE:6>OM60IL\n" +
|
||||
"<VUCC_GRIDS:11>EN52en,EN53fa\n" +
|
||||
"<EOR>\n"
|
||||
val result = repo.parseConfirmedGrids(report(satQso))
|
||||
assertEquals(setOf("OM60", "EN52", "EN53"), result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parsePropModeDoesNotLeakAcrossRecords() {
|
||||
// PROP_MODE SAT record first, then a ground QSO with a grid — the second
|
||||
// record must not inherit the satellite flag after its own <EOR>.
|
||||
val satQso = "<PROP_MODE:3>SAT\n<GRIDSQUARE:4>OL62\n<EOR>\n"
|
||||
val groundQso = "<GRIDSQUARE:4>PM00\n<EOR>\n"
|
||||
val result = repo.parseConfirmedGrids(report(satQso, groundQso))
|
||||
assertEquals(setOf("OL62"), result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parseReturnsEmptySetForReportWithoutGrids() {
|
||||
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 parseQsosVuccQuadrupleFeedsAllFourGrids() {
|
||||
// VUCC allows up to four grids in one contact.
|
||||
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:23>EN52en,EN53fa,EN42gj,EN43kh\n<EOR>\n"
|
||||
val result = repo.parseConfirmedGridQsos(report(qso))!!
|
||||
assertEquals(setOf("EN52", "EN53", "EN42", "EN43"), result.keys)
|
||||
result.values.forEach { assertEquals(1, it.size) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parseQsosRejectsBodyWithoutEoh() {
|
||||
assertNull(repo.parseConfirmedGridQsos("<HTML>Username/password incorrect</HTML>"))
|
||||
}
|
||||
|
||||
// endregion
|
||||
|
||||
// region failure classification (fetchReportBody -> LoTWResult mapping)
|
||||
|
||||
@Test
|
||||
fun failureClassificationMapsEachException() {
|
||||
// LoTWRepository.toResult() must keep each failure cause distinct so
|
||||
// the UI can show a specific message per cause.
|
||||
assertEquals(
|
||||
LoTWResult.BadCredentials,
|
||||
repo.toResult(LoTWRepository.CredentialsException())
|
||||
)
|
||||
assertEquals(
|
||||
LoTWResult.RateLimited,
|
||||
repo.toResult(LoTWRepository.RateLimitException())
|
||||
)
|
||||
assertEquals(
|
||||
LoTWResult.Timeout,
|
||||
repo.toResult(LoTWRepository.TimeoutException("read timed out"))
|
||||
)
|
||||
val net = repo.toResult(java.io.IOException("HTTP 500"))
|
||||
assertEquals(LoTWResult.NetworkError("HTTP 500"), net)
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
fun parseQsosExtractsAwardFields() {
|
||||
// Award statistics rely on the DXCC/CQZ/STATE fields coming straight
|
||||
// from LoTW's qso_qsldetail report (STATE depends on DXCC). LoTW
|
||||
// writes STATE as "CODE // NAME" (verified with a real report 2026-09);
|
||||
// the parser must strip the name suffix and keep the bare code.
|
||||
val qso = "<CALL:5>BG7XYZ\n" +
|
||||
"<QSO_DATE:8>20260820\n<TIME_ON:4>1130\n" +
|
||||
"<PROP_MODE:3>SAT\n<SAT_NAME:5>FO-29\n<MODE:2>CW\n" +
|
||||
"<DXCC:3>318\n<COUNTRY:5>CHINA\n<CQZ:2>24\n<STATE:13>GD // Guangdong\n" +
|
||||
"<GRIDSQUARE:4>OL62\n<EOR>\n"
|
||||
val result = repo.parseConfirmedGridQsos(report(qso))!!
|
||||
val parsed = result["OL62"]!!.first()
|
||||
assertEquals(318, parsed.dxcc)
|
||||
assertEquals("CHINA", parsed.country)
|
||||
assertEquals(24, parsed.cqz)
|
||||
assertEquals("GD", parsed.state)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parseQsosStripsStateNameSuffixForAllEntities() {
|
||||
// Japan prefecture: "34 // Tottori-ken" -> "34" (WAJA matching needs
|
||||
// the 2-digit code). US states also arrive as "CODE // Name".
|
||||
val jp = "<CALL:5>JH0ABC\n<QSO_DATE:8>20260820\n<TIME_ON:4>1130\n" +
|
||||
"<PROP_MODE:3>SAT\n<SAT_NAME:5>RS-44\n<DXCC:3>339\n<COUNTRY:7>JAPAN\n" +
|
||||
"<STATE:18>34 // Tottori-ken\n<GRIDSQUARE:4>PM95\n<EOR>\n"
|
||||
val us = "<CALL:5>K1ABC\n<QSO_DATE:8>20260820\n<TIME_ON:4>1130\n" +
|
||||
"<PROP_MODE:3>SAT\n<SAT_NAME:5>AO-07\n<DXCC:3>291\n<COUNTRY:12>UNITED STATES\n" +
|
||||
"<STATE:22>CA // California\n<GRIDSQUARE:4>EM40\n<EOR>\n"
|
||||
val result = repo.parseConfirmedGridQsos(report(jp, us))!!
|
||||
assertEquals("34", result["PM95"]!!.first().state)
|
||||
assertEquals("CA", result["EM40"]!!.first().state)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parseQsosLeavesAwardFieldsNullWhenAbsent() {
|
||||
val qso = "<CALL:5>JH0ABC\n<QSO_DATE:8>20260820\n<TIME_ON:4>1130\n" +
|
||||
"<PROP_MODE:3>SAT\n<SAT_NAME:5>RS-44\n<GRIDSQUARE:4>PM95\n<EOR>\n"
|
||||
val result = repo.parseConfirmedGridQsos(report(qso))!!
|
||||
val parsed = result["PM95"]!!.first()
|
||||
assertNull(parsed.dxcc)
|
||||
assertNull(parsed.country)
|
||||
assertNull(parsed.cqz)
|
||||
assertNull(parsed.state)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parseQsosAwardFieldsDoNotLeakAcrossRecords() {
|
||||
val withFields = "<CALL:5>BG7AAA\n<QSO_DATE:8>20260820\n<TIME_ON:4>1130\n" +
|
||||
"<PROP_MODE:3>SAT\n<SAT_NAME:5>FO-29\n<DXCC:3>318\n<CQZ:2>24\n<STATE:2>GD\n" +
|
||||
"<GRIDSQUARE:4>OL62\n<EOR>\n"
|
||||
val withoutFields = "<CALL:5>JH0BBB\n<QSO_DATE:8>20260821\n<TIME_ON:4>1130\n" +
|
||||
"<PROP_MODE:3>SAT\n<SAT_NAME:5>RS-44\n<GRIDSQUARE:4>PM95\n<EOR>\n"
|
||||
val result = repo.parseConfirmedGridQsos(report(withFields, withoutFields))!!
|
||||
assertNull(result["PM95"]!!.first().dxcc)
|
||||
assertNull(result["PM95"]!!.first().cqz)
|
||||
assertNull(result["PM95"]!!.first().state)
|
||||
}
|
||||
|
||||
// endregion
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
/*
|
||||
* 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
|
||||
|
||||
/**
|
||||
* Award progress computed from the confirmed-QSO store.
|
||||
*
|
||||
* @param type which award
|
||||
* @param workedKeys matching keys for the boundary assets: each is compared
|
||||
* against the region "code" field of the award's GeoJSON
|
||||
* (e.g. WAPC -> "GD", WAJA -> "34", WAZ -> "24",
|
||||
* WAS -> "CA", DXCC -> "318", VUCC -> "OL62").
|
||||
* @param count distinct worked units (grids / entities / provinces / ...)
|
||||
* @param target units required for the award
|
||||
*/
|
||||
data class AwardProgress(
|
||||
val type: AwardType,
|
||||
val workedKeys: Set<String>,
|
||||
val count: Int,
|
||||
val target: Int
|
||||
)
|
||||
|
||||
enum class AwardType { DXCC, VUCC, WAPC, WAJA, WAZ, WAS }
|
||||
|
||||
object AwardTargets {
|
||||
const val DXCC = 100
|
||||
const val VUCC = 100
|
||||
const val WAPC = 34
|
||||
const val WAJA = 47
|
||||
const val WAZ = 40
|
||||
const val WAS = 50
|
||||
}
|
||||
|
||||
/**
|
||||
* Derives six-award progress from the confirmed QSO store.
|
||||
*
|
||||
* Award rules (ADIF / award-program semantics):
|
||||
* - VUCC: distinct 4-char gridsquares.
|
||||
* - DXCC: distinct ARRL DXCC entities (from the QSO's dxcc field; falls back
|
||||
* to a callsign-prefix guess when LoTW omitted it).
|
||||
* - WAPC: distinct Chinese provinces (31 mainland STATE codes) plus the
|
||||
* Hong Kong / Macao / Taiwan entities (counted as their own provinces).
|
||||
* - WAJA: distinct Japanese prefectures (STATE code "01".."47").
|
||||
* - WAZ: distinct CQ zones.
|
||||
* - WAS: distinct US states (STATE abbreviations).
|
||||
*
|
||||
* The ADIF STATE field's meaning depends on the DXCC entity
|
||||
* ("STATE depends on DXCC"), so every QSO is first classified by entity.
|
||||
*/
|
||||
object AwardCalculator {
|
||||
|
||||
fun calculate(qsosByGrid: Map<String, List<GridQso>>): List<AwardProgress> {
|
||||
val all = qsosByGrid.values.flatten()
|
||||
return listOf(
|
||||
AwardProgress(
|
||||
AwardType.VUCC,
|
||||
workedKeys = qsosByGrid.keys,
|
||||
count = qsosByGrid.size,
|
||||
target = AwardTargets.VUCC
|
||||
),
|
||||
calculateDxcc(all),
|
||||
calculateWapc(all),
|
||||
calculateWaja(all),
|
||||
calculateWaz(all),
|
||||
calculateWas(all)
|
||||
)
|
||||
}
|
||||
|
||||
private fun calculateDxcc(all: List<GridQso>): AwardProgress {
|
||||
val keys = mutableSetOf<String>()
|
||||
for (q in all) {
|
||||
val code = q.dxcc ?: prefixEntityCode(q.call)
|
||||
if (code != null) keys.add(code.toString())
|
||||
}
|
||||
return AwardProgress(AwardType.DXCC, keys, keys.size, AwardTargets.DXCC)
|
||||
}
|
||||
|
||||
private fun calculateWapc(all: List<GridQso>): AwardProgress {
|
||||
val keys = mutableSetOf<String>()
|
||||
for (q in all) {
|
||||
when (entityOf(q)) {
|
||||
Entity.CHINA -> q.state?.trim()?.uppercase()?.takeIf { it in CN_STATES }?.let { keys.add(it) }
|
||||
Entity.HONG_KONG -> keys.add(HK_CODE)
|
||||
Entity.MACAO -> keys.add(MO_CODE)
|
||||
Entity.TAIWAN -> keys.add(TW_CODE)
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
return AwardProgress(AwardType.WAPC, keys, keys.size, AwardTargets.WAPC)
|
||||
}
|
||||
|
||||
private fun calculateWaja(all: List<GridQso>): AwardProgress {
|
||||
val keys = mutableSetOf<String>()
|
||||
for (q in all) {
|
||||
if (entityOf(q) != Entity.JAPAN) continue
|
||||
q.state?.trim()?.takeIf { it.length == 2 && it.all(Char::isDigit) }?.let { keys.add(it) }
|
||||
}
|
||||
return AwardProgress(AwardType.WAJA, keys, keys.size, AwardTargets.WAJA)
|
||||
}
|
||||
|
||||
private fun calculateWaz(all: List<GridQso>): AwardProgress {
|
||||
val keys = mutableSetOf<String>()
|
||||
for (q in all) {
|
||||
q.cqz?.takeIf { it in 1..40 }?.let { keys.add(it.toString()) }
|
||||
}
|
||||
return AwardProgress(AwardType.WAZ, keys, keys.size, AwardTargets.WAZ)
|
||||
}
|
||||
|
||||
private fun calculateWas(all: List<GridQso>): AwardProgress {
|
||||
val keys = mutableSetOf<String>()
|
||||
for (q in all) {
|
||||
if (entityOf(q) != Entity.USA) continue
|
||||
q.state?.trim()?.uppercase()?.takeIf { it in US_STATES }?.let { keys.add(it) }
|
||||
}
|
||||
return AwardProgress(AwardType.WAS, keys, keys.size, AwardTargets.WAS)
|
||||
}
|
||||
|
||||
private enum class Entity { CHINA, HONG_KONG, MACAO, TAIWAN, JAPAN, USA, OTHER }
|
||||
|
||||
private fun entityOf(q: GridQso): Entity = when (q.dxcc) {
|
||||
318 -> Entity.CHINA
|
||||
321 -> Entity.HONG_KONG
|
||||
330 -> Entity.MACAO
|
||||
386 -> Entity.TAIWAN
|
||||
339 -> Entity.JAPAN
|
||||
291 -> Entity.USA
|
||||
else -> prefixEntity(q.call)
|
||||
}
|
||||
|
||||
/** Coarse entity from callsign prefix, used when LoTW omitted the DXCC field. */
|
||||
private fun prefixEntity(call: String): Entity {
|
||||
val c = call.uppercase().substringBefore('/')
|
||||
return when {
|
||||
CN_PREFIXES.any { c.startsWith(it) } -> Entity.CHINA
|
||||
c.startsWith("VR") -> Entity.HONG_KONG
|
||||
c.startsWith("XX9") -> Entity.MACAO
|
||||
TW_PREFIXES.any { c.startsWith(it) } -> Entity.TAIWAN
|
||||
JP_PREFIXES.any { c.startsWith(it) } -> Entity.JAPAN
|
||||
US_PREFIXES.any { c.startsWith(it) } -> Entity.USA
|
||||
else -> Entity.OTHER
|
||||
}
|
||||
}
|
||||
|
||||
/** ARRL DXCC entity code guessed from callsign prefix (very coarse). */
|
||||
private fun prefixEntityCode(call: String): Int? {
|
||||
return when (prefixEntity(call)) {
|
||||
Entity.CHINA -> 318
|
||||
Entity.HONG_KONG -> 321
|
||||
Entity.MACAO -> 330
|
||||
Entity.TAIWAN -> 386
|
||||
Entity.JAPAN -> 339
|
||||
Entity.USA -> 291
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
private val CN_PREFIXES = listOf("BA", "BD", "BG", "BH", "BI", "BY", "BJ", "BK", "BL", "BQ", "BR", "BS")
|
||||
private val TW_PREFIXES = listOf("BM", "BV", "BU", "BW", "BX", "BN", "BO", "BT", "BZ")
|
||||
private val JP_PREFIXES =
|
||||
listOf("7J", "7K", "7L", "7M", "7N") + ('A'..'Z').map { "J$it" }
|
||||
private val US_PREFIXES =
|
||||
listOf("AA", "AB", "AC", "AD", "AE", "AF", "AG", "AH", "AI", "AJ", "AK", "AL", "K", "N", "W")
|
||||
|
||||
private val CN_STATES = setOf(
|
||||
"AH", "BJ", "CQ", "FJ", "GD", "GS", "GX", "GZ", "HA", "HB", "HE", "HI", "HL", "HN", "JL",
|
||||
"JS", "JX", "LN", "NM", "NX", "QH", "SC", "SD", "SH", "SN", "SX", "TJ", "XJ", "XZ", "YN", "ZJ"
|
||||
)
|
||||
|
||||
private val US_STATES = setOf(
|
||||
"AL", "AK", "AZ", "AR", "CA", "CO", "CT", "DE", "FL", "GA", "HI", "ID", "IL", "IN", "IA",
|
||||
"KS", "KY", "LA", "ME", "MD", "MA", "MI", "MN", "MS", "MO", "MT", "NE", "NV", "NH", "NJ",
|
||||
"NM", "NY", "NC", "ND", "OH", "OK", "OR", "PA", "RI", "SC", "SD", "TN", "TX", "UT", "VT",
|
||||
"VA", "WA", "WV", "WI", "WY"
|
||||
)
|
||||
|
||||
private const val HK_CODE = "HK"
|
||||
private const val MO_CODE = "MO"
|
||||
private const val TW_CODE = "TW"
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
/*
|
||||
* 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
|
||||
* @param dxcc ARRL DXCC entity code of the opposite station (from ADIF
|
||||
* <DXCC>), or null when LoTW omitted it
|
||||
* @param country DXCC entity name (ADIF <COUNTRY>), or null
|
||||
* @param cqz CQ zone of the opposite station (ADIF <CQZ>), or null
|
||||
* @param state Primary administrative subdivision of the opposite station
|
||||
* (ADIF <STATE>). Interpretation depends on the DXCC entity:
|
||||
* China -> province code ("GD"), Japan -> prefecture number
|
||||
* ("34"), USA -> state abbreviation ("CA"). Null when absent.
|
||||
*/
|
||||
data class GridQso(
|
||||
val call: String,
|
||||
val epochMs: Long,
|
||||
val satName: String,
|
||||
val mode: String,
|
||||
val bandUp: String,
|
||||
val bandDown: String,
|
||||
val dxcc: Int? = null,
|
||||
val country: String? = null,
|
||||
val cqz: Int? = null,
|
||||
val state: String? = null
|
||||
) {
|
||||
/** 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()
|
||||
}
|
||||
}
|
||||
+12
-2
@@ -22,7 +22,17 @@ interface ILoTWRepository {
|
||||
|
||||
/**
|
||||
* Fetch all confirmed (QSL_RCVD=Y) gridsquares for the given LoTW account.
|
||||
* Returns the 4-char grid set, or null on any failure (network / bad credentials).
|
||||
* Returns the 4-char grid set with an explicit failure cause on error.
|
||||
*/
|
||||
suspend fun fetchConfirmedGrids(callsign: String, password: String): Set<String>?
|
||||
suspend fun fetchConfirmedGrids(callsign: String, password: String): LoTWResult
|
||||
|
||||
/**
|
||||
* Same report, but keeps the per-QSO detail of every confirmed satellite
|
||||
* QSO (call / time / satellite / mode / bands), grouped by worked 4-char
|
||||
* gridsquare. Failure cause is kept explicit.
|
||||
*/
|
||||
suspend fun fetchConfirmedGridQsos(
|
||||
callsign: String,
|
||||
password: String
|
||||
): LoTWResult
|
||||
}
|
||||
+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
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
/*
|
||||
* 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.repository
|
||||
|
||||
/** Result of a LoTW report fetch, with the failure cause kept explicit. */
|
||||
sealed class LoTWResult {
|
||||
|
||||
/** Report downloaded and parsed successfully. */
|
||||
data class Success(
|
||||
val grids: Set<String>,
|
||||
val qsos: Map<String, List<com.rtbishop.look4sat.core.domain.model.GridQso>>
|
||||
) : LoTWResult()
|
||||
|
||||
/** HTTP 200 but LoTW replied with its login-error page (bad callsign/password). */
|
||||
data object BadCredentials : LoTWResult()
|
||||
|
||||
/**
|
||||
* Report endpoint refused the request without a usable error page. LoTW
|
||||
* limits downloads to one in progress per user id and flags accounts that
|
||||
* pull the full report too often; the server answers those with a bare
|
||||
* non-ADIF body (no <eoh>).
|
||||
*/
|
||||
data object RateLimited : LoTWResult()
|
||||
|
||||
/** Connect/read timed out — typically a slow route to the ARRL servers. */
|
||||
data object Timeout : LoTWResult()
|
||||
|
||||
/** Any other network-level failure (DNS, TLS, connection reset, HTTP 5xx). */
|
||||
data class NetworkError(val detail: String) : LoTWResult()
|
||||
}
|
||||
+155
@@ -0,0 +1,155 @@
|
||||
/*
|
||||
* 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
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class AwardCalculatorTest {
|
||||
|
||||
private fun qso(
|
||||
call: String,
|
||||
dxcc: Int? = null,
|
||||
cqz: Int? = null,
|
||||
state: String? = null,
|
||||
grid: String = "OL62"
|
||||
) = GridQso(
|
||||
call = call, epochMs = 0L, satName = "SO-50", mode = "FM",
|
||||
bandUp = "70CM", bandDown = "2M", dxcc = dxcc, cqz = cqz, state = state
|
||||
)
|
||||
|
||||
private fun byGrid(vararg qsos: GridQso) =
|
||||
qsos.groupBy { "OL62" } // all share one grid for award-math tests
|
||||
|
||||
@Test
|
||||
fun `vucc counts distinct grid keys`() {
|
||||
val qsos = mapOf(
|
||||
"OL62" to listOf(qso("BG7XYZ")),
|
||||
"PM95" to listOf(qso("JH0ABC")),
|
||||
"OL62" to listOf(qso("BA7AAA"))
|
||||
)
|
||||
// map literal dedupes keys: OL62/PM95 -> 2 grids
|
||||
val progress = AwardCalculator.calculate(qsos).first { it.type == AwardType.VUCC }
|
||||
assertEquals(2, progress.count)
|
||||
assertTrue(progress.workedKeys.containsAll(setOf("OL62", "PM95")))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `dxcc counts distinct entities from dxcc field`() {
|
||||
val all = listOf(
|
||||
qso("BG7XYZ", dxcc = 318),
|
||||
qso("JH0ABC", dxcc = 339),
|
||||
qso("YD1XYZ", dxcc = 327),
|
||||
qso("BG7ABC", dxcc = 318)
|
||||
)
|
||||
val progress = AwardCalculator.calculate(byGrid(*all.toTypedArray())).first { it.type == AwardType.DXCC }
|
||||
assertEquals(3, progress.count)
|
||||
assertTrue(progress.workedKeys.containsAll(setOf("318", "339", "327")))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `dxcc falls back to prefix when dxcc missing`() {
|
||||
val all = listOf(qso("JH0ABC"), qso("BG7XYZ"), qso("VR2XYZ"))
|
||||
val progress = AwardCalculator.calculate(byGrid(*all.toTypedArray())).first { it.type == AwardType.DXCC }
|
||||
assertEquals(3, progress.count)
|
||||
assertTrue(progress.workedKeys.containsAll(setOf("339", "318", "321")))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `wapc counts mainland provinces plus hk mo tw`() {
|
||||
val all = listOf(
|
||||
qso("BG7XYZ", dxcc = 318, state = "GD"), // Guangdong
|
||||
qso("BD5ABC", dxcc = 318, state = "ZJ"), // Zhejiang
|
||||
qso("BD5ABC", dxcc = 318, state = "ZJ"), // dup
|
||||
qso("VR2X", dxcc = 321), // Hong Kong
|
||||
qso("XX9A", dxcc = 330), // Macao
|
||||
qso("BM4X", dxcc = 386) // Taiwan
|
||||
)
|
||||
val progress = AwardCalculator.calculate(byGrid(*all.toTypedArray())).first { it.type == AwardType.WAPC }
|
||||
assertEquals(5, progress.count)
|
||||
assertTrue(progress.workedKeys.containsAll(setOf("GD", "ZJ", "HK", "MO", "TW")))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `wapc ignores non-china states and missing states`() {
|
||||
val all = listOf(
|
||||
qso("JH0ABC", dxcc = 339, state = "34"), // Japan state ignored
|
||||
qso("BG7XYZ", dxcc = 318, state = null), // missing province
|
||||
qso("K1ABC", dxcc = 291, state = "HI") // Hawaii != Hainan
|
||||
)
|
||||
val progress = AwardCalculator.calculate(byGrid(*all.toTypedArray())).first { it.type == AwardType.WAPC }
|
||||
assertEquals(0, progress.count)
|
||||
assertTrue(progress.workedKeys.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `waja counts japanese prefectures only`() {
|
||||
val all = listOf(
|
||||
qso("JH0ABC", dxcc = 339, state = "10"), // Tokyo
|
||||
qso("JH0ABC", dxcc = 339, state = "47"), // Okinawa
|
||||
qso("JH0ABC", dxcc = 339, state = "10"), // dup
|
||||
qso("BG7XYZ", dxcc = 318, state = "10"), // Chinese call, state=10 must NOT count
|
||||
qso("JE2XYZ", dxcc = 339, state = null) // missing
|
||||
)
|
||||
val progress = AwardCalculator.calculate(byGrid(*all.toTypedArray())).first { it.type == AwardType.WAJA }
|
||||
assertEquals(2, progress.count)
|
||||
assertTrue(progress.workedKeys.containsAll(setOf("10", "47")))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `waz counts distinct cq zones in range`() {
|
||||
val all = listOf(
|
||||
qso("BG7XYZ", cqz = 24),
|
||||
qso("JH0ABC", cqz = 25),
|
||||
qso("YD1XYZ", cqz = 28),
|
||||
qso("BG7AAA", cqz = 24),
|
||||
qso("BG7BBB", cqz = null),
|
||||
qso("BG7CCC", cqz = 0)
|
||||
)
|
||||
val progress = AwardCalculator.calculate(byGrid(*all.toTypedArray())).first { it.type == AwardType.WAZ }
|
||||
assertEquals(3, progress.count)
|
||||
assertTrue(progress.workedKeys.containsAll(setOf("24", "25", "28")))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `was counts us states only`() {
|
||||
val all = listOf(
|
||||
qso("K1ABC", dxcc = 291, state = "CA"),
|
||||
qso("W2XYZ", dxcc = 291, state = "NY"),
|
||||
qso("N3QRS", dxcc = 291, state = "ca"), // lowercase normalized
|
||||
qso("BG7XYZ", dxcc = 318, state = "CA"), // not US
|
||||
qso("K1AAA", dxcc = 291, state = "XX") // invalid state
|
||||
)
|
||||
val progress = AwardCalculator.calculate(byGrid(*all.toTypedArray())).first { it.type == AwardType.WAS }
|
||||
assertEquals(2, progress.count)
|
||||
assertTrue(progress.workedKeys.containsAll(setOf("CA", "NY")))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `all six awards present with correct targets`() {
|
||||
val progress = AwardCalculator.calculate(emptyMap())
|
||||
assertEquals(6, progress.size)
|
||||
assertEquals(100, progress.first { it.type == AwardType.DXCC }.target)
|
||||
assertEquals(100, progress.first { it.type == AwardType.VUCC }.target)
|
||||
assertEquals(34, progress.first { it.type == AwardType.WAPC }.target)
|
||||
assertEquals(47, progress.first { it.type == AwardType.WAJA }.target)
|
||||
assertEquals(40, progress.first { it.type == AwardType.WAZ }.target)
|
||||
assertEquals(50, progress.first { it.type == AwardType.WAS }.target)
|
||||
}
|
||||
}
|
||||
+31
-5
@@ -210,12 +210,20 @@ fun RowScope.NextPassRow(pass: OrbitalPass, modifier: Modifier = Modifier, isUtc
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun CardButton(onClick: () -> Unit, text: String, modifier: Modifier = Modifier) {
|
||||
fun CardButton(
|
||||
onClick: () -> Unit,
|
||||
text: String,
|
||||
modifier: Modifier = Modifier,
|
||||
isEnabled: Boolean = true
|
||||
) {
|
||||
ElevatedButton(
|
||||
onClick = onClick,
|
||||
enabled = isEnabled,
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||
contentColor = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
contentColor = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
disabledContainerColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||
disabledContentColor = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f)
|
||||
),
|
||||
shape = MaterialTheme.shapes.small,
|
||||
modifier = modifier,
|
||||
@@ -316,9 +324,21 @@ fun InfoDialog(
|
||||
|
||||
@Composable
|
||||
fun SharedDialog(
|
||||
title: String, onCancel: () -> Unit, onAccept: () -> Unit, content: @Composable () -> Unit
|
||||
title: String,
|
||||
onCancel: () -> Unit,
|
||||
onAccept: () -> Unit,
|
||||
acceptText: String? = null,
|
||||
acceptEnabled: Boolean = true,
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
SharedDialog(title = title, onDismissRequest = onCancel, onCancel = onCancel, onAccept = onAccept) { _ ->
|
||||
SharedDialog(
|
||||
title = title,
|
||||
onDismissRequest = onCancel,
|
||||
onCancel = onCancel,
|
||||
onAccept = onAccept,
|
||||
acceptText = acceptText,
|
||||
acceptEnabled = acceptEnabled
|
||||
) { _ ->
|
||||
content()
|
||||
}
|
||||
}
|
||||
@@ -329,6 +349,8 @@ fun SharedDialog(
|
||||
onDismissRequest: () -> Unit,
|
||||
onCancel: (() -> Unit)? = null,
|
||||
onAccept: (() -> Unit)? = null,
|
||||
acceptText: String? = null,
|
||||
acceptEnabled: Boolean = true,
|
||||
titleFontSize: Int = 16,
|
||||
titleTextAlign: TextAlign = if (onCancel != null && onAccept != null) TextAlign.Center else TextAlign.Start,
|
||||
content: @Composable (padding: Dp) -> Unit
|
||||
@@ -356,7 +378,11 @@ fun SharedDialog(
|
||||
.padding(horizontal = if (onCancel != null && onAccept != null) padding else 0.dp)
|
||||
)
|
||||
if (onAccept != null) {
|
||||
CardButton(onClick = onAccept, text = stringResource(R.string.btn_accept))
|
||||
CardButton(
|
||||
onClick = onAccept,
|
||||
text = acceptText ?: stringResource(R.string.btn_accept),
|
||||
isEnabled = acceptEnabled
|
||||
)
|
||||
}
|
||||
}
|
||||
content(padding)
|
||||
|
||||
+4
-8
@@ -53,7 +53,8 @@ fun GridTargetChip(
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val configured = grid != null && distanceKm != null && bearingDeg != null
|
||||
val accent = MaterialTheme.colorScheme.tertiaryContainer
|
||||
// The app's signature accent yellow (MaterialTheme primary in both themes).
|
||||
val accent = MaterialTheme.colorScheme.primary
|
||||
val placeholder = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
@@ -83,13 +84,8 @@ fun GridTargetChip(
|
||||
Text(
|
||||
text = distanceKm?.let { "${"%,.0f".format(it)} km" } ?: "-- km",
|
||||
fontSize = 14.sp,
|
||||
color = if (configured) Color.White else placeholder,
|
||||
modifier = Modifier
|
||||
.background(
|
||||
color = if (configured) MaterialTheme.colorScheme.onSurfaceVariant else Color.Transparent,
|
||||
shape = RoundedCornerShape(8.dp)
|
||||
)
|
||||
.padding(horizontal = 8.dp, vertical = 2.dp)
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
modifier = Modifier.padding(horizontal = 8.dp, vertical = 2.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -107,6 +107,9 @@
|
||||
<!-- Map screen -->
|
||||
<string name="map_prev">上一个</string>
|
||||
<string name="map_next">下一个</string>
|
||||
<string name="map_grid_mode">网格模式</string>
|
||||
<string name="map_satellite_mode">卫星模式</string>
|
||||
<string name="map_award_all">全部</string>
|
||||
<string name="map_azimuth">方位角:%.1f°</string>
|
||||
<string name="map_elevation">仰角:%.1f°</string>
|
||||
<string name="map_altitude">高度:%.0f km</string>
|
||||
@@ -118,6 +121,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 -->
|
||||
@@ -151,8 +158,8 @@
|
||||
<string name="prefs_lotw_title">LoTW 已确认网格</string>
|
||||
<string name="prefs_lotw_callsign">LoTW 呼号</string>
|
||||
<string name="prefs_lotw_password">LoTW 密码</string>
|
||||
<string name="prefs_lotw_hint">直接从 ARRL LoTW 拉取已确认 QSO 的网格并与 Wavelog 网格合并(当前 %1$d 个)。密码仅保存在手机本地。填好直接点同步,会自动保存并拉取。</string>
|
||||
<string name="prefs_lotw_sync">立即同步</string>
|
||||
<string name="prefs_lotw_hint">直接从 ARRL LoTW 拉取该呼号已确认 QSO 的网格并覆盖本地已存集合(当前 %1$d 个)。密码仅保存在手机本地。点"同步"会自动保存凭据并拉取。</string>
|
||||
<string name="prefs_lotw_sync">同步</string>
|
||||
<string name="prefs_lotw_syncing">同步中…</string>
|
||||
<string name="prefs_lotw_configured">已从 LoTW 同步 — 共 %1$d 个网格</string>
|
||||
<string name="prefs_lotw_not_configured">未配置 — 直接登录 ARRL LoTW 拉取已确认 QSO 的网格,无需自建服务器</string>
|
||||
|
||||
@@ -131,6 +131,9 @@
|
||||
<string name="map_prev">Prev</string>
|
||||
<string name="map_next">Next</string>
|
||||
<string name="map_copyright" translatable="false">© OpenStreetMap contributors</string>
|
||||
<string name="map_grid_mode">Grid mode</string>
|
||||
<string name="map_satellite_mode">Satellite mode</string>
|
||||
<string name="map_award_all">All</string>
|
||||
<string name="map_azimuth">Azimuth: %.1f°</string>
|
||||
<string name="map_elevation">Elevation: %.1f°</string>
|
||||
<string name="map_altitude">Altitude: %.0f km</string>
|
||||
@@ -143,6 +146,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 -->
|
||||
@@ -186,8 +193,8 @@
|
||||
<string name="prefs_lotw_title">LoTW confirmed grids</string>
|
||||
<string name="prefs_lotw_callsign">LoTW callsign</string>
|
||||
<string name="prefs_lotw_password">LoTW password</string>
|
||||
<string name="prefs_lotw_hint">Fetch confirmed-QSO grids directly from ARRL LoTW and merge them with Wavelog grids (currently %1$d). The password is stored on this device only. Just tap Sync — credentials are saved automatically.</string>
|
||||
<string name="prefs_lotw_sync">Sync now</string>
|
||||
<string name="prefs_lotw_hint">Fetch confirmed-QSO grids directly from ARRL LoTW for this callsign and replace the stored set (currently %1$d). The password is stored on this device only. Tapping Sync saves the credentials and fetches in one step.</string>
|
||||
<string name="prefs_lotw_sync">Sync</string>
|
||||
<string name="prefs_lotw_syncing">Syncing…</string>
|
||||
<string name="prefs_lotw_configured">Synced from LoTW — %1$d grids total</string>
|
||||
<string name="prefs_lotw_not_configured">Not configured — pull confirmed grids directly from ARRL LoTW, no self-hosted server needed</string>
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
{"type":"dxcc","regions":[{"id":0,"code":176,"name":"Fiji","label_lon":177.9759,"label_lat":-17.9376,"rings":[[[180.0,-16.0671],[180.0,-16.5552],[179.3641,-16.8014],[178.7251,-17.012],[178.5968,-16.6392],[179.0966,-16.434],[179.4135,-16.3791]],[[178.1256,-17.5048],[178.3736,-17.3399],[178.7181,-17.6285],[178.5527,-18.1506],[177.9327,-18.288],[177.3815,-18.1643],[177.285,-17.7247],[177.6709,-17.3811]],[[-179.7933,-16.0209],[-179.9174,-16.5018],[-180.0,-16.5552],[-180.0,-16.0671]]]},{"id":1,"code":470,"name":"Tanzania","label_lon":34.1421,"label_lat":-6.2078,"rings":[[[33.9037,-0.95],[34.0726,-1.0598],[37.6987,-3.097],[37.7669,-3.6771],[39.2022,-4.6768],[38.7405,-5.9089],[38.7998,-6.4757],[39.44,-6.84],[39.47,-7.1],[39.1947,-7.7039],[39.252,-8.0078],[39.1865,-8.4855],[39.5357,-9.1124],[39.9496,-10.0984],[40.3166,-10.3171],[40.3166,-10.3171],[39.521,-10.8969],[38.4276,-11.2852],[37.8276,-11.2688],[37.4713,-11.5688],[36.7752,-11.5945],[36.5141,-11.7209],[35.3124,-11.4391],[34.56,-11.52],[34.28,-10.16],[33.9408,-9.6937],[33.7397,-9.4171],[32.7594,-9.2306],[32.1919,-8.9304],[31.5563,-8.762],[31.1578,-8.5946],[30.74,-8.34],[30.74,-8.34],[30.2,-7.08],[29.62,-6.52],[29.42,-5.94],[29.52,-5.42],[29.34,-4.5],[29.7535,-4.4524],[30.1163,-4.0901],[30.5055,-3.5686],[30.7522,-3.3593],[30.743,-3.0343],[30.5277,-2.8076],[30.4697,-2.4139],[30.4697,-2.4138],[30.7583,-2.2872],[30.8161,-1.6989],[30.4191,-1.1347],[30.7699,-1.0146],[31.8662,-1.0274]]]},{"id":2,"code":302,"name":"W. Sahara","label_lon":-12.572,"label_lat":24.2306,"rings":[[[-8.6656,27.6564],[-8.6651,27.5895],[-8.6844,27.3957],[-8.6873,25.8811],[-11.9694,25.9334],[-11.9372,23.3746],[-12.8742,23.2848],[-13.1188,22.7712],[-12.9291,21.3271],[-16.8452,21.3333],[-17.0634,20.9998],[-17.0204,21.4223],[-17.003,21.4207],[-14.751,21.5006],[-14.6308,21.8609],[-14.2212,22.3102],[-13.8911,23.691],[-12.501,24.7701],[-12.0308,26.0309],[-11.7182,26.1041],[-11.3926,26.8834],[-10.5513,26.9908],[-10.1894,26.8609],[-9.7353,26.8609],[-9.413,27.0885],[-8.7949,27.1207],[-8.8178,27.6564]]]},{"id":3,"code":1,"name":"Canada","label_lon":-110.2438,"label_lat":56.7019,"rings":[[[-122.84,49.0],[-122.9742,49.0025],[-124.9102,49.9846],[-125.6246,50.4166],[-127.4356,50.8306],[-127.9928,51.7158],[-127.8503,52.3296],[-129.1298,52.7554],[-129.3052,53.5616],[-130.515,54.2876],[-130.5361,54.8028],[-130.5361,54.8028],[-129.98,55.285],[-130.0078,55.9158],[-131.7078,56.5521],[-132.7304,57.6929],[-133.3556,58.4103],[-134.2711,58.8611],[-134.945,59.2706],[-135.4758,59.7878],[-136.4797,59.4639],[-137.4525,58.905],[-138.3409,59.5621],[-139.039,60.0],[-140.013,60.2768],[-140.9978,60.3064],[-140.9925,66.0],[-140.986,69.712],[-140.986,69.712],[-139.1205,69.471],[-137.5464,68.99],[-136.5036,68.898],[-135.6258,69.3151],[-134.4146,69.6274],[-132.9292,69.5053],[-131.4314,69.9445],[-129.7947,70.1937],[-129.1077,69.7793],[-128.3616,70.0129],[-128.1382,70.4838],[-127.4471,70.3772],[-125.7563,69.4806],[-124.4248,70.1584],[-124.2897,69.3997],[-123.0611,69.5637],[-122.6835,69.8555],[-121.4723,69.7978],[-119.9429,69.3779],[-117.6027,69.0113],[-116.2264,68.8415],[-115.2469,68.9059],[-113.8979,68.3989],[-115.3049,67.9026],[-113.4973,67.6881],[-110.798,67.8061],[-109.9462,67.981],[-108.8802,67.3814],[-107.7924,67.8874],[-108.813,68.3116],[-108.1672,68.6539],[-106.95,68.7],[-106.15,68.8],[-105.3428,68.5612],[-104.3379,68.018],[-103.2211,68.0978],[-101.4543,67.6469],[-99.9019,67.8057],[-98.4432,67.7816],[-98.5586,68.4039],[-97.6695,68.5786],[-96.1199,68.2394],[-96.1259,67.2934],[-95.4894,68.0907],[-94.685,68.0638],[-94.2328,69.069],[-95.3041,69.6857],[-96.4713,70.0898],[-96.3911,71.1948],[-95.2088,71.9205],[-93.89,71.7601],[-92.8782,71.3187],[-91.5196,70.1913],[-92.4069,69.7],[-90.5471,69.4977],[-90.5515,68.475],[-89.2151,69.2587],[-88.0197,68.6151],[-88.3175,67.8734],[-87.3502,67.1987],[-86.3061,67.9215],[-85.5766,68.7846],[-85.522,69.8821],[-84.1008,69.8054],[-82.6226,69.6583],[-81.2804,69.162],[-81.2202,68.6657],[-81.9644,68.1325],[-81.2593,67.5972],[-81.3865,67.1108],[-83.3446,66.4115],[-84.7354,66.2573],[-85.7694,66.5583],[-86.0676,66.0563],[-87.0314,65.213],[-87.3232,64.7756],[-88.483,64.099],[-89.9144,64.0327],[-90.704,63.6102],[-90.77,62.9602],[-91.9334,62.8351],[-93.157,62.0247],[-94.2415,60.8987],[-94.6293,60.1102],[-94.6846,58.9488],[-93.215,58.7821],[-92.7646,57.8457],[-92.297,57.0871],[-90.8977,57.2847],[-89.0395,56.8517],[-88.0398,56.4716],[-87.3242,55.9991],[-86.0712,55.7238],[-85.0118,55.3026],[-83.3606,55.2449],[-82.2729,55.1483],[-82.4362,54.2823],[-82.125,53.277],[-81.4008,52.1579],[-79.9129,51.2084],[-79.143,51.5339],[-78.6019,52.5621],[-79.1242,54.1414],[-79.8296,54.6677],[-78.2287,55.1365],[-77.0956,55.8374],[-76.5414,56.5342],[-76.6232,57.2026],[-77.3023,58.0521],[-78.5169,58.8046],[-77.3368,59.8526],[-77.7727,60.7579],[-78.1069,62.3196],[-77.4107,62.5505],[-75.6962,62.2784],[-74.6682,62.1811],[-73.8399,62.4438],[-72.9085,62.1051],[-71.6771,61.5254],[-71.3737,61.1372],[-69.5904,61.0614],[-69.6203,60.2212]Line truncated
|
||||
@@ -0,0 +1 @@
|
||||
{"type":"waja","regions":[{"id":0,"code":"22","name":"京都府","label_lon":135.5014,"label_lat":35.2429,"rings":[[[135.0367,35.5373],[134.9265,35.5125],[134.9143,35.5448],[134.8542,35.5881],[134.8725,35.6197],[134.8671,35.6581],[134.9353,35.6505],[134.9849,35.6923],[135.0222,35.6895],[135.0607,35.7081],[135.0857,35.7398],[135.223,35.7792],[135.308,35.6944],[135.3029,35.6628],[135.2874,35.6756],[135.268,35.6674],[135.2443,35.6205],[135.1926,35.5711],[135.1931,35.5383],[135.2547,35.5989],[135.2507,35.5702],[135.2739,35.5609],[135.2449,35.5613],[135.2442,35.5359],[135.2882,35.5155],[135.3303,35.517],[135.3163,35.4512],[135.3467,35.4753],[135.3468,35.494],[135.3975,35.4788],[135.3963,35.5152],[135.3445,35.5041],[135.3423,35.5518],[135.4263,35.5665],[135.4337,35.5907],[135.4619,35.6035],[135.4521,35.5675],[135.4865,35.558],[135.4599,35.5472],[135.449,35.5235],[135.4748,35.5049],[135.4591,35.4675],[135.4958,35.4251],[135.5253,35.4207],[135.5285,35.3797],[135.6419,35.3668],[135.7007,35.3437],[135.7684,35.3561],[135.7637,35.3445],[135.809,35.3161],[135.8313,35.2761],[135.8581,35.2891],[135.8291,35.2207],[135.8558,35.1595],[135.8355,35.059],[135.815,35.0461],[135.8334,35.0223],[135.8199,35.0129],[135.8351,35.0032],[135.8268,34.9911],[135.8493,34.991],[135.8782,34.9475],[135.8664,34.8923],[135.898,34.8712],[135.9405,34.8932],[135.9457,34.8517],[136.005,34.8427],[136.0047,34.8282],[136.0281,34.8192],[136.0104,34.8007],[136.0335,34.7789],[136.0285,34.7572],[136.0552,34.7384],[136.0285,34.7062],[135.9859,34.7151],[135.9754,34.7409],[135.9254,34.7403],[135.9254,34.7575],[135.89,34.7087],[135.7551,34.7275],[135.7284,34.7779],[135.7447,34.8043],[135.6955,34.8482],[135.6749,34.8987],[135.6307,34.9353],[135.5999,34.928],[135.6129,34.9713],[135.5733,34.9717],[135.5783,34.9557],[135.5568,34.9381],[135.58,34.937],[135.5807,34.9236],[135.5418,34.9153],[135.5132,34.9439],[135.481,34.9495],[135.4874,34.9919],[135.3821,35.0101],[135.3688,35.0536],[135.4031,35.0894],[135.3885,35.0971],[135.3894,35.1288],[135.3435,35.1357],[135.3405,35.1499],[135.2822,35.1449],[135.2898,35.1699],[135.2738,35.1771],[135.2067,35.1609],[135.1869,35.1718],[135.1967,35.2029],[135.1556,35.2262],[135.1583,35.2599],[135.109,35.2663],[135.0675,35.2372],[135.0565,35.264],[135.01,35.2698],[134.9988,35.2911],[134.9256,35.3112],[134.9185,35.3645],[134.9338,35.4074],[134.9586,35.4091],[134.9895,35.3863],[135.0483,35.4074]],[[135.4273,35.6825],[135.4214,35.6797],[135.4243,35.6882]],[[135.4809,35.5943],[135.4749,35.5956],[135.4779,35.6059]],[[135.3472,35.4959],[135.3404,35.4986],[135.3474,35.4971]]]},{"id":1,"code":"41","name":"佐賀県","label_lon":130.0903,"label_lat":33.2574,"rings":[[[129.8671,33.5967],[129.8479,33.5998],[129.8612,33.6183]],[[129.9007,33.594],[129.8967,33.6037],[129.9018,33.6029]],[[129.8447,33.5858],[129.8351,33.5891],[129.8459,33.5915]],[[129.757,33.5634],[129.7394,33.5728],[129.7412,33.5781],[129.7679,33.582]],[[129.8741,33.5438],[129.8729,33.5581],[129.8804,33.5675],[129.892,33.5561]],[[130.0387,33.4697],[130.2766,33.4783],[130.3555,33.4335],[130.3953,33.4273],[130.4144,33.4103],[130.4102,33.395],[130.4962,33.4448],[130.5362,33.4382],[130.5352,33.3428],[130.479,33.3307],[130.4863,33.3123],[130.4554,33.3017],[130.4668,33.2886],[130.4489,33.265],[130.431,33.2925],[130.4146,33.2532],[130.3907,33.2599],[130.3843,33.2403],[130.3713,33.243],[130.3766,33.2204],[130.3386,33.2033],[130.3549,33.1404],[130.2878,33.1469],[130.2454,33.2029],[130.2465,33.1923],[130.2149,33.1993],[130.2306,33.1887],[130.2217,33.1653],[130.149,33.1127],[130.1219,33.1247],[130.1277,33.1085],[130.1123,33.1011],[130.1398,33.0943],[130.2198,32.9595],[130.0857,32.9748],[130.0087,33.019],[129.9935,33.0477],[129.9215,33.0904],[129.9256,33.1075],[129.9545,33.1166],[129.9425,33.1643],[129.8877,33.1563],[129.818,33.1835],[129.8134,33.2421],[129.7588,33.2903],[129.7899,33.3403],[129.836,33.2867],[129.8528,33.2921],[129.8274,33.3124],[129.8339,33.3113],[129.8484,33.3287],[129.8273,33.3373],[129.8474,33.3369],[129.868,33.4075],[129.8227,33.4118],[129.8307,33.4218],[129.7828,33.4541],[129.8062,33.4573],[129.7936,33.4713],[129.8055,33.485],[129.8382,33.4621],[129.8352,33.4436],[129.8579,33.4538],[129.8497,33.4751],[129.8399,33.4698],[129.8314,33.4762],[129.8443,33.4786],[129.8273,33.5176],[129.8563,33.5055],[129.8396,33.5222],[129.8562,33.5191],[129.8451,33.5553],[129.8749,33.5371],[129.8744,33.5161],[129.8958,33.5494],[129.9278,33.5507],[129.9288,33.537],[129.956,33.5295],[129.967,33.5086],[129.9362,33.4786],[129.9546,33.4693],[129.9624,33.4835],[129.9671,33.4552],[130.0194,33.4463],[130.0402,33.4558]],[[129.9674,33.5271],[129.9645,33.5389],[129.9721,33.5432],[129.9772,33.5349]],[[129.9928,33.4777],[129.9846,33.4779],[129.9888,33.4806]],[[129.8563,33.4021],[129.8558,33.4049],[129.858,33.4024]],[[129.8601,33.3995],[129.8614,33.4017],[129.8656,33.4027]],[[129.8386,33.3191],[129.8351,33.3215],[129.8352,33.3227]],[[130.2242,32.9565],[130.2127,32.9546],[130.222,32.9584]]]},{"id":2,"code":Line truncated
|
||||
@@ -0,0 +1 @@
|
||||
{"type":"wapc","regions":[{"id":0,"code":"BJ","name":"北京市","label_lon":116.42,"label_lat":40.1899,"rings":[[[117.3486,40.5811],[117.3899,40.5616],[117.4299,40.5761],[117.4127,40.6052],[117.4675,40.6497],[117.4675,40.6497],[117.5014,40.6366],[117.5149,40.6602],[117.494,40.6752],[117.409,40.687],[117.3425,40.6738],[117.3197,40.6579],[117.2784,40.6643],[117.2082,40.6947],[117.117,40.7001],[117.1121,40.7074],[117.0123,40.6938],[116.9649,40.7096],[116.9267,40.745],[116.9242,40.7736],[116.8485,40.8393],[116.8134,40.8483],[116.7598,40.89],[116.7136,40.9099],[116.7222,40.9275],[116.6779,40.9709],[116.6988,41.0215],[116.6883,41.0445],[116.6477,41.0594],[116.6156,41.0531],[116.623,41.021],[116.5984,40.9745],[116.5676,40.9926],[116.5196,40.9813],[116.5196,40.9813],[116.4555,40.9808],[116.4475,40.9537],[116.4771,40.8999],[116.3982,40.9062],[116.3705,40.9438],[116.3397,40.9293],[116.3342,40.9044],[116.4383,40.8193],[116.466,40.7745],[116.4537,40.7659],[116.3169,40.7722],[116.3114,40.755],[116.2732,40.7627],[116.2473,40.7917],[116.2202,40.7441],[116.2048,40.74],[116.1716,40.6956],[116.1629,40.6625],[116.134,40.6665],[116.0989,40.6307],[116.0052,40.5839],[115.9825,40.5789],[115.972,40.6025],[115.9079,40.6175],[115.8851,40.5952],[115.8279,40.5875],[115.8198,40.5593],[115.7847,40.5584],[115.7552,40.5402],[115.7361,40.5034],[115.781,40.4934],[115.7718,40.4437],[115.8642,40.3594],[115.9178,40.3544],[115.9517,40.2819],[115.9689,40.264],[115.8987,40.2344],[115.8704,40.1859],[115.8556,40.1887],[115.8476,40.147],[115.8063,40.1534],[115.7737,40.1763],[115.7546,40.1457],[115.7546,40.1457],[115.6,40.1196],[115.5907,40.0962],[115.5273,40.0761],[115.4854,40.0404],[115.4546,40.0298],[115.4503,39.9927],[115.4287,39.9844],[115.4263,39.9505],[115.4811,39.9358],[115.523,39.8991],[115.5156,39.8922],[115.5156,39.8922],[115.526,39.8757],[115.5143,39.8375],[115.5673,39.8164],[115.5525,39.7948],[115.5057,39.7842],[115.4835,39.7985],[115.4835,39.7985],[115.4435,39.7856],[115.4398,39.752],[115.4866,39.7419],[115.4916,39.6701],[115.4786,39.6507],[115.4786,39.6507],[115.5224,39.6401],[115.518,39.5973],[115.5458,39.6189],[115.587,39.5899],[115.6338,39.5996],[115.6338,39.5996],[115.6677,39.6152],[115.6985,39.5779],[115.6985,39.5779],[115.6991,39.57],[115.6991,39.57],[115.717,39.5603],[115.717,39.5603],[115.7188,39.5539],[115.7188,39.5539],[115.7207,39.5511],[115.7207,39.5511],[115.7225,39.5442],[115.7213,39.5437],[115.7225,39.5442],[115.7225,39.5437],[115.7213,39.5437],[115.7225,39.5437],[115.725,39.5442],[115.725,39.5442],[115.7385,39.54],[115.7385,39.5396],[115.7385,39.54],[115.7385,39.5396],[115.7527,39.5156],[115.8063,39.51],[115.8063,39.51],[115.8211,39.523],[115.8211,39.523],[115.8285,39.5414],[115.8679,39.5465],[115.8679,39.5465],[115.9153,39.583],[115.9153,39.583],[115.9104,39.6005],[115.9104,39.6005],[115.9572,39.5608],[115.9781,39.5959],[115.9954,39.577],[116.0262,39.5876],[116.036,39.5719],[116.0989,39.5751],[116.1303,39.5677],[116.1518,39.5834],[116.1987,39.5894],[116.2405,39.564],[116.2578,39.5003],[116.3071,39.4883],[116.3379,39.4555],[116.3619,39.4551],[116.3619,39.4551],[116.4346,39.4426],[116.4549,39.4532],[116.4444,39.4823],[116.4118,39.4828],[116.4019,39.528],[116.4438,39.51],[116.4376,39.5267],[116.4783,39.5354],[116.4734,39.553],[116.5085,39.5511],[116.5245,39.5963],[116.5922,39.6212],[116.5922,39.6212],[116.6206,39.6019],[116.6649,39.6056],[116.7234,39.5926],[116.724,39.5926],[116.7234,39.5926],[116.724,39.5926],[116.7265,39.5954],[116.7265,39.5954],[116.7093,39.618],[116.7487,39.6198],[116.7906,39.5959],[116.8121,39.6157],[116.8497,39.6678],[116.9064,39.6774],[116.9057,39.688],[116.8897,39.6876],[116.8873,39.7253],[116.9168,39.7313],[116.9021,39.7635],[116.9495,39.7787],[116.9181,39.8463],[116.9076,39.8325],[116.8657,39.844],[116.8121,39.8899],[116.7844,39.8913],[116.7826,39.9477],[116.7579,39.9679],[116.7813,40.0349],[116.8201,40.0284],[116.8312,40.0514],[116.8676,40.0417],[116.9279,40.055],[116.9452,40.0413],[117.0252,40.0303],[117.0517,40.0596],[117.1053,40.0743],[117.1053,40.0743],[117.1404,40.0642],[117.1595,40.077],[117.2045,40.0697],[117.21,40.082],[117.2242,40.0949],[117.2242,40.0949],[117.255,40.1145],[117.255,40.1145],[117.255,40.1145],[117.2741,40.1059],[117.3073,40.137],[117.3492,40.1365],[117.3671,40.1726],[117.3671,40.1731],[117.3671,40.1731],[117.3671,40.1726],[117.3837,40.1882],[117.3899,40.228],[117.3511,40.2298],[117.3314,40.2896],[117.295,40.2782],[117.2716,40.3252],[117.2716,40.3252],[117.2433,40.3695],[117.226,40.369],[117.234,40.4173],[117.2636,40.4424],[117.2088,40.5016],[117.263,40.5129],[117.2476,40.5398],[117.2698,40.5607],[117.3486,40.5811]]]},{"id":1,"code":"TJ","name":"天津市","label_lon":117.347,"label_lat":39.288,"rings":[[[117.7656,39.4005],[117.8469,39.4079],[117.8715,39.4116],[117.8703,39.4551],[117.8999,39.4745],[117.9122,39.5174],[117.9122,39.5174],[117.9048,39.5336],[117.9338,39.5742],[117.8685,39.5968],[117.8297,39.5899],[117.7668,39.5986],[117.7539,39.5797],[117.7539,39.5797],[11Line truncated
|
||||
@@ -0,0 +1 @@
|
||||
{"type":"was","regions":[{"id":0,"code":"AL","name":"Alabama","label_lon":-86.7494,"label_lat":32.72,"rings":[[[-87.3593,35.0012],[-85.6067,34.9847],[-85.4314,34.1249],[-85.185,32.8597],[-85.0699,32.5804],[-84.9604,32.4215],[-85.0042,32.323],[-84.8892,32.2627],[-85.059,32.1367],[-85.0535,32.0108],[-85.1411,31.841],[-85.0426,31.5398],[-85.1138,31.2769],[-85.0042,31.003],[-85.4971,30.9975],[-87.6003,30.9975],[-87.6331,30.8661],[-87.4086,30.6744],[-87.4469,30.5101],[-87.3702,30.4279],[-87.5181,30.2801],[-87.6551,30.2472],[-87.907,30.4115],[-87.9344,30.658],[-88.0111,30.6854],[-88.1042,30.4991],[-88.137,30.3184],[-88.3944,30.3677],[-88.4711,31.8958],[-88.2411,33.7963],[-88.0987,34.8916],[-88.2027,34.9957]]]},{"id":1,"code":"AK","name":"Alaska","label_lon":-152.8897,"label_lat":62.9226,"rings":[[[-131.602,55.118],[-131.5692,55.2823],[-131.3556,55.1837],[-131.3884,55.0139],[-131.6458,55.0358]],[[-131.8321,55.4247],[-131.6458,55.3042],[-131.7499,55.1289],[-131.8321,55.1892]],[[-132.9767,56.4379],[-132.7357,56.4598],[-132.6317,56.4215],[-132.6645,56.2736],[-132.8781,56.2408],[-133.0698,56.3339]],[[-133.5956,56.3503],[-133.1629,56.3174],[-133.0534,56.1257],[-132.6207,55.9121],[-132.4729,55.7807],[-132.4619,55.6712],[-132.3578,55.6492],[-132.3414,55.5068],[-132.1661,55.3644],[-132.1442,55.2385],[-132.0292,55.2768],[-131.9799,55.1782],[-131.958,54.7894],[-132.0292,54.7017],[-132.3085,54.7182],[-132.3852,54.9153],[-132.4838,54.8989],[-132.6865,55.0468],[-132.7467,54.9975],[-132.9165,55.0468],[-132.8891,54.8989],[-132.7303,54.9372],[-132.6262,54.8825],[-132.6755,54.6798],[-132.8672,54.7017],[-133.1575,54.9592],[-133.2396,55.0906],[-133.2232,55.2275],[-133.4532,55.2166],[-133.4532,55.3206],[-133.278,55.3316],[-133.1027,55.4247],[-133.1794,55.589],[-133.3875,55.6219],[-133.4204,55.8848],[-133.497,56.0162],[-133.6394,55.9231],[-133.6942,56.071],[-133.5463,56.1422],[-133.6668,56.312]],[[-133.738,55.5561],[-133.5463,55.4904],[-133.4149,55.5726],[-133.2834,55.5342],[-133.4204,55.3864],[-133.634,55.4302]],[[-133.9078,56.9308],[-134.0502,57.0294],[-133.8859,57.0952],[-133.3437,57.002],[-133.1027,57.0075],[-132.9329,56.8213],[-132.6207,56.668],[-132.6536,56.5529],[-132.8179,56.4927],[-133.0425,56.5201],[-133.2013,56.4489],[-133.4204,56.4927],[-133.6613,56.4489],[-133.7106,56.6844],[-133.6887,56.8377],[-133.8695,56.8432]],[[-134.1159,56.4817],[-134.2529,56.5584],[-134.4007,56.7227],[-134.4172,56.8487],[-134.2967,56.9089],[-134.1707,56.8487],[-134.1433,56.9528],[-133.749,56.772],[-133.7106,56.5968],[-133.8476,56.5748],[-133.9352,56.3777],[-133.8366,56.3229],[-133.9571,56.0929],[-134.1105,56.1422],[-134.1324,55.9998],[-134.231,56.071],[-134.2912,56.3503]],[[-134.6362,56.2846],[-134.6691,56.1696],[-134.806,56.2353],[-135.1785,56.6789],[-135.414,56.8104],[-135.3318,56.9144],[-135.4249,57.1664],[-135.6878,57.369],[-135.4194,57.5662],[-135.299,57.484],[-135.0634,57.4183],[-134.8498,57.4073],[-134.8444,57.2485],[-134.6362,56.7282]],[[-134.7129,58.2234],[-134.3734,58.1467],[-134.1762,58.1577],[-134.1871,58.081],[-133.9023,57.8072],[-134.0995,57.851],[-134.1488,57.7579],[-133.9352,57.6155],[-133.8695,57.3635],[-134.0831,57.2978],[-134.1543,57.2102],[-134.4993,57.0294],[-134.6034,57.0349],[-134.6472,57.2266],[-134.576,57.3416],[-134.6089,57.5114],[-134.7294,57.7195],[-134.7074,57.8291],[-134.7841,58.0974],[-134.9156,58.2125],[-134.9539,58.4096]],[[-135.8576,57.3307],[-135.7152,57.3307],[-135.5673,57.1499],[-135.633,57.024],[-135.8576,56.9966],[-135.8247,57.1937]],[[-136.2793,58.207],[-135.9781,58.2015],[-135.7809,58.2891],[-135.4961,58.1686],[-135.6495,58.0372],[-135.5947,57.9879],[-135.4523,58.1358],[-135.1073,58.0865],[-134.9156,57.9769],[-135.0251,57.7798],[-134.9375,57.7633],[-134.8225,57.5005],[-135.0854,57.4621],[-135.5728,57.6757],[-135.5564,57.4566],[-135.7097,57.369],[-135.8905,57.4073],[-136.0,57.5443],[-136.2081,57.6374],[-136.367,57.8291],[-136.5696,57.9167],[-136.5587,58.0755],[-136.4217,58.1303],[-136.3779,58.2672]],[[-147.0799,60.2006],[-147.5016,59.9486],[-147.5344,59.8501],[-147.874,59.7843],[-147.8028,59.9377],[-147.4359,60.0965],[-147.2058,60.2718]],[[-147.5618,60.5785],[-147.6166,60.3704],[-147.759,60.1568],[-147.9562,60.228],[-147.7919,60.4744]],[[-147.7864,70.2453],[-147.6823,70.2015],[-147.162,70.1577],[-146.8882,70.185],[-146.5103,70.185],[-146.0995,70.1467],[-145.8585,70.1686],[-145.623,70.0865],[-145.1958,69.9934],[-144.6207,69.9714],[-144.4619,70.0262],[-144.0785,70.0591],[-143.9142,70.1303],[-143.4979,70.1412],[-143.5034,70.0919],[-143.2569,70.1193],[-142.7476,70.0426],[-142.4025,69.9167],[-142.0794,69.8564],[-142.0082,69.8017],[-141.7125,69.7907],[-141.4331,69.6976],[-141.3784,69.6373],[-141.2086,69.6866],[-141.0005,69.6483],[-141.0005,60.3046],[-140.5349,60.2225],[-140.4747,60.3101],[-139.9872,60.1842],[-139.6969,60.343],[-139.089,60.3594],[-139.1985,60.091],[-139.0452,59.9979],[-138.7001,59.9103],[-138.6235,59.7679],[-137.6047,59.2421],[-137.4459,58.908],[-137.2652,59.0011],[-136.827,59.16],[-136.5806,59.165Line truncated
|
||||
@@ -0,0 +1 @@
|
||||
{"type":"waz","regions":[{"id":1,"code":"1","name":"1","label_lon":-140,"label_lat":75,"rings":[[[-109.0,85.0],[-109.0,77.5],[-103.2588,75.2824],[-103.4495,72.3827],[-99.2896,70.6113],[-102.0,68.0],[-102.0,60.0],[-139.0569,60.0001],[-138.7074,59.9061],[-138.6686,59.8091],[-138.626,59.7686],[-137.6062,59.2436],[-137.5007,58.9835],[-137.5266,58.9059],[-137.4512,58.9081],[-137.281,59.001],[-136.8272,59.1604],[-136.5843,59.166],[-136.4897,59.2595],[-136.4956,59.2754],[-136.4695,59.284],[-136.4771,59.4656],[-136.3681,59.449],[-136.3055,59.4644],[-136.2387,59.5242],[-136.2393,59.5592],[-136.3538,59.5998],[-136.1933,59.6401],[-135.9529,59.6622],[-135.4793,59.798],[-135.3601,59.7376],[-135.2336,59.6962],[-135.2198,59.6627],[-135.1575,59.6257],[-135.1179,59.6231],[-135.0289,59.5637],[-135.0275,59.4748],[-135.101,59.4276],[-134.9897,59.3871],[-135.0305,59.3466],[-134.9596,59.2808],[-134.7007,59.2489],[-134.6793,59.1925],[-134.5659,59.1312],[-134.4831,59.1316],[-134.3821,59.0386],[-134.4072,58.9796],[-134.3138,58.962],[-134.3365,58.9235],[-134.2579,58.8612],[-133.8404,58.7293],[-133.7068,58.6117],[-133.3772,58.4303],[-133.462,58.3879],[-133.346,58.2769],[-133.1722,58.1536],[-133.0701,58.0002],[-132.8689,57.8393],[-132.7512,57.6962],[-132.5542,57.4971],[-132.37,57.35],[-132.2487,57.2116],[-132.3688,57.0917],[-132.0449,57.045],[-132.1237,56.8739],[-131.8735,56.806],[-131.9009,56.7537],[-131.8604,56.703],[-131.8353,56.5994],[-131.5815,56.6123],[-131.4716,56.5523],[-131.1726,56.4494],[-131.0884,56.4062],[-130.7825,56.3671],[-130.6242,56.2672],[-130.4683,56.2431],[-130.4264,56.1415],[-130.2468,56.0966],[-130.104,56.1229],[-130.0037,56.0081],[-130.0178,55.9119],[-130.0041,55.9074],[-130.1524,55.7664],[-130.1481,55.7152],[-130.1118,55.6829],[-130.1281,55.5809],[-130.09,55.498],[-130.0413,55.4517],[-130.0211,55.3379],[-129.9742,55.282],[-130.1029,55.1926],[-130.1876,55.0629],[-130.346,54.9177],[-130.5682,54.7922],[-130.6591,54.7631],[-130.6257,54.7381],[-130.6156,54.7074],[-134.5376,54.2944],[-140.0,50.0],[-140.0,40.0],[-190.0,40.0],[-190.0,53.0],[-167.6871,66.6671],[-180.0,75.0],[-180.0,85.0]]]},{"id":2,"code":"2","name":"2","label_lon":-82.5,"label_lat":70,"rings":[[[-109.0,85.0],[-109.0,77.5],[-103.2588,75.2824],[-103.4495,72.3827],[-99.2896,70.6113],[-102.0,68.0],[-102.0,60.0],[-85.0,60.0],[-80.8725,54.5159],[-79.7772,51.8514],[-79.6522,51.6179],[-79.5157,51.4606],[-79.5176,50.0],[-60.0,50.0],[-55.9546,51.7455],[-55.4041,51.765],[-49.0,53.0],[-52.2437,59.5838],[-57.8076,67.1394],[-67.854,74.2269],[-74.0332,75.4972],[-74.8618,77.4191],[-73.5809,78.5637],[-69.8831,79.5013],[-68.2947,80.439],[-60.0073,82.2668],[-60.0,85.0]]]},{"id":3,"code":"3","name":"3","label_lon":-125,"label_lat":45,"rings":[[[-120.0,60.0],[-139.0569,60.0001],[-138.7074,59.9061],[-138.6686,59.8091],[-138.626,59.7686],[-137.6062,59.2436],[-137.5007,58.9835],[-137.5266,58.9059],[-137.4512,58.9081],[-137.281,59.001],[-136.8272,59.1604],[-136.5843,59.166],[-136.4897,59.2595],[-136.4956,59.2754],[-136.4695,59.284],[-136.4771,59.4656],[-136.3681,59.449],[-136.3055,59.4644],[-136.2387,59.5242],[-136.2393,59.5592],[-136.3538,59.5998],[-136.1933,59.6401],[-135.9529,59.6622],[-135.4793,59.798],[-135.3601,59.7376],[-135.2336,59.6962],[-135.2198,59.6627],[-135.1575,59.6257],[-135.1179,59.6231],[-135.0289,59.5637],[-135.0275,59.4748],[-135.101,59.4276],[-134.9897,59.3871],[-135.0305,59.3466],[-134.9596,59.2808],[-134.7007,59.2489],[-134.6793,59.1925],[-134.5659,59.1312],[-134.4831,59.1316],[-134.3821,59.0386],[-134.4072,58.9796],[-134.3138,58.962],[-134.3365,58.9235],[-134.2579,58.8612],[-133.8404,58.7293],[-133.7068,58.6117],[-133.3772,58.4303],[-133.462,58.3879],[-133.346,58.2769],[-133.1722,58.1536],[-133.0701,58.0002],[-132.8689,57.8393],[-132.7512,57.6962],[-132.5542,57.4971],[-132.37,57.35],[-132.2487,57.2116],[-132.3688,57.0917],[-132.0449,57.045],[-132.1237,56.8739],[-131.8735,56.806],[-131.9009,56.7537],[-131.8604,56.703],[-131.8353,56.5994],[-131.5815,56.6123],[-131.4716,56.5523],[-131.1726,56.4494],[-131.0884,56.4062],[-130.7825,56.3671],[-130.6242,56.2672],[-130.4683,56.2431],[-130.4264,56.1415],[-130.2468,56.0966],[-130.104,56.1229],[-130.0037,56.0081],[-130.0178,55.9119],[-130.0041,55.9074],[-130.1524,55.7664],[-130.1481,55.7152],[-130.1118,55.6829],[-130.1281,55.5809],[-130.09,55.498],[-130.0413,55.4517],[-130.0211,55.3379],[-129.9742,55.282],[-130.1029,55.1926],[-130.1876,55.0629],[-130.346,54.9177],[-130.5682,54.7922],[-130.6591,54.7631],[-130.6257,54.7381],[-130.6156,54.7074],[-134.5376,54.2944],[-140.0,50.0],[-140.0,40.0],[-130.0,40.0],[-130.0,32.5],[-120.0,32.5],[-117.1249,32.534],[-114.7197,32.7187],[-114.7643,32.6427],[-114.8087,32.6191],[-114.7997,32.5924],[-114.8137,32.5613],[-114.7932,32.5696],[-114.7916,32.5558],[-114.8057,32.5467],[-114.8135,32.4943],[-111.0748,31.3322],[-109.05,31.3325],[-109.0501,41.0007],[-111.0467,40.9979],[-111.049,44.474],[-111.1227,44.4937],[-111.1394,44.5171],[-111.1317,44.5326],[-111.177,44.5509],[-111.1808,44.569Line truncated
|
||||
@@ -0,0 +1,342 @@
|
||||
/*
|
||||
* 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.map
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Color
|
||||
import android.graphics.Paint
|
||||
import android.graphics.Path
|
||||
import org.json.JSONObject
|
||||
import org.osmdroid.views.MapView
|
||||
import org.osmdroid.views.Projection
|
||||
import org.osmdroid.views.overlay.Overlay
|
||||
import java.io.InputStream
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.max
|
||||
import kotlin.math.min
|
||||
|
||||
/**
|
||||
* One administrative/boundary region loaded from the award GeoJSON assets.
|
||||
*
|
||||
* @param code match key against award statistics (e.g. "GD", "34", "24", "CA", "318")
|
||||
* @param name display label (province/prefecture/state/country name)
|
||||
* @param labelLon/labelLat preferred label anchor (usually the region centroid)
|
||||
* @param rings polygon exterior rings as lon/lat pairs; the overlay projects
|
||||
* them per frame with the same projection helpers as the grid overlay
|
||||
*/
|
||||
data class AwardRegion(
|
||||
val code: String,
|
||||
val name: String,
|
||||
val labelLon: Double,
|
||||
val labelLat: Double,
|
||||
val rings: List<List<DoubleArray>>
|
||||
)
|
||||
|
||||
/**
|
||||
* Loads and caches the award boundary assets (assets/awards/<type>.json).
|
||||
* Format (generated by the awarddata/convert2.py pipeline):
|
||||
* {"type": "wapc", "regions": [{"id":..,"code":"GD","name":"广东省",
|
||||
* "label_lon":..,"label_lat":..,"rings":[[[lon,lat],...],...]}, ...]}
|
||||
*/
|
||||
object AwardBoundaryData {
|
||||
|
||||
private val cache = mutableMapOf<AwardAsset, List<AwardRegion>>()
|
||||
|
||||
enum class AwardAsset(val fileName: String) {
|
||||
WAPC("wapc.json"),
|
||||
WAJA("waja.json"),
|
||||
WAZ("waz.json"),
|
||||
WAS("was.json"),
|
||||
DXCC("dxcc.json")
|
||||
}
|
||||
|
||||
fun load(context: Context, asset: AwardAsset): List<AwardRegion> {
|
||||
cache[asset]?.let { return it }
|
||||
val regions = try {
|
||||
val stream: InputStream = context.assets.open("awards/${asset.fileName}")
|
||||
val text = stream.bufferedReader().use { it.readText() }
|
||||
val root = JSONObject(text)
|
||||
val array = root.optJSONArray("regions") ?: return emptyList()
|
||||
val list = mutableListOf<AwardRegion>()
|
||||
for (i in 0 until array.length()) {
|
||||
val o = array.optJSONObject(i) ?: continue
|
||||
val ringsArray = o.optJSONArray("rings") ?: continue
|
||||
val rings = mutableListOf<List<DoubleArray>>()
|
||||
for (r in 0 until ringsArray.length()) {
|
||||
val ring = ringsArray.optJSONArray(r) ?: continue
|
||||
val points = mutableListOf<DoubleArray>()
|
||||
for (p in 0 until ring.length()) {
|
||||
val pt = ring.optJSONArray(p) ?: continue
|
||||
if (pt.length() >= 2) points.add(doubleArrayOf(pt.getDouble(0), pt.getDouble(1)))
|
||||
}
|
||||
if (points.size >= 3) rings.add(points)
|
||||
}
|
||||
if (rings.isEmpty()) continue
|
||||
list.add(
|
||||
AwardRegion(
|
||||
code = o.optString("code"),
|
||||
name = o.optString("name"),
|
||||
labelLon = o.optDouble("label_lon", 0.0),
|
||||
labelLat = o.optDouble("label_lat", 0.0),
|
||||
rings = rings
|
||||
)
|
||||
)
|
||||
}
|
||||
list
|
||||
} catch (_: Exception) {
|
||||
emptyList()
|
||||
}
|
||||
cache[asset] = regions
|
||||
return regions
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders award boundary regions (provinces/prefectures/CQ zones/states/
|
||||
* countries) with the same visual language as the VUCC grid overlay:
|
||||
* yellow boundary lines, green fill for worked regions, yellow labels.
|
||||
*
|
||||
* Worked regions are matched by [workedCodes] against each region's [AwardRegion.code]
|
||||
* (e.g. {"GD", "ZJ", "HK", ...} for WAPC). All region boundaries are always drawn
|
||||
* so missing regions stay visible; only worked ones get the green fill.
|
||||
*/
|
||||
class AwardBoundaryOverlay : Overlay() {
|
||||
|
||||
private val linePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||
strokeWidth = 1.5f
|
||||
style = Paint.Style.STROKE
|
||||
color = Color.argb(160, 255, 224, 130)
|
||||
}
|
||||
private val labelPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||
textSize = 22f
|
||||
style = Paint.Style.FILL
|
||||
color = Color.argb(220, 255, 224, 130)
|
||||
setShadowLayer(3f, 2f, 2f, Color.BLACK)
|
||||
}
|
||||
private val workedPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||
style = Paint.Style.FILL
|
||||
color = Color.argb(90, 76, 217, 100)
|
||||
}
|
||||
|
||||
/** Boundary regions to render; assigning recomputes culling bounds. */
|
||||
var regions: List<AwardRegion> = emptyList()
|
||||
set(value) {
|
||||
field = value
|
||||
regionBounds.clear()
|
||||
for (r in value) {
|
||||
var minLon = Double.MAX_VALUE; var minLat = Double.MAX_VALUE
|
||||
var maxLon = -Double.MAX_VALUE; var maxLat = -Double.MAX_VALUE
|
||||
for (ring in r.rings) for (pt in ring) {
|
||||
if (pt[0] < minLon) minLon = pt[0]
|
||||
if (pt[0] > maxLon) maxLon = pt[0]
|
||||
if (pt[1] < minLat) minLat = pt[1]
|
||||
if (pt[1] > maxLat) maxLat = pt[1]
|
||||
}
|
||||
regionBounds.add(doubleArrayOf(minLon, minLat, maxLon, maxLat))
|
||||
}
|
||||
}
|
||||
|
||||
/** Codes of worked regions -> green fill. */
|
||||
var workedCodes: Set<String> = emptySet()
|
||||
|
||||
/** Bounding boxes (computed on region assignment) for cheap culling. */
|
||||
private val regionBounds = mutableListOf<DoubleArray>() // [minLon,minLat,maxLon,maxLat]
|
||||
|
||||
override fun setEnabled(on: Boolean) {
|
||||
super.setEnabled(on)
|
||||
}
|
||||
|
||||
override fun draw(canvas: Canvas, mapView: MapView, shadow: Boolean) {
|
||||
if (shadow || !isEnabled || regions.isEmpty()) return
|
||||
val projection = mapView.projection
|
||||
val zoom = mapView.zoomLevelDouble
|
||||
val worldWidthPx = 256.0 * Math.pow(2.0, zoom)
|
||||
|
||||
// Visible bounding box (same anchor-on-center approach as the grid overlay).
|
||||
val north = projection.fromPixels(0, 0)
|
||||
val south = projection.fromPixels(canvas.width, canvas.height)
|
||||
val topLat = max(north.latitude, south.latitude).coerceIn(-90.0, 90.0)
|
||||
val bottomLat = min(north.latitude, south.latitude).coerceIn(-90.0, 90.0)
|
||||
val viewCenter = projection.fromPixels(canvas.width / 2, canvas.height / 2)
|
||||
val centerLon = viewCenter.longitude
|
||||
val halfSpanDeg = (canvas.width / 2.0) / worldWidthPx * 360.0
|
||||
val leftLon = centerLon - halfSpanDeg
|
||||
val rightLon = centerLon + halfSpanDeg
|
||||
|
||||
labelPaint.textAlign = Paint.Align.CENTER
|
||||
val fontMetrics = labelPaint.fontMetrics
|
||||
val textHalfHeight = (fontMetrics.descent + fontMetrics.ascent) / 2f
|
||||
|
||||
for (i in regions.indices) {
|
||||
val region = regions[i]
|
||||
val b = regionBounds.getOrNull(i) ?: continue
|
||||
// Cull: region bbox outside the visible span (with wrap slack).
|
||||
if (b[1] > topLat || b[3] < bottomLat) continue
|
||||
if (b[0] > rightLon || b[2] < leftLon) continue
|
||||
val worked = region.code in workedCodes
|
||||
|
||||
val path = Path()
|
||||
var pathHasPoints = false
|
||||
for (ring in region.rings) {
|
||||
// Split each ring into consecutive segments at the antimeridian
|
||||
// (±180°) and draw each as its own sub-path. Naively normalizing
|
||||
// every vertex to [-180,180) folds vertices that sit on the
|
||||
// "far" side of ±180 onto the opposite screen edge, drawing a
|
||||
// spurious line that sweeps across the whole map (Russia/Fiji/
|
||||
// Antarctica for DXCC, the Pacific CQ zones for WAZ). Splitting
|
||||
// keeps each segment contiguous. Each segment then gets ONE
|
||||
// per-segment longitude offset (in whole 360° turns) chosen to
|
||||
// sit closest to the view center, so vertices far from the
|
||||
// center are not folded to ±180 the wrong way at low zoom.
|
||||
for (segment in splitRingAtAntimeridian(ring)) {
|
||||
if (segment.isEmpty()) continue
|
||||
pathHasPoints = traceSegment(path, segment, projection, centerLon, worldWidthPx) || pathHasPoints
|
||||
}
|
||||
}
|
||||
if (!pathHasPoints) continue
|
||||
|
||||
if (worked) canvas.drawPath(path, workedPaint)
|
||||
canvas.drawPath(path, linePaint)
|
||||
|
||||
// Label: draw when its anchor is on screen and the region is big
|
||||
// enough on screen to hold the text (avoid clutter at low zoom).
|
||||
val labelLonNorm = normalizeLon(region.labelLon)
|
||||
if (labelLonNorm < leftLon - 1.0 || labelLonNorm > rightLon + 1.0) continue
|
||||
if (region.labelLat < bottomLat - 1.0 || region.labelLat > topLat + 1.0) continue
|
||||
val lx = projectionToX(projection, labelLonNorm, centerLon, worldWidthPx) ?: continue
|
||||
val ly = projectionToY(projection, region.labelLat) ?: continue
|
||||
// Screen-size gate: the label only when the region spans enough px.
|
||||
val regionH = (projectionToY(projection, b[3]) ?: 0f) - (projectionToY(projection, b[1]) ?: 0f)
|
||||
if (abs(regionH) < MIN_LABEL_REGION_PX) continue
|
||||
canvas.drawText(region.name, lx, ly - textHalfHeight, labelPaint)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Splits a ring's vertices into consecutive segments that do NOT cross the
|
||||
* antimeridian. Vertices are first normalized to [-180,180); whenever two
|
||||
* consecutive vertices differ by more than 180° in longitude (the ring
|
||||
* wraps over ±180), the segment is closed there and a new segment starts.
|
||||
*
|
||||
* A ring that crosses the antimeridian (e.g. Russia's eastern tip, or the
|
||||
* Pacific CQ zones whose raw lon runs past ±180) becomes two segments, each
|
||||
* kept geometrically contiguous so no spurious long edge is drawn.
|
||||
*/
|
||||
private fun splitRingAtAntimeridian(ring: List<DoubleArray>): List<List<DoubleArray>> {
|
||||
val n = ring.size
|
||||
if (n == 0) return emptyList()
|
||||
val pts = Array(n) { doubleArrayOf(normalizeLon(ring[it][0]), ring[it][1]) }
|
||||
val segments = mutableListOf<MutableList<DoubleArray>>()
|
||||
var cur = mutableListOf<DoubleArray>()
|
||||
cur.add(pts[0])
|
||||
for (i in 1 until n) {
|
||||
val prevLon = pts[i - 1][0]
|
||||
val lon = pts[i][0]
|
||||
if (abs(lon - prevLon) > 180.0) {
|
||||
// Crossed the antimeridian: start a fresh segment.
|
||||
if (cur.size >= 2) segments.add(cur)
|
||||
cur = mutableListOf()
|
||||
}
|
||||
cur.add(pts[i])
|
||||
}
|
||||
if (cur.size >= 2) segments.add(cur)
|
||||
// A fully-winding ring may have collapsed into a single segment after
|
||||
// normalization (no actual crossing between consecutive vertices); in
|
||||
// that case fall back to the whole ring as one segment.
|
||||
if (segments.isEmpty()) segments.add(mutableListOf<DoubleArray>().also { it.addAll(pts) })
|
||||
return segments
|
||||
}
|
||||
|
||||
/**
|
||||
* Projects [segment] into [path], keeping the segment's geometry
|
||||
* contiguous on screen.
|
||||
*
|
||||
* Longitudes are normalized once when the ring is split, and every vertex
|
||||
* of a segment lies within 180° of its neighbours (a segment never crosses
|
||||
* the antimeridian). We anchor on the segment's FIRST vertex: its screen x
|
||||
* comes from [projectionToX] (correctly placed relative to the view
|
||||
* center), and every other vertex is offset by the *continuous* longitude
|
||||
* difference from that anchor, scaled by worldWidthPx. Because the whole
|
||||
* segment stays on the "near" side of ±180 relative to its anchor, no
|
||||
* vertex is folded to the opposite screen edge — that fold is what drew
|
||||
* the spurious long lines across the map (Russia/Fiji/Antarctica for
|
||||
* DXCC, the Pacific CQ zones for WAZ).
|
||||
*
|
||||
* @return true if at least one point was placed in the path
|
||||
*/
|
||||
private fun traceSegment(
|
||||
path: Path,
|
||||
segment: List<DoubleArray>,
|
||||
projection: Projection,
|
||||
centerLon: Double,
|
||||
worldWidthPx: Double
|
||||
): Boolean {
|
||||
if (segment.isEmpty()) return false
|
||||
val anchorLon = segment[0][0]
|
||||
val anchorX = projectionToX(projection, anchorLon, centerLon, worldWidthPx) ?: return false
|
||||
var penDown = false
|
||||
var any = false
|
||||
for (i in segment.indices) {
|
||||
val pt = segment[i]
|
||||
// Continuous longitude difference from the anchor — no per-vertex
|
||||
// fold. The segment spans < 180° (guaranteed by the antimeridian
|
||||
// split), so delta stays within a single turn of the anchor.
|
||||
val delta = pt[0] - anchorLon
|
||||
val x = anchorX + (delta / 360.0 * worldWidthPx).toFloat()
|
||||
val y = projectionToY(projection, pt[1]) ?: continue
|
||||
if (!penDown) {
|
||||
path.moveTo(x, y)
|
||||
penDown = true
|
||||
} else {
|
||||
path.lineTo(x, y)
|
||||
}
|
||||
any = true
|
||||
}
|
||||
if (penDown) path.close()
|
||||
return any
|
||||
}
|
||||
|
||||
/** X pixel for a longitude (Mercator X is linear in longitude). */
|
||||
private fun projectionToX(projection: Projection, lon: Double, centerLon: Double, worldWidthPx: Double): Float? {
|
||||
var delta = lon - centerLon
|
||||
while (delta > 180.0) delta -= 360.0
|
||||
while (delta < -180.0) delta += 360.0
|
||||
val geo = org.osmdroid.util.GeoPoint(0.0, centerLon)
|
||||
val p = projection.toPixels(geo, null)
|
||||
return (p.x + delta / 360.0 * worldWidthPx).toFloat()
|
||||
}
|
||||
|
||||
/** Y pixel for a latitude. */
|
||||
private fun projectionToY(projection: Projection, lat: Double): Float? {
|
||||
val geo = org.osmdroid.util.GeoPoint(lat, 0.0)
|
||||
val p = projection.toPixels(geo, null)
|
||||
return p.y.toFloat()
|
||||
}
|
||||
|
||||
private fun normalizeLon(lon: Double): Double {
|
||||
var l = lon % 360.0
|
||||
if (l >= 180.0) l -= 360.0
|
||||
if (l < -180.0) l += 360.0
|
||||
return l
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val MIN_LABEL_REGION_PX = 42f
|
||||
}
|
||||
}
|
||||
+183
-66
@@ -61,6 +61,11 @@ class MaidenheadGridOverlay : Overlay() {
|
||||
style = Paint.Style.STROKE
|
||||
color = Color.argb(255, 90, 200, 255)
|
||||
}
|
||||
private val selectedLinePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||
strokeWidth = 4.5f
|
||||
style = Paint.Style.STROKE
|
||||
color = Color.argb(255, 76, 217, 100)
|
||||
}
|
||||
|
||||
/** Worked gridsquares (4-char, uppercase) to highlight, e.g. {"OL62", "PM95"}. */
|
||||
var workedGrids: Set<String> = emptySet()
|
||||
@@ -68,6 +73,9 @@ class MaidenheadGridOverlay : Overlay() {
|
||||
/** The station's own 4-char gridsquare, drawn with a distinct outline. */
|
||||
var ownGrid: String? = null
|
||||
|
||||
/** The currently tapped worked grid (4-char) highlighted with a distinct outline, or null. */
|
||||
var selectedGrid: String? = null
|
||||
|
||||
/** Viewport width, refreshed each draw; used by projectionToX bounds. */
|
||||
private var canvasWidthPx = 1080f
|
||||
|
||||
@@ -79,91 +87,170 @@ class MaidenheadGridOverlay : Overlay() {
|
||||
val cellLat = if (zoom >= GRID_ZOOM_SUB) SUB_SQUARE_LAT else FIELD_LAT
|
||||
val cellLon = if (zoom >= GRID_ZOOM_SUB) SUB_SQUARE_LON else FIELD_LON
|
||||
|
||||
// Visible bounding box in geographic coordinates
|
||||
// Visible bounding box. Do NOT derive longitude bounds from the left/
|
||||
// right edge pixels: osmdroid normalizes them to [-180,180), which both
|
||||
// garbles antimeridian-crossing views AND makes any global view (span
|
||||
// > 180°) look like an antimeridian crossing — unwrapping then produced
|
||||
// an inverted range and ALL meridians vanished at low zoom. Instead
|
||||
// anchor on the view-center longitude and expand by the half-width in
|
||||
// degrees; the range is continuous (may exceed ±180) and cell indices
|
||||
// beyond 18 wrap correctly via normalizeLon().
|
||||
val north = projection.fromPixels(0, 0)
|
||||
val south = projection.fromPixels(canvas.width, canvas.height)
|
||||
val topLat = max(north.latitude, south.latitude).coerceIn(-90.0, 90.0)
|
||||
val bottomLat = min(north.latitude, south.latitude).coerceIn(-90.0, 90.0)
|
||||
val leftLon = min(north.longitude, south.longitude)
|
||||
val rightLon = max(north.longitude, south.longitude)
|
||||
val worldWidthPx = 256.0 * Math.pow(2.0, zoom)
|
||||
val viewCenter = projection.fromPixels(canvas.width / 2, canvas.height / 2)
|
||||
val centerLon = viewCenter.longitude
|
||||
val halfSpanDeg = (canvas.width / 2.0) / worldWidthPx * 360.0
|
||||
val leftLon = centerLon - halfSpanDeg
|
||||
val rightLon = centerLon + halfSpanDeg
|
||||
|
||||
val firstRow = floor(bottomLat / cellLat).toInt()
|
||||
val lastRow = ceil(topLat / cellLat).toInt()
|
||||
// Longitude cell indices may exceed the [-180, 180) range when the view
|
||||
// crosses the antimeridian; index arithmetic still works (PMxx at 360° == same grid)
|
||||
// Longitude cell indices are in the continuous unwrapped space and may
|
||||
// exceed the [-180, 180) range when the view crosses the antimeridian
|
||||
// or spans world repeats; labels normalize each cell back.
|
||||
val firstCol = floor(leftLon / cellLon).toInt()
|
||||
val lastCol = ceil(rightLon / cellLon).toInt()
|
||||
val centerLon = (leftLon + rightLon) / 2.0
|
||||
// At low zoom the world is narrower than the viewport and osmdroid shows
|
||||
// repeating copies on both sides. The visible bounding box spans more
|
||||
// than 360° of longitude there; extend the column range by whole world
|
||||
// turns so meridians, fills and labels tile across the repeats too.
|
||||
val worldTurns = ceil(((rightLon - leftLon) / 360.0) - 1e-9).toInt().coerceAtLeast(0)
|
||||
val colRepeats = if (worldTurns > 0) worldTurns else 0
|
||||
|
||||
// The station's own grid square (4-char, only meaningful at sub-square zoom)
|
||||
val ownCell = ownGrid?.takeIf { zoom >= GRID_ZOOM_SUB }
|
||||
// The tapped worked grid gets a distinct outline (same zoom gate).
|
||||
val selectedCell = selectedGrid?.takeIf { zoom >= GRID_ZOOM_SUB }
|
||||
|
||||
// Worked-grid highlight fills (only meaningful at sub-square zoom)
|
||||
if (workedGrids.isNotEmpty() && zoom >= GRID_ZOOM_SUB) {
|
||||
for (row in firstRow..lastRow) {
|
||||
val lat = row * cellLat
|
||||
if (lat < -90.0 || lat >= 90.0) continue
|
||||
val topLatCell = lat + cellLat
|
||||
if (topLatCell > 90.0) continue
|
||||
val yTop = projectionToY(projection, topLatCell)
|
||||
val yBottom = projectionToY(projection, lat)
|
||||
if (yTop == null || yBottom == null) continue
|
||||
for (col in firstCol..lastCol) {
|
||||
val lon = col * cellLon
|
||||
val xLeft = projectionToX(projection, lon, centerLon) ?: continue
|
||||
val xRight = projectionToX(projection, lon + cellLon, centerLon) ?: continue
|
||||
if (xRight < 0f || xLeft > canvas.width) continue
|
||||
if (cellLabel(lat, lon, zoom) in workedGrids) {
|
||||
// Worked-grid highlight fills.
|
||||
// - Sub-square zoom: fill each worked 4-char cell directly.
|
||||
// - Field zoom (two-char labels): do NOT fill whole fields; instead fill
|
||||
// the individual 2°x1° squares that were worked, without drawing the
|
||||
// square grid lines — so you see green patches inside the field.
|
||||
if (workedGrids.isNotEmpty()) {
|
||||
if (zoom >= GRID_ZOOM_SUB) {
|
||||
for (row in firstRow..lastRow) {
|
||||
val lat = row * cellLat
|
||||
if (lat < -90.0 || lat >= 90.0) continue
|
||||
val topLatCell = lat + cellLat
|
||||
if (topLatCell > 90.0) continue
|
||||
val yTop = projectionToY(projection, topLatCell)
|
||||
val yBottom = projectionToY(projection, lat)
|
||||
if (yTop == null || yBottom == null) continue
|
||||
for (turn in -colRepeats..colRepeats) for (col in firstCol..lastCol) {
|
||||
val lon = col * cellLon
|
||||
// World-repeat copies: keep the turn offset in pixels
|
||||
// (colRepeats is 0 at this zoom today, but the same
|
||||
// alpha-stacking fix applies if it ever becomes > 0).
|
||||
val xLeftBase = projectionToX(projection, lon, centerLon, worldWidthPx) ?: continue
|
||||
val xRightBase = projectionToX(projection, lon + cellLon, centerLon, worldWidthPx) ?: continue
|
||||
val xLeft = xLeftBase + turn * worldWidthPx.toFloat()
|
||||
val xRight = xRightBase + turn * worldWidthPx.toFloat()
|
||||
if (xRight < 0f || xLeft > canvas.width) continue
|
||||
if (cellLabel(lat, lon, zoom) in workedGrids) {
|
||||
canvas.drawRect(xLeft, yTop, xRight, yBottom, workedPaint)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for (grid in workedGrids) {
|
||||
val cell = gridCellBounds(grid) ?: continue
|
||||
// World-repeat copies: a 360° turn shifts the cell by a
|
||||
// full world width in pixels. projectionToX() normalizes
|
||||
// longitude back into [-180,180), so WITHOUT adding the
|
||||
// turn*worldWidthPx offset every copy lands on the SAME
|
||||
// screen x and the cell gets painted 2*colRepeats+1 times
|
||||
// in place — alpha stacks and the green turns brighter and
|
||||
// more opaque. Keep the world-width offset on the x.
|
||||
for (turn in -colRepeats..colRepeats) {
|
||||
val dLon = turn * 360.0
|
||||
if (cell.lonRight + dLon <= leftLon || cell.lonLeft + dLon >= rightLon) continue
|
||||
val yTop = projectionToY(projection, cell.latTop) ?: continue
|
||||
val yBottom = projectionToY(projection, cell.latBottom) ?: continue
|
||||
val xLeftBase = projectionToX(projection, cell.lonLeft, centerLon, worldWidthPx) ?: continue
|
||||
val xRightBase = projectionToX(projection, cell.lonRight, centerLon, worldWidthPx) ?: continue
|
||||
val xLeft = xLeftBase + turn * worldWidthPx.toFloat()
|
||||
val xRight = xRightBase + turn * worldWidthPx.toFloat()
|
||||
if (xRight < 0f || xLeft > canvas.width) continue
|
||||
if (yBottom < 0f || yTop > canvas.height) continue
|
||||
canvas.drawRect(xLeft, yTop, xRight, yBottom, workedPaint)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Vertical lines (meridians)
|
||||
for (col in firstCol..lastCol) {
|
||||
// Vertical lines (meridians). Worked-grid cells are NOT excluded:
|
||||
// the boundary lines between adjacent worked squares must stay visible
|
||||
// so the grid structure remains readable (reverted 2026-09-09 — the
|
||||
// earlier skip logic removed those shared edges entirely).
|
||||
for (turn in -colRepeats..colRepeats) for (col in firstCol..lastCol) {
|
||||
val lon = col * cellLon
|
||||
val x = projectionToX(projection, lon, centerLon)
|
||||
if (x == null) continue
|
||||
canvas.drawLine(x, 0f, x, canvas.height.toFloat(), linePaint)
|
||||
// World-repeat copies: keep the turn offset in pixels, otherwise
|
||||
// every turn normalizes to the same x and the line paints over
|
||||
// itself 2*colRepeats+1 times (alpha stacking).
|
||||
val xBase = projectionToX(projection, lon, centerLon, worldWidthPx) ?: continue
|
||||
val x = xBase + turn * worldWidthPx.toFloat()
|
||||
for (row in firstRow..lastRow) {
|
||||
val lat = row * cellLat
|
||||
if (lat < -90.0 || lat >= 90.0) continue
|
||||
val topLatCell = lat + cellLat
|
||||
if (topLatCell > 90.0) continue
|
||||
val yTop = projectionToY(projection, topLatCell) ?: continue
|
||||
val yBottom = projectionToY(projection, lat) ?: continue
|
||||
canvas.drawLine(x, yTop, x, yBottom, linePaint)
|
||||
}
|
||||
}
|
||||
// Horizontal lines (parallels)
|
||||
// Horizontal lines (parallels).
|
||||
for (row in firstRow..lastRow) {
|
||||
val lat = row * cellLat
|
||||
if (lat <= -90.0 || lat >= 90.0) continue
|
||||
val y = projectionToY(projection, lat)
|
||||
if (y == null) continue
|
||||
canvas.drawLine(0f, y, canvas.width.toFloat(), y, linePaint)
|
||||
for (turn in -colRepeats..colRepeats) for (col in firstCol..lastCol) {
|
||||
val lon = col * cellLon
|
||||
// World-repeat copies: keep the turn offset in pixels (same
|
||||
// alpha-stacking fix as the vertical lines above).
|
||||
val xLeftBase = projectionToX(projection, lon, centerLon, worldWidthPx) ?: continue
|
||||
val xRightBase = projectionToX(projection, lon + cellLon, centerLon, worldWidthPx) ?: continue
|
||||
val xLeft = xLeftBase + turn * worldWidthPx.toFloat()
|
||||
val xRight = xRightBase + turn * worldWidthPx.toFloat()
|
||||
if (xRight < 0f || xLeft > canvas.width) continue
|
||||
canvas.drawLine(xLeft, y, xRight, y, linePaint)
|
||||
}
|
||||
}
|
||||
|
||||
// The station's own grid square: redraw its four borders thicker on top.
|
||||
if (ownCell != null) {
|
||||
val ownColIdx = firstCol..lastCol
|
||||
// The tapped worked grid gets the same treatment in a different color.
|
||||
if (ownCell != null || selectedCell != null) {
|
||||
for (row in firstRow..lastRow) {
|
||||
val lat = row * cellLat
|
||||
if (lat < -90.0 || lat >= 90.0) continue
|
||||
for (col in ownColIdx) {
|
||||
for (col in firstCol..lastCol) {
|
||||
val lon = col * cellLon
|
||||
if (cellLabel(lat, lon, zoom) != ownCell) continue
|
||||
val label = cellLabel(lat, lon, zoom)
|
||||
if (label != ownCell && label != selectedCell) continue
|
||||
val yTop = projectionToY(projection, lat + cellLat) ?: continue
|
||||
val yBottom = projectionToY(projection, lat) ?: continue
|
||||
val xLeft = projectionToX(projection, lon, centerLon) ?: continue
|
||||
val xRight = projectionToX(projection, lon + cellLon, centerLon) ?: continue
|
||||
canvas.drawLine(xLeft, yTop, xRight, yTop, ownLinePaint)
|
||||
canvas.drawLine(xLeft, yBottom, xRight, yBottom, ownLinePaint)
|
||||
canvas.drawLine(xLeft, yTop, xLeft, yBottom, ownLinePaint)
|
||||
canvas.drawLine(xRight, yTop, xRight, yBottom, ownLinePaint)
|
||||
val xLeft = projectionToX(projection, lon, centerLon, worldWidthPx) ?: continue
|
||||
val xRight = projectionToX(projection, lon + cellLon, centerLon, worldWidthPx) ?: continue
|
||||
val paint = if (label == selectedCell) selectedLinePaint else ownLinePaint
|
||||
canvas.drawLine(xLeft, yTop, xRight, yTop, paint)
|
||||
canvas.drawLine(xLeft, yBottom, xRight, yBottom, paint)
|
||||
canvas.drawLine(xLeft, yTop, xLeft, yBottom, paint)
|
||||
canvas.drawLine(xRight, yTop, xRight, yBottom, paint)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Labels: centered in each cell, only when the cell is large enough on
|
||||
// screen to hold a label (avoid clutter at low zoom)
|
||||
val showLabels = when {
|
||||
zoom >= GRID_ZOOM_SUB -> true
|
||||
zoom >= FIELD_ZOOM_LABELS -> true
|
||||
else -> false
|
||||
}
|
||||
// screen to hold a label (avoid clutter at low zoom).
|
||||
// Field (2-char) labels show at every zoom, subject only to the pixel-
|
||||
// size check below; sub-square (4-char) labels only from LABEL_ZOOM_SUB
|
||||
// (one level above the grid lines, so zoom 6 shows lines but no names).
|
||||
val showLabels = zoom >= LABEL_ZOOM_SUB || cellLat == FIELD_LAT
|
||||
if (!showLabels) return
|
||||
// Estimate on-screen cell height to avoid clutter at low zoom:
|
||||
// project two points 1° apart in latitude and measure the pixel distance.
|
||||
@@ -185,10 +272,14 @@ class MaidenheadGridOverlay : Overlay() {
|
||||
val yBottom = projectionToY(projection, lat) ?: continue
|
||||
val yCenter = (yTop + yBottom) / 2f - textHalfHeight
|
||||
if (yBottom < 0f || yTop > canvas.height) continue
|
||||
for (col in firstCol..lastCol) {
|
||||
for (turn in -colRepeats..colRepeats) for (col in firstCol..lastCol) {
|
||||
val lon = col * cellLon
|
||||
val xLeft = projectionToX(projection, lon, centerLon) ?: continue
|
||||
val xRight = projectionToX(projection, lon + cellLon, centerLon) ?: continue
|
||||
// World-repeat copies: keep the turn offset in pixels (same
|
||||
// alpha-stacking fix as the lines above).
|
||||
val xLeftBase = projectionToX(projection, lon, centerLon, worldWidthPx) ?: continue
|
||||
val xRightBase = projectionToX(projection, lon + cellLon, centerLon, worldWidthPx) ?: continue
|
||||
val xLeft = xLeftBase + turn * worldWidthPx.toFloat()
|
||||
val xRight = xRightBase + turn * worldWidthPx.toFloat()
|
||||
if (xRight < 0f || xLeft > canvas.width) continue
|
||||
val label = cellLabel(lat, lon, zoom)
|
||||
canvas.drawText(label, (xLeft + xRight) / 2f, yCenter, labelPaint)
|
||||
@@ -197,22 +288,18 @@ class MaidenheadGridOverlay : Overlay() {
|
||||
}
|
||||
|
||||
/** X pixel for a longitude (meridians are vertical in Web Mercator). */
|
||||
private fun projectionToX(projection: Projection, lon: Double, centerLon: Double): Float? {
|
||||
// osmdroid clips Mercator X to [0, mapSize], which destroys the projection of
|
||||
// longitudes outside the [0, 360) window of the current view (visible at low
|
||||
// zoom where the whole world is narrower than the viewport). Normalizing the
|
||||
// longitude to the equivalent value closest to the view center keeps every
|
||||
// meridian's X near the center, safely inside the clip window.
|
||||
var normalized = lon
|
||||
while (normalized < centerLon - 180.0) normalized += 360.0
|
||||
while (normalized > centerLon + 180.0) normalized -= 360.0
|
||||
val geo = org.osmdroid.util.GeoPoint(0.0, normalized)
|
||||
private fun projectionToX(projection: Projection, lon: Double, centerLon: Double, worldWidthPx: Double): Float? {
|
||||
// Do NOT use osmdroid's toPixels() here: at low zoom its wrap-around
|
||||
// logic (getCloserPixel) mis-projects longitudes far from the view
|
||||
// center, which makes meridians vanish while panning. Mercator X is
|
||||
// linear in longitude, so compute it directly:
|
||||
// x = screenCenterX + (lon - centerLon) / 360 * worldWidthPx
|
||||
var delta = lon - centerLon
|
||||
while (delta > 180.0) delta -= 360.0
|
||||
while (delta < -180.0) delta += 360.0
|
||||
val geo = org.osmdroid.util.GeoPoint(0.0, centerLon)
|
||||
val p = projection.toPixels(geo, null)
|
||||
// Accept generous horizontal overshoot: at low zoom the world repeats and
|
||||
// a meridian just off-screen may still be projected; rejecting too early
|
||||
// makes lines vanish while panning. 2x viewport width is plenty.
|
||||
val limit = canvasWidthPx * 2f + MAX_OVERSHOOT_PX
|
||||
return if (p.x >= -limit && p.x <= limit) p.x.toFloat() else null
|
||||
return (p.x + delta / 360.0 * worldWidthPx).toFloat()
|
||||
}
|
||||
|
||||
/** Y pixel for a latitude, or null when outside the viewport. */
|
||||
@@ -237,6 +324,30 @@ class MaidenheadGridOverlay : Overlay() {
|
||||
return "$field$squareLon$squareLat"
|
||||
}
|
||||
|
||||
/**
|
||||
* Geographic bounds of a 4-char Maidenhead square (e.g. "OL62"):
|
||||
* 2° wide in longitude, 1° tall in latitude.
|
||||
*/
|
||||
private fun gridCellBounds(grid: String): CellBounds? {
|
||||
val g = grid.trim().uppercase()
|
||||
if (g.length < 4) return null
|
||||
val fieldLon = g[0] - 'A'
|
||||
val fieldLat = g[1] - 'A'
|
||||
val sqLon = g[2] - '0'
|
||||
val sqLat = g[3] - '0'
|
||||
if (fieldLon !in 0..17 || fieldLat !in 0..17 || sqLon !in 0..9 || sqLat !in 0..9) return null
|
||||
val lonLeft = -180.0 + fieldLon * FIELD_LON + sqLon * SUB_SQUARE_LON
|
||||
val latBottom = -90.0 + fieldLat * FIELD_LAT + sqLat * SUB_SQUARE_LAT
|
||||
return CellBounds(lonLeft, lonLeft + SUB_SQUARE_LON, latBottom, latBottom + SUB_SQUARE_LAT)
|
||||
}
|
||||
|
||||
private data class CellBounds(
|
||||
val lonLeft: Double,
|
||||
val lonRight: Double,
|
||||
val latBottom: Double,
|
||||
val latTop: Double
|
||||
)
|
||||
|
||||
private fun normalizeLon(lon: Double): Double {
|
||||
var l = lon % 360.0
|
||||
if (l >= 180.0) l -= 360.0
|
||||
@@ -244,13 +355,19 @@ class MaidenheadGridOverlay : Overlay() {
|
||||
return l
|
||||
}
|
||||
|
||||
private companion object {
|
||||
internal companion object {
|
||||
const val FIELD_LAT = 10.0
|
||||
const val FIELD_LON = 20.0
|
||||
const val SUB_SQUARE_LAT = 1.0
|
||||
const val SUB_SQUARE_LON = 2.0
|
||||
const val GRID_ZOOM_SUB = 5.0
|
||||
const val FIELD_ZOOM_LABELS = 3.0
|
||||
// Zoom at which the 2°x1° sub-square grid lines/fills appear.
|
||||
// 5.0 → 6.0: at zoom 5 a full field spans too little screen width and
|
||||
// the sub-square grid is too dense to read; 6 roughly doubles the
|
||||
// on-screen size of each square.
|
||||
const val GRID_ZOOM_SUB = 6.0
|
||||
// Zoom at which the 4-char sub-square names appear (one level above the
|
||||
// grid lines: zoom 6 = lines only, zoom 6.5+ = lines + names).
|
||||
const val LABEL_ZOOM_SUB = 6.5
|
||||
const val MIN_LABEL_CELL_PX = 48f
|
||||
const val MAX_OVERSHOOT_PX = 64
|
||||
}
|
||||
|
||||
@@ -25,18 +25,29 @@ import android.graphics.Paint
|
||||
import android.graphics.Rect
|
||||
import android.graphics.drawable.Drawable
|
||||
import androidx.collection.LruCache
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.ElevatedCard
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.FilterChipDefaults
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
@@ -48,6 +59,8 @@ import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.rotate
|
||||
import androidx.compose.ui.draw.scale
|
||||
import androidx.compose.ui.graphics.Color as ComposeColor
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
@@ -62,6 +75,9 @@ import androidx.lifecycle.LifecycleEventObserver
|
||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
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.predict.GeoPos
|
||||
import com.rtbishop.look4sat.core.domain.predict.OrbitalObject
|
||||
import com.rtbishop.look4sat.core.domain.predict.OrbitalPos
|
||||
@@ -138,13 +154,64 @@ private fun MapScreen(uiState: MapState, onAction: (MapAction) -> Unit, mapView:
|
||||
val timeString = uiState.mapData?.aosTime ?: "00:00:00"
|
||||
val isTimeAos = uiState.mapData?.isTimeAos ?: true
|
||||
|
||||
// Tapped worked grid -> centered QSO dialog. Local UI state: the map is the
|
||||
// only consumer and it resets when leaving the page.
|
||||
var selectedGrid by remember { mutableStateOf<String?>(null) }
|
||||
// Selected award filter (null = "All" = plain worked-grid view).
|
||||
var selectedAward by remember { mutableStateOf<AwardType?>(null) }
|
||||
// Six-award progress derived from the confirmed QSO store; recomputed when
|
||||
// the store changes (LoTW/Wavelog sync).
|
||||
val awardProgress: List<AwardProgress> = remember(uiState.workedGridQsos) {
|
||||
AwardCalculator.calculate(uiState.workedGridQsos)
|
||||
}
|
||||
// Attach the tap listener whenever grid mode / worked grids change.
|
||||
val workedGrids = uiState.workedGrids
|
||||
val isGridMode = uiState.isGridMode
|
||||
DisposableEffect(isGridMode, workedGrids) {
|
||||
val receiver = object : org.osmdroid.events.MapEventsReceiver {
|
||||
override fun singleTapConfirmedHelper(p: GeoPoint?): Boolean {
|
||||
if (!isGridMode || p == null) return false
|
||||
val zoom = mapView.zoomLevelDouble
|
||||
// Allow tapping worked cells as soon as the 4-char grid LINES
|
||||
// appear (GRID_ZOOM_SUB), not only when labels show (LABEL_ZOOM_SUB).
|
||||
if (zoom < MaidenheadGridOverlay.GRID_ZOOM_SUB) return false
|
||||
val grid = gridOfPoint(p.latitude, p.longitude) ?: return false
|
||||
if (grid !in workedGrids) return false
|
||||
selectedGrid = grid
|
||||
return true
|
||||
}
|
||||
|
||||
override fun longPressHelper(p: GeoPoint?): Boolean = false
|
||||
}
|
||||
val eventsOverlay = org.osmdroid.views.overlay.MapEventsOverlay(receiver)
|
||||
mapView.overlays.add(eventsOverlay)
|
||||
onDispose { mapView.overlays.remove(eventsOverlay) }
|
||||
}
|
||||
// Keep the overlay's selected highlight in sync with the dialog.
|
||||
LaunchedEffect(selectedGrid) {
|
||||
(mapView.overlays.getOrNull(OVERLAY_GRID) as? MaidenheadGridOverlay)?.selectedGrid = selectedGrid
|
||||
mapView.invalidate()
|
||||
}
|
||||
|
||||
LaunchedEffect(uiState.track) {
|
||||
// In grid mode the map is centered on the local grid square; following
|
||||
// the satellite subpoint here would override that centering on every
|
||||
// track update, so skip the tracking animation entirely.
|
||||
if (uiState.isGridMode) return@LaunchedEffect
|
||||
val firstPos = uiState.track?.firstOrNull()?.firstOrNull() ?: return@LaunchedEffect
|
||||
mapView.controller.animateTo(GeoPoint(firstPos.latitude, firstPos.longitude))
|
||||
}
|
||||
Column(modifier = Modifier.layoutPadding(), verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
val isVertical = isVerticalLayout()
|
||||
if (isVertical) {
|
||||
if (isGridMode) {
|
||||
// Grid mode: replace the satellite-selection top bar with the
|
||||
// award filter chips (All / DXCC / VUCC / WAPC / WAJA / WAZ / WAS).
|
||||
AwardChipsRow(
|
||||
progress = awardProgress,
|
||||
selected = selectedAward,
|
||||
onSelect = { selectedAward = it }
|
||||
)
|
||||
} else if (isVertical) {
|
||||
TopBar {
|
||||
IconCard(action = { onAction(MapAction.SelectPrev) }, resId = R.drawable.ic_arrow, modifier = rotateMod)
|
||||
TimerRow(timeString = timeString, isTimeAos = isTimeAos)
|
||||
@@ -165,17 +232,29 @@ private fun MapScreen(uiState: MapState, onAction: (MapAction) -> Unit, mapView:
|
||||
// the map page with grid mode already ON also centers the map.
|
||||
var prevGridMode by remember { mutableStateOf(false) }
|
||||
AndroidView({ mapView }) { view ->
|
||||
// Center on the station grid whenever entering grid mode.
|
||||
// Only mark the transition as consumed once a real position
|
||||
// was available; otherwise a first frame with a null
|
||||
// stationPosition would swallow the centering forever.
|
||||
val shouldCenter = uiState.isGridMode && !prevGridMode
|
||||
setGridMode(
|
||||
uiState.isGridMode, uiState.workedGrids, view,
|
||||
if (shouldCenter) uiState.stationPosition else null
|
||||
)
|
||||
if (!shouldCenter || uiState.stationPosition != null) {
|
||||
// Award filter mode: show the selected award's regions with
|
||||
// worked ones filled green; hide satellite layers like grid mode.
|
||||
val awardMode = selectedAward?.takeIf { it != AwardType.VUCC }
|
||||
if (uiState.isGridMode && awardMode != null) {
|
||||
setAwardMode(
|
||||
awardMode,
|
||||
awardProgress.firstOrNull { it.type == awardMode }?.workedKeys.orEmpty(),
|
||||
view
|
||||
)
|
||||
prevGridMode = uiState.isGridMode
|
||||
} else {
|
||||
// Center on the station grid whenever entering grid mode.
|
||||
// Only mark the transition as consumed once a real position
|
||||
// was available; otherwise a first frame with a null
|
||||
// stationPosition would swallow the centering forever.
|
||||
val shouldCenter = uiState.isGridMode && !prevGridMode
|
||||
setGridMode(
|
||||
uiState.isGridMode, uiState.workedGrids, view,
|
||||
if (shouldCenter) uiState.stationPosition else null
|
||||
)
|
||||
if (!shouldCenter || uiState.stationPosition != null) {
|
||||
prevGridMode = uiState.isGridMode
|
||||
}
|
||||
}
|
||||
if (!uiState.isGridMode) {
|
||||
uiState.stationPosition?.let { setStationPosition(it, view) }
|
||||
@@ -193,11 +272,166 @@ private fun MapScreen(uiState: MapState, onAction: (MapAction) -> Unit, mapView:
|
||||
if (isVertical) MapDataCard(mapData) else MapDataCards(mapData)
|
||||
}
|
||||
}
|
||||
GridModeToggle(
|
||||
isGridMode = uiState.isGridMode,
|
||||
onToggle = { onAction(MapAction.ToggleGridMode(it)) },
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopEnd)
|
||||
.padding(8.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Centered dialog listing the tapped worked grid's confirmed satellite QSOs.
|
||||
// Dismissed by tapping outside (no explicit close button).
|
||||
selectedGrid?.let { grid ->
|
||||
WorkedGridQsoDialog(
|
||||
grid = grid,
|
||||
qsos = uiState.workedGridQsos[grid].orEmpty().sortedBy { it.epochMs },
|
||||
isUtc = uiState.isUtc,
|
||||
onDismiss = { selectedGrid = null }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// region Worked-grid QSO dialog
|
||||
|
||||
@Composable
|
||||
private fun WorkedGridQsoDialog(
|
||||
grid: String,
|
||||
qsos: List<com.rtbishop.look4sat.core.domain.model.GridQso>,
|
||||
isUtc: Boolean,
|
||||
onDismiss: () -> Unit
|
||||
) {
|
||||
androidx.compose.ui.window.Dialog(onDismissRequest = onDismiss) {
|
||||
// Centered card, ~85% width, internal scroll for long lists.
|
||||
Card(
|
||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerLow),
|
||||
modifier = Modifier.fillMaxWidth(0.88f)
|
||||
) {
|
||||
Column {
|
||||
// Header: grid + counts
|
||||
Column(Modifier.padding(horizontal = 16.dp, vertical = 10.dp)) {
|
||||
Text(
|
||||
text = grid,
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace
|
||||
)
|
||||
val distinctCalls = qsos.map { it.call }.distinct().size
|
||||
Text(
|
||||
text = stringResource(R.string.grid_qso_calls_count, distinctCalls, qsos.size),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
androidx.compose.material3.HorizontalDivider()
|
||||
if (qsos.isEmpty()) {
|
||||
Text(
|
||||
text = stringResource(R.string.grid_qso_no_sat),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
modifier = Modifier.padding(16.dp)
|
||||
)
|
||||
} else {
|
||||
// Group by callsign preserving first-contact order (list is
|
||||
// already sorted oldest-first); expandable rows.
|
||||
val grouped = remember(qsos) {
|
||||
qsos.groupBy { it.call }.entries.sortedBy { it.value.first().epochMs }
|
||||
}
|
||||
Column(
|
||||
Modifier
|
||||
.heightIn(max = 380.dp)
|
||||
.verticalScroll(rememberScrollState())
|
||||
) {
|
||||
grouped.forEach { (call, callQsos) ->
|
||||
WorkedGridCallRow(call, callQsos = callQsos, isUtc = isUtc)
|
||||
androidx.compose.material3.HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.3f))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun WorkedGridCallRow(
|
||||
call: String,
|
||||
callQsos: List<com.rtbishop.look4sat.core.domain.model.GridQso>,
|
||||
isUtc: Boolean,
|
||||
) {
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
val first = callQsos.first()
|
||||
Column(Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp)) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { expanded = !expanded }
|
||||
) {
|
||||
Text(
|
||||
text = call,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace,
|
||||
color = MaterialTheme.colorScheme.tertiary,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
Text(
|
||||
text = formatDate(first.epochMs, isUtc),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Text(
|
||||
text = if (expanded) " ▴" else " ▾",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
}
|
||||
// First-QSO summary line, always visible.
|
||||
Text(
|
||||
text = stringResource(R.string.grid_qso_first) + " · " + qsoSummary(first, isUtc),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
modifier = Modifier.padding(top = 3.dp)
|
||||
)
|
||||
// Subsequent QSOs in this grid, revealed on expand. No ordinal prefix;
|
||||
// each line carries its own date (the first QSO's date already sits in
|
||||
// the header row).
|
||||
if (expanded && callQsos.size > 1) {
|
||||
callQsos.drop(1).forEach { qso ->
|
||||
Text(
|
||||
text = formatDate(qso.epochMs, isUtc) + " · " + qsoSummary(qso, isUtc),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 3.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun qsoSummary(qso: com.rtbishop.look4sat.core.domain.model.GridQso, isUtc: Boolean): String {
|
||||
val sat = qso.satName.ifBlank { "?" }
|
||||
val mode = qso.mode.ifBlank { "?" }
|
||||
val band = qso.bandLabel
|
||||
val time = formatTime(qso.epochMs, isUtc)
|
||||
return listOf(sat, mode, band, time).filter { it.isNotBlank() }.joinToString(" · ")
|
||||
}
|
||||
|
||||
private fun formatDate(epochMs: Long, isUtc: Boolean): String {
|
||||
if (epochMs <= 0L) return "--"
|
||||
val zone = if (isUtc) java.time.ZoneOffset.UTC else java.time.ZoneId.systemDefault()
|
||||
return java.time.Instant.ofEpochMilli(epochMs).atZone(zone)
|
||||
.format(java.time.format.DateTimeFormatter.ofPattern("yyyy-MM-dd"))
|
||||
}
|
||||
|
||||
private fun formatTime(epochMs: Long, isUtc: Boolean): String {
|
||||
if (epochMs <= 0L) return "--:--"
|
||||
val zone = if (isUtc) java.time.ZoneOffset.UTC else java.time.ZoneId.systemDefault()
|
||||
return java.time.Instant.ofEpochMilli(epochMs).atZone(zone)
|
||||
.format(java.time.format.DateTimeFormatter.ofPattern(if (isUtc) "HH:mm'Z'" else "HH:mm"))
|
||||
}
|
||||
// endregion
|
||||
|
||||
// region Map data composables
|
||||
@Composable
|
||||
private fun MapDataCard(data: MapData) {
|
||||
@@ -255,7 +489,8 @@ private fun MapDataCards(data: MapData) {
|
||||
Text(text = stringResource(R.string.map_elevation, data.elevation))
|
||||
}
|
||||
}
|
||||
Card(colors = cardColors, modifier = Modifier.align(Alignment.TopEnd)) {
|
||||
// Top-end card is offset down to make room for the grid-mode toggle pill.
|
||||
Card(colors = cardColors, modifier = Modifier.align(Alignment.TopEnd).padding(top = 60.dp)) {
|
||||
Column(horizontalAlignment = Alignment.End, modifier = paddingMod) {
|
||||
Text(text = stringResource(R.string.map_altitude, data.altitude), color = textColor)
|
||||
Text(text = stringResource(R.string.map_distance, data.range))
|
||||
@@ -282,8 +517,127 @@ private fun MapDataCards(data: MapData) {
|
||||
}
|
||||
// endregion
|
||||
|
||||
// region Grid mode toggle pill
|
||||
|
||||
/**
|
||||
* Award filter chips row shown in grid mode (replaces the satellite-selection
|
||||
* top bar). "All" = plain worked-grid view; each award chip carries its live
|
||||
* progress (count/target) and selecting it switches the map to the award's
|
||||
* boundary view.
|
||||
*/
|
||||
@Composable
|
||||
private fun AwardChipsRow(
|
||||
progress: List<AwardProgress>,
|
||||
selected: AwardType?,
|
||||
onSelect: (AwardType?) -> Unit
|
||||
) {
|
||||
LazyRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
item {
|
||||
FilterChip(
|
||||
selected = selected == null,
|
||||
onClick = { onSelect(null) },
|
||||
label = {
|
||||
Text(
|
||||
text = stringResource(R.string.map_award_all),
|
||||
fontSize = 12.sp
|
||||
)
|
||||
},
|
||||
colors = FilterChipDefaults.filterChipColors()
|
||||
)
|
||||
}
|
||||
items(progress) { p ->
|
||||
FilterChip(
|
||||
selected = selected == p.type,
|
||||
onClick = { onSelect(p.type) },
|
||||
label = {
|
||||
Text(
|
||||
text = "${p.type.name} ${p.count}/${p.target}",
|
||||
fontSize = 12.sp
|
||||
)
|
||||
},
|
||||
colors = FilterChipDefaults.filterChipColors()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
@Composable
|
||||
private fun GridModeToggle(
|
||||
isGridMode: Boolean,
|
||||
onToggle: (Boolean) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Surface(
|
||||
color = ComposeColor.Black.copy(alpha = 0.45f),
|
||||
shape = RoundedCornerShape(percent = 50),
|
||||
modifier = modifier
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.padding(start = 12.dp, end = 6.dp, top = 2.dp, bottom = 2.dp)
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(
|
||||
if (isGridMode) R.string.map_grid_mode else R.string.map_satellite_mode
|
||||
),
|
||||
color = ComposeColor.White,
|
||||
fontSize = 12.sp,
|
||||
modifier = Modifier.padding(end = 2.dp)
|
||||
)
|
||||
Switch(
|
||||
checked = isGridMode,
|
||||
onCheckedChange = onToggle,
|
||||
modifier = Modifier.scale(0.75f)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// endregion
|
||||
|
||||
// region Map overlay helpers
|
||||
|
||||
/**
|
||||
* Switches the grid-slot overlay to the award boundary view for the given
|
||||
* award: loads the boundary asset once (cached), paints worked regions green.
|
||||
* Satellite-related layers are hidden exactly like grid mode. VUCC keeps the
|
||||
* plain Maidenhead grid overlay (the grid view IS the VUCC view).
|
||||
*/
|
||||
private fun setAwardMode(award: AwardType, workedCodes: Set<String>, mapView: MapView) {
|
||||
val asset = when (award) {
|
||||
AwardType.WAPC -> AwardBoundaryData.AwardAsset.WAPC
|
||||
AwardType.WAJA -> AwardBoundaryData.AwardAsset.WAJA
|
||||
AwardType.WAZ -> AwardBoundaryData.AwardAsset.WAZ
|
||||
AwardType.WAS -> AwardBoundaryData.AwardAsset.WAS
|
||||
AwardType.DXCC -> AwardBoundaryData.AwardAsset.DXCC
|
||||
AwardType.VUCC -> return
|
||||
}
|
||||
val overlay = mapView.overlays.getOrNull(OVERLAY_GRID)
|
||||
if (overlay is AwardBoundaryOverlay) {
|
||||
overlay.isEnabled = true
|
||||
overlay.regions = AwardBoundaryData.load(mapView.context, asset)
|
||||
overlay.workedCodes = workedCodes
|
||||
} else {
|
||||
mapView.overlays[OVERLAY_GRID] = AwardBoundaryOverlay().apply {
|
||||
isEnabled = true
|
||||
regions = AwardBoundaryData.load(mapView.context, asset)
|
||||
this.workedCodes = workedCodes
|
||||
}
|
||||
}
|
||||
for (index in OVERLAY_STATION..OVERLAY_MOON) {
|
||||
mapView.overlays.getOrNull(index)?.isEnabled = false
|
||||
}
|
||||
}
|
||||
|
||||
/** Toggle the grid-mode layer visibility on/off without recreating any overlay. */
|
||||
private fun setGridMode(
|
||||
gridMode: Boolean,
|
||||
@@ -310,16 +664,23 @@ private fun setGridMode(
|
||||
mapView.overlays.getOrNull(index)?.isEnabled = !gridMode
|
||||
}
|
||||
// Entering grid mode: center on the station's current grid square,
|
||||
// keeping the current zoom level unchanged.
|
||||
// keeping the current zoom level unchanged. post{} is essential: on the
|
||||
// first composition this runs before MapView's first layout, and a
|
||||
// setCenter issued pre-layout is discarded when the view lays out —
|
||||
// the map then keeps its default center forever.
|
||||
if (gridMode) {
|
||||
val pos = stationPosition ?: return
|
||||
val lat = pos.latitude
|
||||
val lon = pos.longitude
|
||||
// Maidenhead field indices: field 0 spans -90..-80° lat and
|
||||
// -180..-160° lon, so the field-center must be offset by those
|
||||
// bases — omitting them maps e.g. OL62's field center to
|
||||
// 115°N/290°E (an ocean a hemisphere away) instead of 25°N/110°E.
|
||||
val fieldLat = ((lat + 90.0) / 10.0).toInt().coerceIn(0, 17)
|
||||
val fieldLon = ((lon + 180.0) / 20.0).toInt().coerceIn(0, 17)
|
||||
val centerLat = fieldLat * 10.0 + 5.0
|
||||
val centerLon = fieldLon * 20.0 + 10.0
|
||||
mapView.controller.setCenter(GeoPoint(centerLat, centerLon))
|
||||
val centerLat = fieldLat * 10.0 + 5.0 - 90.0
|
||||
val centerLon = fieldLon * 20.0 + 10.0 - 180.0
|
||||
mapView.post { mapView.controller.setCenter(GeoPoint(centerLat, centerLon)) }
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
println(e)
|
||||
@@ -338,6 +699,17 @@ private fun ownGridOf(stationPosition: GeoPos?): String? {
|
||||
return "${'A' + fieldLon}${'A' + fieldLat}$subLon$subLat"
|
||||
}
|
||||
|
||||
/** The 4-char Maidenhead gridsquare containing the given point, or null outside the grid. */
|
||||
private fun gridOfPoint(latitude: Double, longitude: Double): String? {
|
||||
if (latitude < -90.0 || latitude >= 90.0) return null
|
||||
val lon = ((longitude + 180.0) % 360.0 + 360.0) % 360.0 - 180.0
|
||||
val fieldLat = ((latitude + 90.0) / 10.0).toInt().coerceIn(0, 17)
|
||||
val fieldLon = ((lon + 180.0) / 20.0).toInt().coerceIn(0, 17)
|
||||
val subLat = ((latitude + 90.0) % 10.0).toInt()
|
||||
val subLon = ((lon + 180.0) % 20.0 / 2.0).toInt()
|
||||
return "${'A' + fieldLon}${'A' + fieldLat}$subLon$subLat"
|
||||
}
|
||||
|
||||
private fun setStationPosition(stationPos: GeoPos, mapView: MapView) {
|
||||
try {
|
||||
val overlay = mapView.overlays[OVERLAY_STATION]
|
||||
|
||||
@@ -28,6 +28,7 @@ data class MapState(
|
||||
val isUtc: Boolean = false,
|
||||
val isGridMode: Boolean = false,
|
||||
val workedGrids: Set<String> = emptySet(),
|
||||
val workedGridQsos: Map<String, List<com.rtbishop.look4sat.core.domain.model.GridQso>> = emptyMap(),
|
||||
val stationPosition: GeoPos? = null,
|
||||
val orbitalPass: OrbitalPass,
|
||||
val track: List<List<GeoPos>>? = null,
|
||||
@@ -44,6 +45,7 @@ sealed interface MapAction {
|
||||
data object SelectNext : MapAction
|
||||
data class SelectItem(val item: OrbitalObject) : MapAction
|
||||
data class SelectDefaultItem(val catnum: Int) : MapAction
|
||||
data class ToggleGridMode(val value: Boolean) : MapAction
|
||||
}
|
||||
|
||||
data class MapData(
|
||||
|
||||
@@ -79,7 +79,12 @@ class MapViewModel(
|
||||
}
|
||||
viewModelScope.launch {
|
||||
settingsRepo.wavelogSettings.collectLatest { _ ->
|
||||
_uiState.update { it.copy(workedGrids = settingsRepo.getWorkedGrids()) }
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
workedGrids = settingsRepo.getWorkedGrids(),
|
||||
workedGridQsos = settingsRepo.getWorkedGridQsos()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
val (selectedCatNum, _) = satelliteRepo.selectedPass.value
|
||||
@@ -93,6 +98,7 @@ class MapViewModel(
|
||||
MapAction.SelectNext -> scrollSelection(false)
|
||||
is MapAction.SelectItem -> selectSatellite(action.item)
|
||||
is MapAction.SelectDefaultItem -> selectDefaultSatellite(action.catnum)
|
||||
is MapAction.ToggleGridMode -> settingsRepo.updateOtherSettings { it.copy(stateOfMapGrid = action.value) }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -89,7 +89,8 @@ fun MutualScreen(
|
||||
Text(
|
||||
text = stringResource(R.string.mutual_title),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier.padding(start = 12.dp)
|
||||
)
|
||||
},
|
||||
bottomInfo = {
|
||||
@@ -99,7 +100,9 @@ fun MutualScreen(
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f)
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(start = 12.dp)
|
||||
)
|
||||
},
|
||||
endAction = {
|
||||
|
||||
+6
-17
@@ -238,10 +238,13 @@ fun LoTWDialog(
|
||||
SharedDialog(
|
||||
title = stringResource(R.string.prefs_lotw_title),
|
||||
onCancel = dismiss,
|
||||
// The confirm button IS the sync action: saving credentials and
|
||||
// fetching confirmed grids happen in one step.
|
||||
onAccept = {
|
||||
onSave(com.rtbishop.look4sat.core.domain.model.LoTWSettings(call.value, pass.value))
|
||||
dismiss()
|
||||
}
|
||||
onSync(com.rtbishop.look4sat.core.domain.model.LoTWSettings(call.value, pass.value))
|
||||
},
|
||||
acceptText = stringResource(if (isSyncing) R.string.prefs_lotw_syncing else R.string.prefs_lotw_sync),
|
||||
acceptEnabled = !isSyncing
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = call.value,
|
||||
@@ -273,20 +276,6 @@ fun LoTWDialog(
|
||||
modifier = Modifier.padding(horizontal = LocalSpacing.current.large)
|
||||
)
|
||||
}
|
||||
// Sync uses the values typed in the fields directly — saving and syncing
|
||||
// happen in one step, no need to close and reopen the dialog.
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.End,
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = LocalSpacing.current.large)
|
||||
) {
|
||||
TextButton(
|
||||
onClick = { onSync(com.rtbishop.look4sat.core.domain.model.LoTWSettings(call.value, pass.value)) },
|
||||
enabled = !isSyncing
|
||||
) {
|
||||
Text(text = if (isSyncing) stringResource(R.string.prefs_lotw_syncing)
|
||||
else stringResource(R.string.prefs_lotw_sync))
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.height(0.dp))
|
||||
}
|
||||
}
|
||||
|
||||
-57
@@ -203,18 +203,6 @@ private fun SettingsScreen(uiState: SettingsState, onAction: (SettingsAction) ->
|
||||
onSave = { onAction(SettingsAction.UpdateRadioControl(it)) }
|
||||
)
|
||||
}
|
||||
if (dialogs.wavelog) {
|
||||
WavelogDialog(
|
||||
initialSettings = uiState.wavelogSettings,
|
||||
workedGridsCount = uiState.workedGridsCount,
|
||||
isSyncing = uiState.wavelogSyncing,
|
||||
message = uiState.wavelogMessage,
|
||||
dismiss = { dialogs.wavelog = false },
|
||||
onSave = { onAction(SettingsAction.UpdateWavelog(it)) },
|
||||
onSync = { onAction(SettingsAction.SyncWorkedGrids(it)) }
|
||||
)
|
||||
}
|
||||
|
||||
if (dialogs.lotw) {
|
||||
LoTWDialog(
|
||||
initialSettings = uiState.lotwSettings,
|
||||
@@ -330,13 +318,6 @@ private fun SettingsScreen(uiState: SettingsState, onAction: (SettingsAction) ->
|
||||
onRadioControlClick = { dialogs.radioControl = true }
|
||||
)
|
||||
}
|
||||
item {
|
||||
WavelogCard(
|
||||
settings = uiState.wavelogSettings,
|
||||
workedGridsCount = uiState.workedGridsCount,
|
||||
showWavelogDialog = { dialogs.wavelog = true }
|
||||
)
|
||||
}
|
||||
item {
|
||||
LoTWCard(
|
||||
settings = uiState.lotwSettings,
|
||||
@@ -581,44 +562,6 @@ private fun SwitchRow(labelResId: Int, checked: Boolean, onCheckedChange: (Boole
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Composable
|
||||
private fun WavelogCardPreview() = MainTheme {
|
||||
WavelogCard(settings = WavelogSettings("http://192.168.1.10", "wl2_demo"), workedGridsCount = 42) {}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun WavelogCard(
|
||||
settings: WavelogSettings,
|
||||
workedGridsCount: Int,
|
||||
showWavelogDialog: () -> Unit
|
||||
) {
|
||||
ElevatedCard(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)) {
|
||||
Text(
|
||||
text = stringResource(id = R.string.prefs_wavelog_title),
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
Spacer(modifier = Modifier.height(2.dp))
|
||||
Text(
|
||||
text = if (settings.isConfigured) {
|
||||
stringResource(R.string.prefs_wavelog_configured, workedGridsCount)
|
||||
} else {
|
||||
stringResource(R.string.prefs_wavelog_not_configured)
|
||||
},
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
maxLines = 2
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
CardButton(
|
||||
onClick = showWavelogDialog,
|
||||
text = stringResource(id = R.string.prefs_wavelog_configure),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LoTWCard(
|
||||
settings: com.rtbishop.look4sat.core.domain.model.LoTWSettings,
|
||||
|
||||
+40
-9
@@ -201,16 +201,47 @@ class SettingsViewModel(
|
||||
settingsRepo.updateLoTWSettings(settings)
|
||||
_uiState.update { it.copy(lotwSyncing = true, lotwMessage = null) }
|
||||
viewModelScope.launch {
|
||||
val lotwGrids = lotwRepo.fetchConfirmedGrids(settings.callsign, settings.password)
|
||||
_uiState.update { state ->
|
||||
if (lotwGrids == null) {
|
||||
state.copy(lotwSyncing = false, lotwMessage = "LoTW sync failed — check callsign/password/network")
|
||||
} else {
|
||||
// Merge with Wavelog grids: union of both sources, all confirmed/worked
|
||||
val merged = settingsRepo.getWorkedGrids() + lotwGrids
|
||||
settingsRepo.setWorkedGrids(merged)
|
||||
state.copy(lotwSyncing = false, workedGridsCount = merged.size, lotwMessage = null)
|
||||
when (val result = lotwRepo.fetchConfirmedGridQsos(settings.callsign, settings.password)) {
|
||||
is com.rtbishop.look4sat.core.domain.repository.LoTWResult.Success -> {
|
||||
// Wavelog entry removed: LoTW is now the only source, so the
|
||||
// synced set fully replaces the stored worked grids.
|
||||
settingsRepo.setWorkedGrids(result.grids)
|
||||
settingsRepo.setWorkedGridQsos(result.qsos)
|
||||
_uiState.update { state ->
|
||||
state.copy(
|
||||
lotwSyncing = false, workedGridsCount = result.grids.size,
|
||||
lotwMessage = null
|
||||
)
|
||||
}
|
||||
}
|
||||
is com.rtbishop.look4sat.core.domain.repository.LoTWResult.BadCredentials ->
|
||||
_uiState.update { state ->
|
||||
state.copy(
|
||||
lotwSyncing = false,
|
||||
lotwMessage = "LoTW sync failed — callsign or password incorrect"
|
||||
)
|
||||
}
|
||||
is com.rtbishop.look4sat.core.domain.repository.LoTWResult.RateLimited ->
|
||||
_uiState.update { state ->
|
||||
state.copy(
|
||||
lotwSyncing = false,
|
||||
lotwMessage = "LoTW sync failed — rate limited by server, wait a few minutes and retry"
|
||||
)
|
||||
}
|
||||
is com.rtbishop.look4sat.core.domain.repository.LoTWResult.Timeout ->
|
||||
_uiState.update { state ->
|
||||
state.copy(
|
||||
lotwSyncing = false,
|
||||
lotwMessage = "LoTW sync failed — connection timed out, try another network"
|
||||
)
|
||||
}
|
||||
is com.rtbishop.look4sat.core.domain.repository.LoTWResult.NetworkError ->
|
||||
_uiState.update { state ->
|
||||
state.copy(
|
||||
lotwSyncing = false,
|
||||
lotwMessage = "LoTW sync failed — network error (${result.detail})"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
[versions]
|
||||
#noinspection UnusedVersionCatalogEntry
|
||||
appVersionCode = "457"
|
||||
appVersionCode = "464"
|
||||
#noinspection UnusedVersionCatalogEntry
|
||||
appVersionName = "4.4.6-ba7opf.9.5"
|
||||
appVersionName = "4.4.6-ba7opf.9.14"
|
||||
#noinspection UnusedVersionCatalogEntry
|
||||
compileSdk = "37"
|
||||
#noinspection UnusedVersionCatalogEntry
|
||||
|
||||
Reference in new issue
Block a user