Compare commits

..
Author SHA1 Message Date
atsunatsu 623d859c97 test(mutual): 钉死 fixture 时间基准,消除时段性 flaky
fixture 用 2024-03-09 固定 ISS TLE + System.currentTimeMillis() 起点;SGP4 带阻力衰减 942 天后高度仅 ≈262km,12h 窗口内可能无广州过境 → 3 条依赖过境窗的用例时段性失败(Gradle 缓存 FROM-CACHE 会掩盖上一次的真跑)。

- TestOrbits.FIXTURE_BASE(2024-03-10T00:00Z,历元后约 18h)取代默认 System.currentTimeMillis()

- MutualViewModel 墙钟可注入:nowMillis: () -> Long = System::currentTimeMillis(第 4 个默认参数,生产行为逐位不变;工厂具名构造零改动)

- 断言里的 now 改用同一基准;prefill/scroll 测试不碰时间未动

修后::feature:mutual 28/28,全量 ./gradlew test 451/451。
2026-10-08 12:36:40 +08:00
atsunatsu 08803e0f55 feat(logbook): LoTW 卫星别名补 RS95S(QMR-KWT 2)+内置 config.tq6 升 11.35
RS-95S(QMR-KWT 2,NORAD 67291)QSO 上传时发现:别名表无该星、内置 config 11.34 也无此官方名(11.34 里没有 RS95S,会被 every-alias-target 校验挡住)。

- config.tq6 11.34 → 11.35(ARRL 现行版本,新增 RS95S=QMR-KWT-2)

- 别名表补 QMRKWT2→RS95S(SatNOGS 名 QMR-KWT 2)与 RS95S→RS95S(兼容 RS-95S 连字符写法,resolve 的包含回退逐字符比会漏)

- 单测补断言:QMR-KWT 2 lookup / RS-95S+QMR-KWT 2 resolve / 用真实 config 解析 RS95S
2026-10-08 12:36:35 +08:00
atsunatsu 6f1c0a2d23 build: release version 4.4.8-ba7opf.11.5 (580) 2026-10-08 02:27:47 +08:00
atsunatsu 74e7e19cf2 feat(satellites): 勾选置顶显示——已选/可用分组+顶部计数条(对齐上游) 2026-10-08 02:26:56 +08:00
atsunatsu bb001b4d38 fix(logbook): ADIF 网格字段方向修正——VUCC_GRIDS 归对方、MY_VUCC_GRIDS 归己方(编解码双向) 2026-10-08 02:26:51 +08:00
atsunatsu 9ea83cb161 build: release version 4.4.8-ba7opf.11.4 (579) 2026-10-07 00:19:31 +08:00
atsunatsu d7d5918f0a fix(lotw): 呼号冲突批量全阻时也进预览,呼号不符改为显示记录≠证书
整批记录都被呼号冲突挡住(0 条可传)时,统计闸门直接弹「N QSO(s) can't be uploaded」
消息并返回,带【改写为…】【换一张证书】两个动作的预览弹窗根本走不到,那批记录无从
处理。现在只要原因里有呼号冲突就放行到预览:0 条可传也出预览(显示 0 QSO(s) + 冲突
提示 + 两个动作),上传键置灰——以前点确认会返回“预览已过期”。其它原因的 0 条可传
批次保持原消息弹窗不变。

「MY callsign does not match the certificate」的示例值由某条记录的对方呼号改为
「记录呼号 ≠ 证书呼号」,把需要协调的两个值都打出来(旧文案读起来像证书呼号)。

空预览不再显示空的日期区间。

新增 LoTWUploadMessagesTest 3 例:冲突批量必须进预览、无冲突的 0 条可传批次保持消息、
有待传记录时不走冲突兜底;仓库层用例新增:全阻批量仍返回带冲突清单的预览、且无载荷
时确认安全地按“过期”失败。
2026-10-07 00:19:27 +08:00
atsunatsu b2e9454ee4 build: release version 4.4.8-ba7opf.11.3 (578) 2026-10-06 22:14:05 +08:00
atsunatsu ea3e04d5db fix(lotw): 空呼号记录按证书上传并回填,呼号冲突可选改写或换证书
证书装好之前记录的 QSO 自己呼号存空,签名时被判 CALLSIGN_MISMATCH,整批上传失败。
现在这类记录改用当前证书呼号签名,不再拒签:预览提示「其中 N 条记录没有呼号,将按证书
XXX 上传」,上传成功后仅把这些记录的空呼号回填为证书呼号(只填空值,经 save() 写入以
保持去重键一致,不动时间/频率/模式/对方呼号/上传状态与确认状态)。

自己呼号非空且与证书不同的记录仍拒签(不冒签他人记录),但预览给出两个选择:
「改写为 XXX」把冲突记录的自己呼号改成当前证书呼号后即可重传;「换一张证书」在设置页
关掉日志本并打开 LoTW 配置导入另一张证书(雷达页提示去设置页导入)。

新增单测:空呼号签名与计数、冲突记录 id、markUploaded 只填空值、rewriteMyCallsign 只改
冲突记录;并修正测试夹具 MemStorage.read 返回共享数组(被调用方 fill(0) 后破坏已存证书,
与文件存储行为不一致)导致的问题。
2026-10-06 22:13:59 +08:00
atsunatsu 68e1925784 fix(lotw): 空密码 PBES2 证书回落自研解析器,修复 p12 导入误报无效
平台 KeyStore 对空密码 PBES2 文件不抛异常但给不出私钥条目,原逻辑随即报「不是有效的 LoTW 证书文件」。现在平台读取器抛异常、无私钥条目或取不出密钥时,只要文件是 PBES2 就回落自研解析器,两条都失败才报错;非 PBES2 旧式 p12 报错分类不变。新增 5 个单测(空密码回落、平台可用回归、错密码、非 p12、超限)。
2026-10-06 22:13:47 +08:00
atsunatsu 1e563f9cb0 build: release version 4.4.8-ba7opf.11.1 (576) 2026-10-06 16:33:36 +08:00
atsunatsu f8d3c5b185 feat(map): DXCC 通联详情副行显示实体字头
- dxcc.json 全部 280 实体新增 pfx 字段(Club Log cty.csv 实体主字头):
  多字头实体显示惯例实体字头(中国=BY/美国=K/亚俄=UA9), Somalia 覆盖为 6O,
  共享 DXCC 号的子图斑行(维也纳国际中心 206)不参与选取
- RegionQsoDialog 副行 'DXCC 318 · ' → 'BY · '(无字头回退数字码);
  标题/布局/WAZ 'CQ ZONE n'/WAPC-WAJA-WAS 裸代码均不变
- DxccAssetTest +1 守卫: 280 实体字头非空 + 14 组码→字头钉死
2026-10-06 16:33:33 +08:00
atsunatsu 917788041c build: release version 4.4.8-ba7opf.11 (575) 2026-10-06 14:16:29 +08:00
atsunatsu 58bc96c441 fix(map): DXCC 领土缺口补齐、父国填色挖洞、挪威斯瓦尔巴粗环删除
- 领土缺口: Natural Earth 把 Somaliland/N. Cyprus 画成独立图斑, 6O 索马里与
  5B 塞浦路斯的北部领土此前无任何边界(不画框/不填绿); 按 NE 1:110m 并入父国
  环并溶解内部界线(单例外框), 与埃塞/吉布提/肯尼亚零重叠
- 父国填色挖洞: 新增 fill_rings(父领土−子实体, shapely 差集), worked 填充改
  偶奇挖洞, 通父国不再涂绿独立子实体: 南非→莱索托, 中国→香港, 马来西亚→新加坡,
  法国→安道尔/摩纳哥, 意大利→圣马力诺/梵蒂冈, 奥+瑞→列支敦士登, 西班牙→
  直布罗陀/安道尔, 古巴→关塔那摩湾; 描边/命中/其他奖状资产不变
- 双线修复: 删除 Norway 残留的 3 个斯瓦尔巴粗环(NE Norway 几何自带), 斯瓦尔巴
  只剩精环单线, 通 LA 不再误涂绿 JW
- DxccAssetTest +3 守卫(合并领土探针/填色挖洞钉对/Norway 无 >73°N 残环)
2026-10-06 14:15:53 +08:00
43 changed files with 1019 additions and 113 deletions

No files matched your search

Binary file not shown.
@@ -36,7 +36,7 @@ import java.util.Locale
import java.util.zip.GZIPInputStream import java.util.zip.GZIPInputStream
import javax.xml.parsers.DocumentBuilderFactory import javax.xml.parsers.DocumentBuilderFactory
/** Bundled ARRL configuration 11.34 from https://lotw.arrl.org/lotw/config.tq6. */ /** Bundled ARRL configuration 11.35 from https://lotw.arrl.org/lotw/config.tq6. */
internal class LoTWConfig(input: InputStream) { internal class LoTWConfig(input: InputStream) {
private val root = input.use { source -> GZIPInputStream(source).use { gzip -> private val root = input.use { source -> GZIPInputStream(source).use { gzip ->
val xml = gzip.bufferedReader(Charsets.UTF_8).readText() val xml = gzip.bufferedReader(Charsets.UTF_8).readText()
@@ -33,53 +33,38 @@ import java.util.TimeZone
internal data class LoTWKeyMaterial(val key: PrivateKey, val certificate: X509Certificate, val info: LoTWCertificate) { internal data class LoTWKeyMaterial(val key: PrivateKey, val certificate: X509Certificate, val info: LoTWCertificate) {
companion object { companion object {
fun read(bytes: ByteArray, password: CharArray, now: Long): LoTWKeyMaterial { /**
* Reads the private key and certificate out of a TrustedQSL .p12 backup.
*
* The platform PKCS12 parser is tried first because it resolves the certificate that
* belongs to the key entry. When it cannot deliver a usable pair — it may refuse the file
* outright (older Android cannot read PBES2 at all) or accept the container yet expose no
* key entry (Conscrypt/OpenSSL reads PBES2 but fails to recover the key of, for example,
* an empty-password backup) — our own PBES2 reader is consulted before reporting a problem,
* so a valid certificate is not rejected as "not a valid LoTW certificate file".
*
* [platformStore] is a seam that lets tests simulate a platform which accepts the file
* without exposing any key entry.
*/
internal fun read(
bytes: ByteArray,
password: CharArray,
now: Long,
platformStore: (ByteArray, CharArray) -> KeyStore? = { data, pass -> loadPlatformStore(data, pass) },
): LoTWKeyMaterial {
if (bytes.isEmpty() || bytes.size > MAX_CERTIFICATE_BYTES) fail(LoTWProblem.CERTIFICATE_INVALID) if (bytes.isEmpty() || bytes.size > MAX_CERTIFICATE_BYTES) fail(LoTWProblem.CERTIFICATE_INVALID)
val key: PrivateKey val key: PrivateKey
val cert: X509Certificate val cert: X509Certificate
val store = try { val outcome = platformStore(bytes, password)?.let { platformKeyAndCertificate(it, password) }
KeyStore.getInstance("PKCS12").apply { bytes.inputStream().use { load(it, password) } } if (outcome is PlatformOutcome.Ok) {
} catch (_: Exception) { key = outcome.key
// Modern TQSL / OpenSSL 3 exports use PBES2+AES-CBC which Android's legacy cert = outcome.certificate
// Bouncy Castle parser cannot read. Fall back to our own PBES2 reader; if } else if (isPbes2(bytes)) {
// that fails too, report the real reason (format vs password). val parsed = readWithPbes2Reader(bytes, password)
if (isPbes2(bytes)) { key = parsed.first
try { cert = parsed.second
val parsed = Pkcs12Reader.read(bytes, password)
parsed.first to parsed.second
} catch (e: Exception) {
// Classify by exception type so the user sees the right message:
// - BadPadding (BAD_DECRYPT) -> wrong password
// - IllegalStateException (error()) -> unsupported algorithm,
// message is the algorithm name
// - IllegalArgumentException (require()) -> structurally invalid file
fail(
when {
e is javax.crypto.BadPaddingException -> LoTWProblem.CERTIFICATE_PASSWORD
e is IllegalStateException && password.isNotEmpty() -> LoTWProblem.CERTIFICATE_FORMAT
else -> LoTWProblem.CERTIFICATE_INVALID
},
if (e is IllegalStateException) (e.message ?: "") else ""
)
}
} else {
fail(LoTWProblem.CERTIFICATE_PASSWORD)
}
}
if (store is Pair<*, *>) {
@Suppress("UNCHECKED_CAST")
key = store.first as PrivateKey
@Suppress("UNCHECKED_CAST")
cert = store.second as X509Certificate
} else { } else {
val ks = store as KeyStore fail((outcome as? PlatformOutcome.Unusable)?.problem ?: LoTWProblem.CERTIFICATE_PASSWORD)
val aliases = Collections.list(ks.aliases()).filter { ks.isKeyEntry(it) }
if (aliases.size != 1) fail(LoTWProblem.CERTIFICATE_INVALID)
val alias = aliases.single()
key = try { ks.getKey(alias, password) as? PrivateKey }
catch (_: Exception) { fail(LoTWProblem.CERTIFICATE_PASSWORD) }
?: fail(LoTWProblem.CERTIFICATE_INVALID)
cert = ks.getCertificate(alias) as? X509Certificate ?: fail(LoTWProblem.CERTIFICATE_INVALID)
} }
if (key.algorithm != "RSA" || cert.publicKey.algorithm != "RSA") fail(LoTWProblem.CERTIFICATE_INVALID) if (key.algorithm != "RSA" || cert.publicKey.algorithm != "RSA") fail(LoTWProblem.CERTIFICATE_INVALID)
try { cert.checkValidity(Date(now)) } catch (_: Exception) { fail(LoTWProblem.CERTIFICATE_EXPIRED) } try { cert.checkValidity(Date(now)) } catch (_: Exception) { fail(LoTWProblem.CERTIFICATE_EXPIRED) }
@@ -92,6 +77,54 @@ internal data class LoTWKeyMaterial(val key: PrivateKey, val certificate: X509Ce
return LoTWKeyMaterial(key, cert, info) return LoTWKeyMaterial(key, cert, info)
} }
/** What the platform PKCS12 parser managed to deliver for the key entry. */
private sealed interface PlatformOutcome {
data class Ok(val key: PrivateKey, val certificate: X509Certificate) : PlatformOutcome
/** The problem the platform path would have reported on its own. */
data class Unusable(val problem: LoTWProblem) : PlatformOutcome
}
/** The platform PKCS12 keystore, or null when it refuses to read the file at all. */
private fun loadPlatformStore(bytes: ByteArray, password: CharArray): KeyStore? = try {
KeyStore.getInstance("PKCS12").apply { bytes.inputStream().use { load(it, password) } }
} catch (_: Exception) {
null
}
/** Resolves the single key entry the platform offers, keeping its own failure reasons. */
private fun platformKeyAndCertificate(store: KeyStore, password: CharArray): PlatformOutcome {
val aliases = Collections.list(store.aliases()).filter { store.isKeyEntry(it) }
val alias = aliases.singleOrNull() ?: return PlatformOutcome.Unusable(LoTWProblem.CERTIFICATE_INVALID)
val key = try {
store.getKey(alias, password) as? PrivateKey
} catch (_: Exception) {
return PlatformOutcome.Unusable(LoTWProblem.CERTIFICATE_PASSWORD)
} ?: return PlatformOutcome.Unusable(LoTWProblem.CERTIFICATE_INVALID)
val certificate = store.getCertificate(alias) as? X509Certificate
?: return PlatformOutcome.Unusable(LoTWProblem.CERTIFICATE_INVALID)
return PlatformOutcome.Ok(key, certificate)
}
/** Our own PBES2 reader, mapping failures onto the message the operator should see. */
private fun readWithPbes2Reader(bytes: ByteArray, password: CharArray): Pair<PrivateKey, X509Certificate> = try {
Pkcs12Reader.read(bytes, password)
} catch (e: Exception) {
// Classify by exception type so the user sees the right message:
// - BadPadding (BAD_DECRYPT) -> wrong password
// - IllegalStateException (error()) -> unsupported algorithm,
// message is the algorithm name
// - IllegalArgumentException (require()) -> structurally invalid file
fail(
when {
e is javax.crypto.BadPaddingException -> LoTWProblem.CERTIFICATE_PASSWORD
e is IllegalStateException && password.isNotEmpty() -> LoTWProblem.CERTIFICATE_FORMAT
else -> LoTWProblem.CERTIFICATE_INVALID
},
if (e is IllegalStateException) (e.message ?: "") else ""
)
}
/** True when the PKCS12 uses PBES2 (OID 1.2.840.113549.1.5.13), the default /** True when the PKCS12 uses PBES2 (OID 1.2.840.113549.1.5.13), the default
* algorithm of OpenSSL 3 / modern TQSL. Android's legacy BC parser can't read it. */ * algorithm of OpenSSL 3 / modern TQSL. Android's legacy BC parser can't read it. */
private fun isPbes2(bytes: ByteArray): Boolean { private fun isPbes2(bytes: ByteArray): Boolean {
@@ -37,7 +37,12 @@ internal class LoTWSigner(private val config: LoTWConfig) {
val call = record.theirCallsign.trim().uppercase(Locale.US) val call = record.theirCallsign.trim().uppercase(Locale.US)
if (record.status != QsoStatus.COMPLETE || !call.matches(Regex("[A-Z0-9]+(/[A-Z0-9]+)*")) || if (record.status != QsoStatus.COMPLETE || !call.matches(Regex("[A-Z0-9]+(/[A-Z0-9]+)*")) ||
!call.any(Char::isLetter) || !call.any(Char::isDigit)) fail(LoTWProblem.INVALID_CONTACT, call) !call.any(Char::isLetter) || !call.any(Char::isDigit)) fail(LoTWProblem.INVALID_CONTACT, call)
if (!record.myCallsign.trim().equals(key.info.callsign, true)) fail(LoTWProblem.CALLSIGN_MISMATCH, call) // Report the two callsigns the operator has to reconcile (record vs certificate). A generic
// sample — the opposite station's call — says nothing about which side is wrong, which is
// exactly what a "MY callsign does not match the certificate" message must tell.
if (!record.myCallsign.trim().equals(key.info.callsign, true)) {
fail(LoTWProblem.CALLSIGN_MISMATCH, "${record.myCallsign.trim()} ≠ ${key.info.callsign}")
}
val date = utc(record.startUtcMillis, "yyyy-MM-dd") val date = utc(record.startUtcMillis, "yyyy-MM-dd")
// A record dated after "now" gets its own message: it happens when the operator // A record dated after "now" gets its own message: it happens when the operator
// logged against a pass that had not started yet (out-of-window flow), and "fix the // logged against a pass that had not started yet (out-of-window flow), and "fix the
@@ -267,16 +267,29 @@ class LoTWUploadRepository internal constructor(
var duplicates = 0 var duplicates = 0
val unique = hashSetOf<String>() val unique = hashSetOf<String>()
val reasons = mutableMapOf<LoTWProblem, Int>() val reasons = mutableMapOf<LoTWProblem, Int>()
val certificateCallsign = signing.key.info.callsign
val substitutedIds = mutableSetOf<Long>()
val callsignConflicts = mutableListOf<Long>()
val contacts = records.sortedBy { it.startUtcMillis }.mapNotNull { record -> val contacts = records.sortedBy { it.startUtcMillis }.mapNotNull { record ->
coroutineContext.ensureActive() coroutineContext.ensureActive()
if (record.status != QsoStatus.COMPLETE || ((record.lotwReceived || record.lotwUploaded) && !resubmit)) { skipped++; return@mapNotNull null } if (record.status != QsoStatus.COMPLETE || ((record.lotwReceived || record.lotwUploaded) && !resubmit)) { skipped++; return@mapNotNull null }
// A record logged while no certificate was installed carries no own callsign: sign
// it with the certificate's callsign (the preview says so, and an accepted upload
// writes the callsign into the record). A record naming a DIFFERENT callsign may be
// someone else's contact, so it stays refused — the conflict list lets the operator
// rewrite those records or import the other certificate.
val signable = if (record.myCallsign.isBlank()) {
substitutedIds += record.id
record.copy(myCallsign = certificateCallsign)
} else record
val contact = try { val contact = try {
signing.signer.contact(record, signing.key, signing.location, now()) signing.signer.contact(signable, signing.key, signing.location, now())
} catch (e: LoTWOperationException) { } catch (e: LoTWOperationException) {
// One un-signable record must not abort the whole batch: // One un-signable record must not abort the whole batch:
// skip it, count the real reason, and let the rest upload. // skip it, count the real reason, and let the rest upload.
unavailable++ unavailable++
reasons[e.reason] = (reasons[e.reason] ?: 0) + 1 reasons[e.reason] = (reasons[e.reason] ?: 0) + 1
if (e.reason == LoTWProblem.CALLSIGN_MISMATCH) callsignConflicts += record.id
return@mapNotNull null return@mapNotNull null
} }
val previous = ledger[contact.fingerprint] val previous = ledger[contact.fingerprint]
@@ -299,6 +312,7 @@ class LoTWUploadRepository internal constructor(
signing.location["MY_VUCC_GRIDS"]?.split(',') signing.location["MY_VUCC_GRIDS"]?.split(',')
?.map(String::trim)?.filter(String::isNotBlank)?.let(::addAll) ?.map(String::trim)?.filter(String::isNotBlank)?.let(::addAll)
}.distinct() }.distinct()
val submittedIds = contacts.map { it.record.id }
val preview = LoTWUploadPreview( val preview = LoTWUploadPreview(
UUID.randomUUID().toString(), signing.key.info.callsign, signing.key.info.dxcc, signing.location.getValue("GRIDSQUARE"), UUID.randomUUID().toString(), signing.key.info.callsign, signing.key.info.dxcc, signing.location.getValue("GRIDSQUARE"),
contacts.size, skipped, contacts.size, skipped,
@@ -309,9 +323,12 @@ class LoTWUploadRepository internal constructor(
unavailable, unavailable,
reasons.toMap(), reasons.toMap(),
duplicates, duplicates,
contacts.map { it.record.id }, submittedIds,
locationGrids, locationGrids,
resubmit resubmit,
// Only the callsign-filled records that actually made it into this batch are reported.
submittedIds.count { it in substitutedIds },
callsignConflicts
) )
if (contacts.isNotEmpty()) pending = Pending( if (contacts.isNotEmpty()) pending = Pending(
preview, preview,
@@ -82,18 +82,39 @@ class QsoRepository(
override suspend fun delete(id: Long) = withContext(dispatcher) { importMutex.withLock { dao.delete(id) } } override suspend fun delete(id: Long) = withContext(dispatcher) { importMutex.withLock { dao.delete(id) } }
override suspend fun markUploaded(ids: List<Long>, grids: List<String>) = withContext(dispatcher) { override suspend fun markUploaded(ids: List<Long>, grids: List<String>, certificateCallsign: String) = withContext(dispatcher) {
if (ids.isEmpty()) return@withContext if (ids.isEmpty()) return@withContext
dao.markUploaded(ids) dao.markUploaded(ids)
// Stamp the station grid set this batch went out under (a line/corner prefill from // Stamp the station grid set this batch went out under (a line/corner prefill from
// the grid finder carries 2–4 grids). The logbook row shows it after the timestamp. // the grid finder carries 2–4 grids). The logbook row shows it after the timestamp.
val normalized = grids.map { it.trim().uppercase(Locale.US).take(4) } val normalized = grids.map { it.trim().uppercase(Locale.US).take(4) }
.filter { it.length >= 4 }.distinct().sorted() .filter { it.length >= 4 }.distinct().sorted()
if (normalized.isEmpty()) return@withContext if (normalized.isNotEmpty()) {
val stamped = ids.mapNotNull { id -> val stamped = ids.mapNotNull { id ->
dao.find(id)?.toDomain()?.takeIf { it.vuccGrids != normalized }?.copy(vuccGrids = normalized) dao.find(id)?.toDomain()?.takeIf { it.vuccGrids != normalized }?.copy(vuccGrids = normalized)
}
if (stamped.isNotEmpty()) dao.saveBatch(stamped.map { it.toEntity() })
} }
if (stamped.isNotEmpty()) dao.saveBatch(stamped.map { it.toEntity() }) // Records logged while no certificate was installed carry no own callsign; they were just
// signed with the certificate's, so write it into them. Blanks only, and through save() so
// the stored dedupe key follows the new value.
if (certificateCallsign.isNotBlank()) {
ids.mapNotNull { id -> dao.find(id)?.toDomain()?.takeIf { it.myCallsign.isBlank() } }
.forEach { save(it.copy(myCallsign = certificateCallsign.trim().uppercase(Locale.US))) }
}
}
override suspend fun rewriteMyCallsign(ids: List<Long>, callsign: String): Int = withContext(dispatcher) {
val target = callsign.trim().uppercase(Locale.US)
if (ids.isEmpty() || target.isBlank()) return@withContext 0
var changed = 0
ids.forEach { id ->
val record = dao.find(id)?.toDomain() ?: return@forEach
if (record.myCallsign.trim().equals(target, true)) return@forEach
save(record.copy(myCallsign = target))
changed++
}
changed
} }
override suspend fun markWavelogUploaded(ids: List<Long>, stationId: String) = withContext(dispatcher) { override suspend fun markWavelogUploaded(ids: List<Long>, stationId: String) = withContext(dispatcher) {
@@ -66,8 +66,17 @@ class SelectionRepo(
} }
} }
// Selected satellites are pinned to the top (matches upstream); ordering
// within each group stays name/catnum. Sits on the reactive chain, so
// ticking an entry re-sorts the visible list immediately.
private val itemsWithQuery = currentQuery.flatMapLatest { query -> private val itemsWithQuery = currentQuery.flatMapLatest { query ->
itemsWithTypes.map { items -> filterByQuery(items, query) } itemsWithTypes.map { items ->
filterByQuery(items, query).sortedWith(
compareByDescending<SatItem> { it.isSelected }
.thenBy { it.name }
.thenBy { it.catnum }
)
}
} }
override fun getCurrentTypes() = currentTypes.value override fun getCurrentTypes() = currentTypes.value
@@ -87,6 +87,7 @@ class LoTWConfigTest {
assertEquals("AO-91", config.resolveSatellite("FOX-1B")) assertEquals("AO-91", config.resolveSatellite("FOX-1B"))
assertEquals("IO-86", config.resolveSatellite("LAPAN-A2")) assertEquals("IO-86", config.resolveSatellite("LAPAN-A2"))
assertEquals("RS-44", config.resolveSatellite("DOSAAF-85")) assertEquals("RS-44", config.resolveSatellite("DOSAAF-85"))
assertEquals("RS95S", config.resolveSatellite("QMR-KWT 2"))
assertEquals("BO-102", config.resolveSatellite("CAS-7B")) assertEquals("BO-102", config.resolveSatellite("CAS-7B"))
assertEquals("SO-50", config.resolveSatellite("SO-50")) assertEquals("SO-50", config.resolveSatellite("SO-50"))
} }
@@ -0,0 +1,95 @@
/*
* 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.data.lotw
import com.rtbishop.look4sat.core.domain.repository.LoTWOperationException
import com.rtbishop.look4sat.core.domain.repository.LoTWProblem
import java.io.InputStream
import java.security.KeyStore
import org.junit.Assert.assertEquals
import org.junit.Assert.assertThrows
import org.junit.Test
/**
* Certificate import must not depend on the platform PKCS12 parser. Android's parser accepts a
* modern TQSL PBES2 export but can expose no key entry for it (observed with an empty-password
* backup, which was then rejected as "not a valid LoTW certificate file" although every field of
* the certificate is readable). These tests pin the fallback to our own PBES2 reader.
*/
class LoTWKeyMaterialTest {
private fun fixture(name: String): ByteArray =
javaClass.classLoader!!.getResourceAsStream(name)!!.use { it.readBytes() }
/** Stands in for a platform that parsed the container but offers no private-key entry. */
private fun storeWithoutKeyEntry(): KeyStore = KeyStore.getInstance("PKCS12").apply {
load(null as InputStream?, null as CharArray?)
}
private val now = System.currentTimeMillis()
/** TQSL-style backup (empty password) imported although the platform delivers no key entry. */
@Test
fun importsCertificateWhenPlatformExposesNoKeyEntry() {
val material = LoTWKeyMaterial.read(
bytes = fixture("test_tqsl_empty.p12"),
password = charArrayOf(),
now = now,
platformStore = { _, _ -> storeWithoutKeyEntry() },
)
assertEquals("BA7OPF", material.info.callsign)
assertEquals(318, material.info.dxcc)
assertEquals("2026-01-01", material.info.firstQsoDate)
}
/** The same backup still imports when the platform is the one that resolves the key entry. */
@Test
fun importsCertificateThroughPlatformWhenAvailable() {
val material = LoTWKeyMaterial.read(fixture("test_tqsl_empty.p12"), charArrayOf(), now)
assertEquals("BA7OPF", material.info.callsign)
}
/** A wrong password is still reported as a password problem, not as an invalid file. */
@Test
fun reportsWrongPasswordForPbes2Backup() {
val error = assertThrows(LoTWOperationException::class.java) {
LoTWKeyMaterial.read(fixture("test_tqsl_empty.p12"), "wrong-password".toCharArray(), now)
}
assertEquals(LoTWProblem.CERTIFICATE_PASSWORD, error.reason)
}
/** Something that is not a PKCS12 at all keeps the message the platform path produced. */
@Test
fun refusesNonPkcs12File() {
val error = assertThrows(LoTWOperationException::class.java) {
LoTWKeyMaterial.read("not a p12 at all".toByteArray(), "x".toCharArray(), now) { _, _ -> null }
}
assertEquals(LoTWProblem.CERTIFICATE_PASSWORD, error.reason)
}
/** Oversized input is rejected before any parser runs. */
@Test
fun refusesOversizedInput() {
val bytes = ByteArray(MAX_CERTIFICATE_BYTES + 1)
val error = assertThrows(LoTWOperationException::class.java) {
LoTWKeyMaterial.read(bytes, charArrayOf(), now) { _, _ -> storeWithoutKeyEntry() }
}
assertEquals(LoTWProblem.CERTIFICATE_INVALID, error.reason)
}
}
@@ -17,8 +17,13 @@
*/ */
package com.rtbishop.look4sat.core.data.lotw package com.rtbishop.look4sat.core.data.lotw
import com.rtbishop.look4sat.core.domain.logbook.QsoRecord
import com.rtbishop.look4sat.core.domain.logbook.QsoStatus
import com.rtbishop.look4sat.core.domain.repository.LoTWOperationException import com.rtbishop.look4sat.core.domain.repository.LoTWOperationException
import com.rtbishop.look4sat.core.domain.repository.LoTWProblem import com.rtbishop.look4sat.core.domain.repository.LoTWProblem
import com.rtbishop.look4sat.core.domain.repository.LoTWStation
import com.rtbishop.look4sat.core.domain.repository.LoTWUploadResult
import java.io.File
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull import org.junit.Assert.assertNull
@@ -39,7 +44,9 @@ class LoTWUploadRepositoryPreviewTest {
private class MemStorage : LoTWStorage { private class MemStorage : LoTWStorage {
val files = mutableMapOf<String, ByteArray>() val files = mutableMapOf<String, ByteArray>()
override fun read(name: String): ByteArray? = files[name] // Reads hand out their own copy, like the file-backed storage does: several call sites
// wipe what they read, and that must not corrupt the stored bytes.
override fun read(name: String): ByteArray? = files[name]?.copyOf()
override fun write(name: String, data: ByteArray) { files[name] = data.copyOf() } override fun write(name: String, data: ByteArray) { files[name] = data.copyOf() }
override fun delete(name: String) { files.remove(name) } override fun delete(name: String) { files.remove(name) }
} }
@@ -79,4 +86,104 @@ class LoTWUploadRepositoryPreviewTest {
assertTrue("nothing written", storage.files.isEmpty()) assertTrue("nothing written", storage.files.isEmpty())
} }
} }
/**
* Records logged before a certificate was installed (blank own callsign) or under another
* certificate are signed with the certificate's callsign instead of being refused, and the
* preview reports how many they were.
*/
@Test
fun `records without a matching callsign upload under the certificate and are reported`() {
runBlocking {
val storage = MemStorage()
val repo = LoTWUploadRepository(
storage,
{ LoTWConfig(File("src/main/assets/lotw/config.tq6").inputStream()) },
now = { System.currentTimeMillis() }
)
repo.importCertificate(fixture("test_tqsl_empty.p12"), charArrayOf())
repo.saveStation(LoTWStation(grid = "OL62TI"))
val start = 1_787_000_000_000L // 2026-08, inside the fixture certificate's QSO range
val preview = repo.prepare(
listOf(
sampleRecord(start, "BH6RJD", ""),
sampleRecord(start + 60_000L, "BG7QBL", "XX0YY"),
sampleRecord(start + 120_000L, "BA8BLK", "BA7OPF")
),
resubmit = false
)
// The blank record is signed with the certificate's callsign; the one naming another
// callsign is refused and offered up for a rewrite instead.
assertEquals(2, preview.count)
assertEquals(1, preview.missingCallsign)
assertEquals(listOf(start + 60_000L), preview.callsignConflicts)
assertEquals(1, preview.unavailableReasons[LoTWProblem.CALLSIGN_MISMATCH])
}
}
/** Nothing signable at all: the preview still comes back (count 0) with the conflicts listed,
* so the UI can offer the rewrite / use-another-certificate actions instead of a dead end. */
@Test
fun `fully blocked batch still returns a preview with the conflicts`() {
runBlocking {
val storage = MemStorage()
val repo = LoTWUploadRepository(
storage,
{ LoTWConfig(File("src/main/assets/lotw/config.tq6").inputStream()) },
now = { System.currentTimeMillis() }
)
repo.importCertificate(fixture("test_tqsl_empty.p12"), charArrayOf())
repo.saveStation(LoTWStation(grid = "OL62TI"))
val start = 1_787_000_000_000L
val preview = repo.prepare(
listOf(
sampleRecord(start, "BH6RJD", "XX0YY"),
sampleRecord(start + 60_000L, "BG7QBL", "XX0ZZ")
),
resubmit = false
)
assertEquals(0, preview.count)
assertEquals(2, preview.unavailableSkipped)
assertEquals(listOf(start, start + 60_000L), preview.callsignConflicts)
assertEquals(2, preview.unavailableReasons[LoTWProblem.CALLSIGN_MISMATCH])
assertEquals(0, preview.missingCallsign)
// No payload was prepared: confirming this preview must fail as expired, not crash.
assertTrue(repo.upload(preview.id) is LoTWUploadResult.ExpiredPreview)
}
}
/** The mismatch reason names both callsigns (record vs certificate). The old detail showed the
* opposite station's call, which told the operator nothing about which side to fix. */
@Test
fun `callsign mismatch detail names the record and the certificate callsign`() {
runBlocking {
val storage = MemStorage()
val repo = LoTWUploadRepository(
storage,
{ LoTWConfig(File("src/main/assets/lotw/config.tq6").inputStream()) },
now = { System.currentTimeMillis() }
)
repo.importCertificate(fixture("test_tqsl_empty.p12"), charArrayOf())
repo.saveStation(LoTWStation(grid = "OL62TI"))
val audit = repo.audit(listOf(sampleRecord(1_787_000_000_000L, "BH6RJD", "XX0YY")))
assertEquals("XX0YY ≠ BA7OPF", audit.details[LoTWProblem.CALLSIGN_MISMATCH])
}
}
private fun sampleRecord(start: Long, call: String, myCallsign: String) = QsoRecord(
id = start,
startUtcMillis = start,
endUtcMillis = start,
theirCallsign = call,
myCallsign = myCallsign,
myGrid = "OL62TI",
txFrequencyHz = 145_850_000L,
rxFrequencyHz = 436_795_000L,
band = "2M",
rxBand = "70CM",
mode = "FM",
satelliteName = "SO-50",
propagationMode = "SAT",
status = QsoStatus.COMPLETE
)
} }
@@ -295,6 +295,36 @@ class QsoRepositoryTest {
assertEquals(1, dao.getAll().size) assertEquals(1, dao.getAll().size)
} }
@Test
fun markUploaded_fillsBlankCallsignsAndKeepsTheOthers() = runBlocking {
val blank = dao.save(loggedInApp("SO-50", lotwUploaded = false).copy(myCallsign = "").toEntity())
val named = dao.save(loggedInApp("SO-50", lotwUploaded = false).copy(myCallsign = "BH3HCF").toEntity())
repository.markUploaded(listOf(blank, named), emptyList(), "BA7OPF")
val rows = dao.getAll().associateBy { it.id }
// Records logged before a certificate existed get the certificate's callsign; a record that
// names another callsign is not touched (they are refused, not rewritten, by the uploader).
assertEquals("BA7OPF", rows[blank]!!.myCallsign)
assertEquals("BH3HCF", rows[named]!!.myCallsign)
assertTrue(rows[blank]!!.lotwUploaded)
assertTrue(rows[named]!!.lotwUploaded)
}
@Test
fun rewriteMyCallsign_rewritesTheConflictRecordsOnly() = runBlocking {
val conflict = dao.save(loggedInApp("SO-50", lotwUploaded = false).copy(myCallsign = "XX0YY").toEntity())
val kept = dao.save(loggedInApp("SO-50", lotwUploaded = false).copy(myCallsign = "BA7OPF").toEntity())
val changed = repository.rewriteMyCallsign(listOf(conflict, kept), "ba7opf")
assertEquals(1, changed)
val rows = dao.getAll().associateBy { it.id }
assertEquals("BA7OPF", rows[conflict]!!.myCallsign)
assertEquals("BA7OPF", rows[kept]!!.myCallsign)
assertFalse(rows[conflict]!!.lotwUploaded)
}
@Test @Test
fun markWavelogUploaded_setsTheFlagAndTheStationStamp() = runBlocking { fun markWavelogUploaded_setsTheFlagAndTheStationStamp() = runBlocking {
val id = dao.save(loggedInApp("SO-50", lotwUploaded = false).toEntity()) val id = dao.save(loggedInApp("SO-50", lotwUploaded = false).toEntity())
@@ -284,6 +284,24 @@ class SelectionRepoSearchTest {
assertEquals(sampleItems.map { it.catnum }.toSet(), results.map { it.catnum }.toSet()) assertEquals(sampleItems.map { it.catnum }.toSet(), results.map { it.catnum }.toSet())
} }
@Test
fun `selected satellites are shown first`() = runTest {
// Pinning ported from upstream: ticked satellites sort above the rest,
// each group keeping the name order.
val repo = createRepo(
items = listOf(
SatItem(catnum = 44444, name = "Zeta"),
SatItem(catnum = 25544, name = "Alpha"),
SatItem(catnum = 40967, name = "Beta")
)
)
val flow = repo.getEntriesFlow()
repo.setSelection(listOf(40967), true)
val results = flow.first()
assertEquals(listOf(40967, 25544, 44444), results.map { it.catnum })
assertEquals(listOf(true, false, false), results.map { it.isSelected })
}
private fun createRepo( private fun createRepo(
items: List<SatItem>, items: List<SatItem>,
sstvIds: List<Int> = emptyList() sstvIds: List<Int> = emptyList()
@@ -14,9 +14,15 @@ import java.util.Date
import java.util.Locale import java.util.Locale
import java.util.TimeZone import java.util.TimeZone
/** Small ADI codec for the logbook's actual field set (ADIF 3.1.7). */ /**
* Small ADI codec for the logbook's actual field set (ADIF 3.1.7).
*
* Grid directions follow ADIF: the un-prefixed fields (GRIDSQUARE, VUCC_GRIDS) belong to
* the OPPOSITE station, the MY_* fields (MY_GRIDSQUARE, MY_VUCC_GRIDS) to the operator.
*/
object AdifCodec { object AdifCodec {
private val utc = TimeZone.getTimeZone("UTC") private val utc = TimeZone.getTimeZone("UTC")
private val gridPattern = Regex("[A-R]{2}[0-9]{2}([A-X]{2}([0-9]{2})?)?")
fun encode(records: List<QsoRecord>, includeStationCallsign: Boolean = true): String = buildString { fun encode(records: List<QsoRecord>, includeStationCallsign: Boolean = true): String = buildString {
append(field("ADIF_VER", "3.1.7")) append(field("ADIF_VER", "3.1.7"))
@@ -36,7 +42,17 @@ object AdifCodec {
appendOptional("MODE", record.mode) appendOptional("MODE", record.mode)
appendOptional("SUBMODE", record.submode) appendOptional("SUBMODE", record.submode)
appendOptional("GRIDSQUARE", record.theirGrid) appendOptional("GRIDSQUARE", record.theirGrid)
// The OPPOSITE station's multi-grid set (2+ grids: a grid line or corner).
// A single grid is carried by GRIDSQUARE; one value inside the VUCC field
// makes Wavelog flag the record in its QSO view.
if (record.theirVuccGrids.size > 1) {
appendOptional("VUCC_GRIDS", record.theirVuccGrids.joinToString(","))
}
appendOptional("MY_GRIDSQUARE", record.myGrid) appendOptional("MY_GRIDSQUARE", record.myGrid)
// The grid set this qso went out under (own operated grids, stamped at
// upload). ADIF's MY_VUCC_GRIDS is its home — not VUCC_GRIDS, which belongs
// to the opposite station and drives Wavelog's grid/distance/QRB display.
appendOptional("MY_VUCC_GRIDS", record.vuccGrids.joinToString(","))
appendOptional("RST_SENT", record.sentReport) appendOptional("RST_SENT", record.sentReport)
appendOptional("RST_RCVD", record.receivedReport) appendOptional("RST_RCVD", record.receivedReport)
record.txFrequencyHz?.let { append(field("FREQ", hzToMhz(it))) } record.txFrequencyHz?.let { append(field("FREQ", hzToMhz(it))) }
@@ -49,7 +65,6 @@ object AdifCodec {
if (record.lotwConfirmed) append(field("LOTW_QSL_RCVD", "Y")) if (record.lotwConfirmed) append(field("LOTW_QSL_RCVD", "Y"))
if (record.lotwReceived) append(field("LOTW_QSL_SENT", "Y")) if (record.lotwReceived) append(field("LOTW_QSL_SENT", "Y"))
appendOptional("LOTW_QSLRDATE", record.lotwQslDate) appendOptional("LOTW_QSLRDATE", record.lotwQslDate)
appendOptional("VUCC_GRIDS", record.vuccGrids.joinToString(","))
record.dxcc?.let { append(field("DXCC", it.toString())) } record.dxcc?.let { append(field("DXCC", it.toString())) }
appendOptional("COUNTRY", record.country) appendOptional("COUNTRY", record.country)
record.cqZone?.let { append(field("CQZ", it.toString())) } record.cqZone?.let { append(field("CQZ", it.toString())) }
@@ -80,7 +95,9 @@ object AdifCodec {
theirCallsign = call, theirCallsign = call,
myCallsign = values["STATION_CALLSIGN"].orEmpty().uppercase(Locale.US), myCallsign = values["STATION_CALLSIGN"].orEmpty().uppercase(Locale.US),
theirGrid = values["GRIDSQUARE"].orEmpty().uppercase(Locale.US), theirGrid = values["GRIDSQUARE"].orEmpty().uppercase(Locale.US),
theirVuccGrids = decodeGrids(values["VUCC_GRIDS"]),
myGrid = values["MY_GRIDSQUARE"].orEmpty().uppercase(Locale.US), myGrid = values["MY_GRIDSQUARE"].orEmpty().uppercase(Locale.US),
vuccGrids = decodeGrids(values["MY_VUCC_GRIDS"]),
sentReport = values["RST_SENT"].orEmpty(), sentReport = values["RST_SENT"].orEmpty(),
receivedReport = values["RST_RCVD"].orEmpty(), receivedReport = values["RST_RCVD"].orEmpty(),
txFrequencyHz = mhzToHz(values["FREQ"]), txFrequencyHz = mhzToHz(values["FREQ"]),
@@ -100,8 +117,6 @@ object AdifCodec {
lotwConfirmed = values["LOTW_QSL_RCVD"].equals("Y", true), lotwConfirmed = values["LOTW_QSL_RCVD"].equals("Y", true),
lotwReceived = values["LOTW_QSL_SENT"].equals("Y", true), lotwReceived = values["LOTW_QSL_SENT"].equals("Y", true),
lotwQslDate = values["LOTW_QSLRDATE"].orEmpty(), lotwQslDate = values["LOTW_QSLRDATE"].orEmpty(),
vuccGrids = values["VUCC_GRIDS"].orEmpty().split(',').map { it.trim().uppercase(Locale.US) }
.filter { it.matches(Regex("[A-R]{2}[0-9]{2}([A-X]{2}([0-9]{2})?)?")) }.distinct(),
dxcc = values["DXCC"]?.toIntOrNull(), dxcc = values["DXCC"]?.toIntOrNull(),
country = values["COUNTRY"].orEmpty(), country = values["COUNTRY"].orEmpty(),
cqZone = values["CQZ"]?.toIntOrNull()?.takeIf { it in 1..40 }, cqZone = values["CQZ"]?.toIntOrNull()?.takeIf { it in 1..40 },
@@ -140,6 +155,12 @@ object AdifCodec {
return records return records
} }
/** Splits an ADIF grid list on commas, keeping only valid 4/6/8-char locators. */
private fun decodeGrids(value: String?): List<String> = value.orEmpty().split(',')
.map { it.trim().uppercase(Locale.US) }
.filter { it.matches(gridPattern) }
.distinct()
private fun StringBuilder.appendOptional(name: String, value: String) { private fun StringBuilder.appendOptional(name: String, value: String) {
if (value.isNotBlank()) append(field(name, value)) if (value.isNotBlank()) append(field(name, value))
} }
@@ -22,7 +22,14 @@ interface IQsoRepository {
* signed with (1–4 gridsquares: inside a grid / on a line / on a corner), so the * signed with (1–4 gridsquares: inside a grid / on a line / on a corner), so the
* logbook row can show which grids the QSO went out under. * logbook row can show which grids the QSO went out under.
*/ */
suspend fun markUploaded(ids: List<Long>, grids: List<String> = emptyList()) suspend fun markUploaded(ids: List<Long>, grids: List<String> = emptyList(), certificateCallsign: String = "")
/**
* Rewrites the operator's callsign on the given records. This is the answer to "these contacts
* were logged under another certificate — upload them under the one installed now". Returns the
* number of records that changed.
*/
suspend fun rewriteMyCallsign(ids: List<Long>, callsign: String): Int
/** /**
* Marks the batch as uploaded to Wavelog (independent of the LoTW upload state) and * Marks the batch as uploaded to Wavelog (independent of the LoTW upload state) and
@@ -34,6 +34,8 @@ import java.util.Locale
* - live SatNOGS 3LE names and Celestrak amateur names joined to the same NORAD id, * - live SatNOGS 3LE names and Celestrak amateur names joined to the same NORAD id,
* - designator tokens of the config.tq6 satellite descriptions (e.g. CAS-7B -> BO-102), * - designator tokens of the config.tq6 satellite descriptions (e.g. CAS-7B -> BO-102),
* - the ISS family, which SatNOGS does not list under its ARRL name "ARISS". * - the ISS family, which SatNOGS does not list under its ARRL name "ARISS".
* Added 2026-10-08: config.tq6 v11.35 lists QMR-KWT 2 as "RS95S"; SatNOGS DB carries it as
* "QMR-KWT 2" (alias "RS95S") and other sources spell it "RS-95S", hence the identity entry.
* Placeholder names ("OBJECT AY" and friends) are deliberately NOT mapped: they are recycled * Placeholder names ("OBJECT AY" and friends) are deliberately NOT mapped: they are recycled
* between objects and would sign the wrong satellite. LoTWConfigTest asserts that every target * between objects and would sign the wrong satellite. LoTWConfigTest asserts that every target
* here is a real config.tq6 name. * here is a real config.tq6 name.
@@ -79,11 +81,11 @@ object LoTWSatelliteAliases {
"OSCAR16PACSAT" to "AO-16", "OSCAR19LUSAT" to "LO-19", "OSCAR64" to "DO-64", "OSCAR16PACSAT" to "AO-16", "OSCAR19LUSAT" to "LO-19", "OSCAR64" to "DO-64",
"OSCAR7" to "AO-7", "PACSAT" to "AO-16", "PARKINSONSAT" to "NO-84", "OSCAR7" to "AO-7", "PACSAT" to "AO-16", "PARKINSONSAT" to "NO-84",
"PCSAT" to "NO-44", "PHILLIPINESOSCAR101" to "PO-101", "PO101" to "PO-101", "PCSAT" to "NO-44", "PHILLIPINESOSCAR101" to "PO-101", "PO101" to "PO-101",
"PSAT" to "NO-84", "PSAT2" to "NO-104", "QB50P1" to "EO-79", "PSAT" to "NO-84", "PSAT2" to "NO-104", "QB50P1" to "EO-79", "QMRKWT2" to "RS95S",
"QO100" to "QO-100", "RADFXSAT" to "AO-91", "RADIOROSTO" to "RS-15", "QO100" to "QO-100", "RADFXSAT" to "AO-91", "RADIOROSTO" to "RS-15",
"RS0ISS" to "ARISS", "RS14" to "AO-21", "RS15" to "RS-15", "RS0ISS" to "ARISS", "RS14" to "AO-21", "RS15" to "RS-15",
"RS44" to "RS-44", "RS64S" to "AO-123", "SAUDISAT1C" to "SO-50", "RS44" to "RS-44", "RS64S" to "AO-123", "RS95S" to "RS95S",
"SO121" to "SO-121", "SO124" to "SO-124", "SO125" to "SO-125", "SAUDISAT1C" to "SO-50", "SO121" to "SO-121", "SO124" to "SO-124", "SO125" to "SO-125",
"SO50" to "SO-50", "SOLUTUSNANOSATTELITE" to "SONATE", "SONATE2" to "SONATE", "SO50" to "SO-50", "SOLUTUSNANOSATTELITE" to "SONATE", "SONATE2" to "SONATE",
"TAURUS1" to "TAURUS", "TEVEL21" to "TEV2-1", "TEVEL22" to "TEV2-2", "TAURUS1" to "TAURUS", "TEVEL21" to "TEV2-1", "TEVEL22" to "TEV2-2",
"TEVEL23" to "TEV2-3", "TEVEL24" to "TEV2-4", "TEVEL25" to "TEV2-5", "TEVEL23" to "TEV2-3", "TEVEL24" to "TEV2-4", "TEVEL25" to "TEV2-5",
@@ -18,6 +18,7 @@
package com.rtbishop.look4sat.core.domain.logbook package com.rtbishop.look4sat.core.domain.logbook
import com.rtbishop.look4sat.core.domain.repository.LoTWProblem import com.rtbishop.look4sat.core.domain.repository.LoTWProblem
import com.rtbishop.look4sat.core.domain.repository.LoTWUploadAudit
/** /**
* Wording for the reasons a QSO cannot be signed for LoTW. * Wording for the reasons a QSO cannot be signed for LoTW.
@@ -69,3 +70,14 @@ fun unavailableUploadSummary(
val detail = parts.joinToString(", ").ifBlank { "check the logbook" } val detail = parts.joinToString(", ").ifBlank { "check the logbook" }
return "$subject — $detail" return "$subject — $detail"
} }
/**
* True when a batch that has nothing uploadable still has to reach the preview dialog.
*
* The preview is the only place that carries the callsign-conflict actions (rewrite the records, or
* upload them under another certificate). Short-circuiting such a batch into the plain "can't be
* uploaded" message strands exactly the records the operator needs to act on — the message has no
* buttons to get out of, so those QSOs could never be uploaded.
*/
fun LoTWUploadAudit.needsPreviewForConflicts(): Boolean =
pending == 0 && (reasons[LoTWProblem.CALLSIGN_MISMATCH] ?: 0) > 0
@@ -48,6 +48,8 @@ data class QsoRecord(
val wavelogStation: String = "", val wavelogStation: String = "",
val lotwReceived: Boolean = false, val lotwReceived: Boolean = false,
val lotwQslDate: String = "", val lotwQslDate: String = "",
/** Own operated grid set this QSO went out under — uploaded as ADIF MY_VUCC_GRIDS
* (never VUCC_GRIDS, which belongs to the opposite station). Stamped at upload. */
val vuccGrids: List<String> = emptyList(), val vuccGrids: List<String> = emptyList(),
val dxcc: Int? = null, val dxcc: Int? = null,
val country: String = "", val country: String = "",
@@ -107,7 +107,13 @@ data class LoTWUploadPreview(
val grids: List<String> = emptyList(), val grids: List<String> = emptyList(),
/** True when this batch is a resubmit: already-uploaded/confirmed records were allowed /** True when this batch is a resubmit: already-uploaded/confirmed records were allowed
* through so the corrected station location reaches LoTW as an update of the contact. */ * through so the corrected station location reaches LoTW as an update of the contact. */
val resubmit: Boolean = false val resubmit: Boolean = false,
/** Records in this batch whose own callsign is blank (logged before a certificate was
* installed). They are signed with [callsign]; a successful upload writes it into them. */
val missingCallsign: Int = 0,
/** Records refused because they name a different own callsign than [callsign]. The upload
* flow offers either rewriting them with [callsign] or using the other certificate. */
val callsignConflicts: List<Long> = emptyList()
) )
/** Pre-upload location check: the freshest known position grid is not covered by the /** Pre-upload location check: the freshest known position grid is not covered by the
@@ -27,7 +27,7 @@ import org.junit.Test
/** Alias table that maps tracker catalogue names onto the names ARRL's config.tq6 uses. */ /** Alias table that maps tracker catalogue names onto the names ARRL's config.tq6 uses. */
class LoTWSatelliteAliasesTest { class LoTWSatelliteAliasesTest {
private val catalogue = listOf("SO-50", "ARISS", "PO-101", "BO-102", "AO-91", "RS-44", "IO-117") private val catalogue = listOf("SO-50", "ARISS", "PO-101", "BO-102", "AO-91", "RS-44", "IO-117", "RS95S")
@Test @Test
fun `lookup maps SatNOGS catalogue names to ARRL names`() { fun `lookup maps SatNOGS catalogue names to ARRL names`() {
@@ -37,6 +37,7 @@ class LoTWSatelliteAliasesTest {
assertEquals("AO-91", LoTWSatelliteAliases.lookup("FOX-1B")) assertEquals("AO-91", LoTWSatelliteAliases.lookup("FOX-1B"))
assertEquals("RS-44", LoTWSatelliteAliases.lookup("DOSAAF-85")) assertEquals("RS-44", LoTWSatelliteAliases.lookup("DOSAAF-85"))
assertEquals("IO-117", LoTWSatelliteAliases.lookup("GREENCUBE")) assertEquals("IO-117", LoTWSatelliteAliases.lookup("GREENCUBE"))
assertEquals("RS95S", LoTWSatelliteAliases.lookup("QMR-KWT 2"))
} }
@Test @Test
@@ -59,6 +60,8 @@ class LoTWSatelliteAliasesTest {
assertEquals("SO-50", LoTWSatelliteAliases.resolve("SAUDISAT 1C (SO-50)", catalogue)) assertEquals("SO-50", LoTWSatelliteAliases.resolve("SAUDISAT 1C (SO-50)", catalogue))
assertEquals("ARISS", LoTWSatelliteAliases.resolve("ISS (ZARYA)", catalogue)) assertEquals("ARISS", LoTWSatelliteAliases.resolve("ISS (ZARYA)", catalogue))
assertEquals("AO-91", LoTWSatelliteAliases.resolve("AO-91 (RadFxSat)", catalogue)) assertEquals("AO-91", LoTWSatelliteAliases.resolve("AO-91 (RadFxSat)", catalogue))
assertEquals("RS95S", LoTWSatelliteAliases.resolve("RS-95S", catalogue))
assertEquals("RS95S", LoTWSatelliteAliases.resolve("QMR-KWT 2", catalogue))
} }
@Test @Test
@@ -10,6 +10,7 @@
package com.rtbishop.look4sat.core.domain.logbook package com.rtbishop.look4sat.core.domain.logbook
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue import org.junit.Assert.assertTrue
import org.junit.Test import org.junit.Test
@@ -22,8 +23,10 @@ class AdifCodecTest {
endUtcMillis = 1_700_000_000_100L, endUtcMillis = 1_700_000_000_100L,
theirCallsign = "BG5JVM", theirCallsign = "BG5JVM",
myCallsign = "BA7OPF", myCallsign = "BA7OPF",
theirGrid = "OM60", theirGrid = "EN52",
theirVuccGrids = listOf("EN52", "EN53"),
myGrid = "OL62TI", myGrid = "OL62TI",
vuccGrids = listOf("OL62", "OL61"),
sentReport = "59", sentReport = "59",
receivedReport = "59", receivedReport = "59",
txFrequencyHz = 145_850_000L, txFrequencyHz = 145_850_000L,
@@ -36,8 +39,7 @@ class AdifCodecTest {
propagationMode = "SAT", propagationMode = "SAT",
status = QsoStatus.COMPLETE, status = QsoStatus.COMPLETE,
lotwConfirmed = true, lotwConfirmed = true,
lotwQslDate = "2026-09-20", lotwQslDate = "2026-09-20"
vuccGrids = listOf("OM60", "OM50")
) )
val decoded = AdifCodec.decode(AdifCodec.encode(listOf(record))).single() val decoded = AdifCodec.decode(AdifCodec.encode(listOf(record))).single()
assertEquals(record.startUtcMillis, decoded.startUtcMillis) assertEquals(record.startUtcMillis, decoded.startUtcMillis)
@@ -47,12 +49,18 @@ class AdifCodecTest {
assertEquals("2M", decoded.band) assertEquals("2M", decoded.band)
assertEquals("70CM", decoded.rxBand) assertEquals("70CM", decoded.rxBand)
assertTrue(decoded.lotwConfirmed) assertTrue(decoded.lotwConfirmed)
assertEquals(listOf("OM60", "OM50"), decoded.vuccGrids) // Grid directions survive a round trip: the opposite's set stays theirs,
// the operator's own set stays theirs.
assertEquals("EN52", decoded.theirGrid)
assertEquals(listOf("EN52", "EN53"), decoded.theirVuccGrids)
assertEquals("OL62TI", decoded.myGrid)
assertEquals(listOf("OL62", "OL61"), decoded.vuccGrids)
} }
@Test @Test
fun decode_lotwStyleReportFields() { fun decode_lotwStyleReportFields() {
// LoTW report subset: lowercase eor, alphabetical fields, VUCC_GRIDS list. // LoTW report subset: lowercase eor, alphabetical fields. The un-prefixed
// grid fields belong to the OPPOSITE station, MY_* fields to the operator.
val adi = """ val adi = """
<EOH> <EOH>
<CALL:6>BH6RJD <CALL:6>BH6RJD
@@ -64,8 +72,9 @@ class AdifCodecTest {
<BAND:3>2M <BAND:3>2M
<BAND_RX:4>70CM <BAND_RX:4>70CM
<GRIDSQUARE:4>OM60 <GRIDSQUARE:4>OM60
<MY_GRIDSQUARE:8>OM60IL70
<VUCC_GRIDS:9>OM60,OM50 <VUCC_GRIDS:9>OM60,OM50
<MY_GRIDSQUARE:6>OL62TI
<MY_VUCC_GRIDS:9>OL62,OL61
<STATION_CALLSIGN:6>BH6RJD <STATION_CALLSIGN:6>BH6RJD
<DXCC:3>318 <DXCC:3>318
<COUNTRY:5>China <COUNTRY:5>China
@@ -78,12 +87,27 @@ class AdifCodecTest {
assertEquals("BH6RJD", record.theirCallsign) assertEquals("BH6RJD", record.theirCallsign)
assertEquals("SO-50", record.satelliteName) assertEquals("SO-50", record.satelliteName)
assertEquals("FM", record.displayMode) assertEquals("FM", record.displayMode)
assertEquals("OM60IL70", record.myGrid) assertEquals("OM60", record.theirGrid)
assertEquals(listOf("OM60", "OM50"), record.vuccGrids) assertEquals(listOf("OM60", "OM50"), record.theirVuccGrids)
assertEquals("OL62TI", record.myGrid)
assertEquals(listOf("OL62", "OL61"), record.vuccGrids)
assertTrue(record.lotwConfirmed) assertTrue(record.lotwConfirmed)
assertTrue(record.isSatellite) assertTrue(record.isSatellite)
} }
@Test
fun decodeWavelogPullStyleRecordKeepsGridDirections() {
// Wavelog's export: the OPPOSITE station's grids un-prefixed, the station
// profile's grid under MY_*. A pull must not flip them into each other.
val adi = "<EOH><CALL:6>BG5JVM<QSO_DATE:8>20260916<TIME_ON:4>0745" +
"<VUCC_GRIDS:9>EN52,EN53<MY_GRIDSQUARE:6>OL62TI<MY_VUCC_GRIDS:9>OL62,OL61" +
"<MODE:2>FM<PROP_MODE:3>SAT<SAT_NAME:5>SO-50<EOR>"
val record = AdifCodec.decode(adi).single()
assertEquals(listOf("EN52", "EN53"), record.theirVuccGrids)
assertEquals("OL62TI", record.myGrid)
assertEquals(listOf("OL62", "OL61"), record.vuccGrids)
}
@Test @Test
fun encodeOmitsStationCallsignForWavelog() { fun encodeOmitsStationCallsignForWavelog() {
val record = QsoRecord( val record = QsoRecord(
@@ -99,4 +123,44 @@ class AdifCodecTest {
assertTrue(adif.contains("<CALL:6>N0CALL")) assertTrue(adif.contains("<CALL:6>N0CALL"))
assertTrue(adif.contains("<SAT_NAME:5>SO-50")) assertTrue(adif.contains("<SAT_NAME:5>SO-50"))
} }
@Test
fun encodeKeepsOwnGridsOutOfTheOppositeVuccField() {
// The reported defect: an operator's stamped own grid set must never land in
// VUCC_GRIDS — Wavelog displays that field as the OPPOSITE station's grid.
val record = QsoRecord(
startUtcMillis = 1_700_000_000_000L,
theirCallsign = "N0CALL",
myCallsign = "BA7OPF",
myGrid = "OL62TI",
vuccGrids = listOf("OL62"),
satelliteName = "SO-50"
)
val adif = AdifCodec.encode(listOf(record), includeStationCallsign = false)
assertTrue(adif.contains("<MY_VUCC_GRIDS:4>OL62"))
assertFalse(adif.contains("<VUCC_GRIDS:"))
assertFalse(adif.contains("<GRIDSQUARE:"))
}
@Test
fun encodeWritesTheOppositeMultiGridSetToVuccGrids() {
val multi = QsoRecord(
startUtcMillis = 1_700_000_000_000L,
theirCallsign = "N0CALL",
myCallsign = "BA7OPF",
theirGrid = "EN52",
theirVuccGrids = listOf("EN52", "EN53"),
satelliteName = "SO-50"
)
val multiAdif = AdifCodec.encode(listOf(multi), false)
assertTrue(multiAdif.contains("<GRIDSQUARE:4>EN52"))
assertTrue(multiAdif.contains("<VUCC_GRIDS:9>EN52,EN53"))
// A single opposite grid rides in GRIDSQUARE only; one value inside the VUCC
// field would trigger Wavelog's single-grid warning in its QSO view.
val single = multi.copy(theirVuccGrids = listOf("EN52"))
val singleAdif = AdifCodec.encode(listOf(single), false)
assertTrue(singleAdif.contains("<GRIDSQUARE:4>EN52"))
assertFalse(singleAdif.contains("<VUCC_GRIDS:"))
}
} }
@@ -0,0 +1,59 @@
/*
* 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.logbook
import com.rtbishop.look4sat.core.domain.repository.LoTWProblem
import com.rtbishop.look4sat.core.domain.repository.LoTWUploadAudit
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* The preview dialog is the only place carrying the callsign-conflict actions (rewrite / use another
* certificate), so a conflict-only batch must NOT be short-circuited into the plain
* "can't be uploaded" message: that dialog has no buttons to get out of.
*/
class LoTWUploadMessagesTest {
@Test
fun `conflict-only batch asks for the preview even with nothing pending`() {
assertTrue(audit(pending = 0, mismatch = 107, satellite = 1).needsPreviewForConflicts())
}
@Test
fun `blocked batch without conflicts keeps the plain message`() {
assertFalse(audit(pending = 0, mismatch = 0, satellite = 3).needsPreviewForConflicts())
}
@Test
fun `batch with something to upload never takes the conflict fallback`() {
assertFalse(audit(pending = 5, mismatch = 2, satellite = 1).needsPreviewForConflicts())
}
private fun audit(pending: Int, mismatch: Int, satellite: Int) = LoTWUploadAudit(
total = 108,
pending = pending,
uploaded = 0,
unknown = 0,
unavailable = 108 - pending,
reasons = buildMap {
if (mismatch > 0) put(LoTWProblem.CALLSIGN_MISMATCH, mismatch)
if (satellite > 0) put(LoTWProblem.SATELLITE, satellite)
}
)
}
@@ -19,6 +19,10 @@
<string name="sat_search_clear">Vaciar</string> <string name="sat_search_clear">Vaciar</string>
<string name="sat_clear_all">Vaciar todo</string> <string name="sat_clear_all">Vaciar todo</string>
<string name="sat_select_all">Seleccionar todo</string> <string name="sat_select_all">Seleccionar todo</string>
<string name="sat_group_selected">Seleccionados</string>
<string name="sat_group_available">Disponibles</string>
<string name="sat_group_selected_count">Seleccionados (%1$d)</string>
<string name="sat_group_available_count">Disponibles (%1$d)</string>
<string name="sat_empty_list_message"> <string name="sat_empty_list_message">
Asegúrese de que su consulta de búsqueda sea correcta y que la base de datos esté actualizada</string> Asegúrese de que su consulta de búsqueda sea correcta y que la base de datos esté actualizada</string>
<string name="sat_warning_title">Alarma\!</string> <string name="sat_warning_title">Alarma\!</string>
@@ -19,6 +19,10 @@
<string name="sat_search_clear">Очистить</string> <string name="sat_search_clear">Очистить</string>
<string name="sat_clear_all">Очистить</string> <string name="sat_clear_all">Очистить</string>
<string name="sat_select_all">Выбрать</string> <string name="sat_select_all">Выбрать</string>
<string name="sat_group_selected">Выбранные</string>
<string name="sat_group_available">Доступные</string>
<string name="sat_group_selected_count">Выбранные (%1$d)</string>
<string name="sat_group_available_count">Доступные (%1$d)</string>
<string name="sat_empty_list_message">Убедитесь, что ваш поисковый запрос верен и база данных обновлена</string> <string name="sat_empty_list_message">Убедитесь, что ваш поисковый запрос верен и база данных обновлена</string>
<string name="sat_warning_title">Внимание\!</string> <string name="sat_warning_title">Внимание\!</string>
<string name="sat_warning_message"> <string name="sat_warning_message">
@@ -19,6 +19,10 @@
<string name="sat_search_clear">පිරිසිදු ක°</string> <string name="sat_search_clear">පිරිසිදු ක°</string>
<string name="sat_clear_all">සියල්ල පිරිසිදු ක°</string> <string name="sat_clear_all">සියල්ල පිරිසිදු ක°</string>
<string name="sat_select_all">සියල්ල තෝරන්න</string> <string name="sat_select_all">සියල්ල තෝරන්න</string>
<string name="sat_group_selected">තෝරාගත්</string>
<string name="sat_group_available">තිබෙන</string>
<string name="sat_group_selected_count">තෝරාගත් (%1$d)</string>
<string name="sat_group_available_count">තිබෙන (%1$d)</string>
<string name="sat_empty_list_message"> <string name="sat_empty_list_message">
ඔබගේ සෙවුම් විමසුම නිවැරදි බවත් දත්ත සමුදාය යාවත්කාලීන කර ඇති බවත් සහතික කර ගන්න</string> ඔබගේ සෙවුම් විමසුම නිවැරදි බවත් දත්ත සමුදාය යාවත්කාලීන කර ඇති බවත් සහතික කර ගන්න</string>
<string name="sat_warning_title">අවවාදයයි\!</string> <string name="sat_warning_title">අවවාදයයි\!</string>
@@ -20,6 +20,10 @@
<string name="sat_search_clear">Temizle</string> <string name="sat_search_clear">Temizle</string>
<string name="sat_clear_all">Tümünü temizle</string> <string name="sat_clear_all">Tümünü temizle</string>
<string name="sat_select_all">Tümünü seç</string> <string name="sat_select_all">Tümünü seç</string>
<string name="sat_group_selected">Seçilenler</string>
<string name="sat_group_available">Kullanılabilir</string>
<string name="sat_group_selected_count">Seçilenler (%1$d)</string>
<string name="sat_group_available_count">Kullanılabilir (%1$d)</string>
<string name="sat_empty_list_message">"Arama sorgunuzun doğru olduğundan ve veritabanının güncellendiğinden emin olun"</string> <string name="sat_empty_list_message">"Arama sorgunuzun doğru olduğundan ve veritabanının güncellendiğinden emin olun"</string>
<string name="sat_warning_title">Uyarı\!</string> <string name="sat_warning_title">Uyarı\!</string>
<string name="sat_warning_message"> <string name="sat_warning_message">
@@ -19,6 +19,10 @@
<string name="sat_search_clear">Очистити</string> <string name="sat_search_clear">Очистити</string>
<string name="sat_clear_all">Очистити всі</string> <string name="sat_clear_all">Очистити всі</string>
<string name="sat_select_all">Вибрати всі</string> <string name="sat_select_all">Вибрати всі</string>
<string name="sat_group_selected">Обрані</string>
<string name="sat_group_available">Доступні</string>
<string name="sat_group_selected_count">Обрані (%1$d)</string>
<string name="sat_group_available_count">Доступні (%1$d)</string>
<string name="sat_empty_list_message"> <string name="sat_empty_list_message">
Переконайтеся, що ваш пошуковий запит правильний, а база даних оновлена</string> Переконайтеся, що ваш пошуковий запит правильний, а база даних оновлена</string>
<string name="sat_warning_title">Увага\!</string> <string name="sat_warning_title">Увага\!</string>
@@ -20,6 +20,10 @@
<string name="sat_search_clear">清空</string> <string name="sat_search_clear">清空</string>
<string name="sat_clear_all">全部清空</string> <string name="sat_clear_all">全部清空</string>
<string name="sat_select_all">全选</string> <string name="sat_select_all">全选</string>
<string name="sat_group_selected">已选择</string>
<string name="sat_group_available">可用</string>
<string name="sat_group_selected_count">已选择(%1$d)</string>
<string name="sat_group_available_count">可用(%1$d)</string>
<string name="sat_empty_list_message">请确保您的搜索查询正确且数据库已更新</string> <string name="sat_empty_list_message">请确保您的搜索查询正确且数据库已更新</string>
<string name="sat_warning_title">警告\!</string> <string name="sat_warning_title">警告\!</string>
<string name="sat_warning_message">此应用收录了超过9000颗卫星信息\n同时追踪所有卫星并不现实\n建议始终通过搜索和分类筛选功能仅勾选您感兴趣的卫星</string> <string name="sat_warning_message">此应用收录了超过9000颗卫星信息\n同时追踪所有卫星并不现实\n建议始终通过搜索和分类筛选功能仅勾选您感兴趣的卫星</string>
@@ -216,6 +220,10 @@
<string name="prefs_logbook_selected">已选 %1$d 条</string> <string name="prefs_logbook_selected">已选 %1$d 条</string>
<string name="prefs_logbook_select_hint">长按记录行可多选重传</string> <string name="prefs_logbook_select_hint">长按记录行可多选重传</string>
<string name="prefs_logbook_resubmit_note">已上传记录将按当前台址就地更新;已被奖项使用的旧网格可能被拒绝。</string> <string name="prefs_logbook_resubmit_note">已上传记录将按当前台址就地更新;已被奖项使用的旧网格可能被拒绝。</string>
<string name="prefs_logbook_callsign_missing">其中 %1$d 条记录没有呼号,将按证书 %2$s 上传。</string>
<string name="prefs_logbook_callsign_different">%1$d 条记录登记的不是证书 %2$s 的呼号。</string>
<string name="prefs_logbook_callsign_rewrite">改写为 %1$s</string>
<string name="prefs_logbook_callsign_switch">换一张证书</string>
<string name="prefs_logbook_station_label">台址</string> <string name="prefs_logbook_station_label">台址</string>
<string name="prefs_logbook_station_all">全部</string> <string name="prefs_logbook_station_all">全部</string>
<string name="prefs_logbook_upload_wavelog_title">上传到 Wavelog</string> <string name="prefs_logbook_upload_wavelog_title">上传到 Wavelog</string>
@@ -21,6 +21,10 @@
<string name="sat_search_clear">Clear</string> <string name="sat_search_clear">Clear</string>
<string name="sat_clear_all">Clear all</string> <string name="sat_clear_all">Clear all</string>
<string name="sat_select_all">Select all</string> <string name="sat_select_all">Select all</string>
<string name="sat_group_selected">Selected</string>
<string name="sat_group_available">Available</string>
<string name="sat_group_selected_count">Selected (%1$d)</string>
<string name="sat_group_available_count">Available (%1$d)</string>
<string name="sat_empty_list_message">"Make sure your search query is correct and the DB is updated"</string> <string name="sat_empty_list_message">"Make sure your search query is correct and the DB is updated"</string>
<string name="sat_warning_title">Warning\!</string> <string name="sat_warning_title">Warning\!</string>
<string name="sat_warning_message"> <string name="sat_warning_message">
@@ -245,6 +249,10 @@
<string name="prefs_logbook_selected">%1$d selected</string> <string name="prefs_logbook_selected">%1$d selected</string>
<string name="prefs_logbook_select_hint">Long-press a row to re-upload corrected records</string> <string name="prefs_logbook_select_hint">Long-press a row to re-upload corrected records</string>
<string name="prefs_logbook_resubmit_note">Existing contacts will be updated in place — a grid already used for an award may be rejected.</string> <string name="prefs_logbook_resubmit_note">Existing contacts will be updated in place — a grid already used for an award may be rejected.</string>
<string name="prefs_logbook_callsign_missing">%1$d record(s) have no callsign — signed with %2$s from the certificate.</string>
<string name="prefs_logbook_callsign_different">%1$d record(s) name another callsign than the certificate %2$s.</string>
<string name="prefs_logbook_callsign_rewrite">Rewrite with %1$s</string>
<string name="prefs_logbook_callsign_switch">Use another certificate</string>
<string name="prefs_logbook_station_label">Station</string> <string name="prefs_logbook_station_label">Station</string>
<string name="prefs_logbook_station_all">All</string> <string name="prefs_logbook_station_all">All</string>
<string name="prefs_logbook_upload_wavelog_title">Upload to Wavelog</string> <string name="prefs_logbook_upload_wavelog_title">Upload to Wavelog</string>
+1 -1
View File
@@ -1 +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],[180.0,-16.0671]],[[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],[Line truncated {"type":"dxcc","regions":[{"id":0,"code":176,"name":"Fiji","label_lon":177.9759,"label_lat":-17.9376,"pfx":"3D2","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],[180.0,-16.0671]],[[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,"pfx":"5H","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,"pfx":"S0","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,"pfx":"VE","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.Line truncated
@@ -22,6 +22,7 @@ import android.graphics.Canvas
import android.graphics.Color import android.graphics.Color
import android.graphics.Paint import android.graphics.Paint
import android.graphics.Path import android.graphics.Path
import org.json.JSONArray
import org.json.JSONObject import org.json.JSONObject
import org.osmdroid.views.MapView import org.osmdroid.views.MapView
import org.osmdroid.views.Projection import org.osmdroid.views.Projection
@@ -48,6 +49,15 @@ data class AwardRegion(
val labelLon: Double, val labelLon: Double,
val labelLat: Double, val labelLat: Double,
val rings: List<List<DoubleArray>>, val rings: List<List<DoubleArray>>,
/** Entity call-sign prefix ("BY" for China, "K" for the USA) from the
* Club Log cty.csv primary prefix; shown in the QSO detail dialog. */
val pfx: String = "",
/** Optional fill geometry with separate child entities knocked out as
* even-odd holes (e.g. Lesotho inside South Africa, Hong Kong inside
* China). Used only for the worked green fill so a worked parent never
* greens a different DXCC entity's territory; [rings] stay the stroke /
* hit-test source. Empty means "fill = rings". */
val fillRings: List<List<DoubleArray>> = emptyList(),
val forceLabel: Boolean = false val forceLabel: Boolean = false
) )
@@ -61,6 +71,20 @@ object AwardBoundaryData {
private val cache = mutableMapOf<AwardAsset, List<AwardRegion>>() private val cache = mutableMapOf<AwardAsset, List<AwardRegion>>()
private fun parseRings(array: JSONArray): List<List<DoubleArray>> {
val rings = mutableListOf<List<DoubleArray>>()
for (r in 0 until array.length()) {
val ring = array.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)
}
return rings
}
enum class AwardAsset(val fileName: String) { enum class AwardAsset(val fileName: String) {
WAPC("wapc.json"), WAPC("wapc.json"),
WAJA("waja.json"), WAJA("waja.json"),
@@ -80,16 +104,7 @@ object AwardBoundaryData {
for (i in 0 until array.length()) { for (i in 0 until array.length()) {
val o = array.optJSONObject(i) ?: continue val o = array.optJSONObject(i) ?: continue
val ringsArray = o.optJSONArray("rings") ?: continue val ringsArray = o.optJSONArray("rings") ?: continue
val rings = mutableListOf<List<DoubleArray>>() val rings = parseRings(ringsArray)
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 if (rings.isEmpty()) continue
list.add( list.add(
AwardRegion( AwardRegion(
@@ -98,7 +113,9 @@ object AwardBoundaryData {
nameEn = o.optString("name_en").takeIf { it.isNotEmpty() }, nameEn = o.optString("name_en").takeIf { it.isNotEmpty() },
labelLon = o.optDouble("label_lon", 0.0), labelLon = o.optDouble("label_lon", 0.0),
labelLat = o.optDouble("label_lat", 0.0), labelLat = o.optDouble("label_lat", 0.0),
pfx = o.optString("pfx"),
rings = rings, rings = rings,
fillRings = o.optJSONArray("fill_rings")?.let { parseRings(it) } ?: emptyList(),
forceLabel = o.optBoolean("force_label", false) forceLabel = o.optBoolean("force_label", false)
) )
) )
@@ -238,6 +255,14 @@ class AwardBoundaryOverlay : Overlay() {
val tiny = max(regionW, regionH) <= AwardHitTest.MIN_SHAPE_PX val tiny = max(regionW, regionH) <= AwardHitTest.MIN_SHAPE_PX
val path = Path() val path = Path()
// The worked fill uses fillRings when present: the award asset stores
// fill geometry with separate child entities (Lesotho in South Africa,
// Hong Kong in China, ...) knocked out as even-odd holes, so working a
// parent never greens a different DXCC entity's territory. The stroke
// keeps using the plain rings — hole borders coincide with the child
// entity's own outline and must not be drawn twice.
val fillRings = region.fillRings.ifEmpty { region.rings }
val fillPath = if (worked) Path().apply { fillType = Path.FillType.EVEN_ODD } else null
var pathHasPoints = false var pathHasPoints = false
for (ring in region.rings) { for (ring in region.rings) {
// Split each ring into consecutive segments at the antimeridian // Split each ring into consecutive segments at the antimeridian
@@ -262,9 +287,19 @@ class AwardBoundaryOverlay : Overlay() {
pathHasPoints = traceSegment(path, segment, projection, centerLon, worldWidthPx, closeRing) || pathHasPoints pathHasPoints = traceSegment(path, segment, projection, centerLon, worldWidthPx, closeRing) || pathHasPoints
} }
} }
fillPath?.let { fp ->
for (ring in fillRings) {
val segments = splitRingAtAntimeridian(ring)
val closeRing = segments.size == 1
for (segment in segments) {
if (segment.isEmpty()) continue
traceSegment(fp, segment, projection, centerLon, worldWidthPx, closeRing)
}
}
}
if (!pathHasPoints && !tiny) continue if (!pathHasPoints && !tiny) continue
if (worked) canvas.drawPath(path, workedPaint) if (worked) fillPath?.let { canvas.drawPath(it, workedPaint) }
linePaint.strokeWidth = if (selected) LINE_WIDTH_SELECTED else LINE_WIDTH linePaint.strokeWidth = if (selected) LINE_WIDTH_SELECTED else LINE_WIDTH
canvas.drawPath(path, linePaint) canvas.drawPath(path, linePaint)
linePaint.strokeWidth = LINE_WIDTH linePaint.strokeWidth = LINE_WIDTH
@@ -778,13 +778,13 @@ private fun RegionQsoDialog(
) { ) {
// WAZ regions are named by their bare zone number in the asset, so the // WAZ regions are named by their bare zone number in the asset, so the
// title gets the full "CQ ZONE 24" wording (user request 2026-10-05) and // title gets the full "CQ ZONE 24" wording (user request 2026-10-05) and
// the code is not repeated in the subtitle. DXCC carries its award prefix // the code is not repeated in the subtitle. DXCC shows the entity's call
// in the subtitle ("DXCC 318", same request); other awards keep the bare // prefix instead of the numeric code ("BY", user request 2026-10-06);
// code. // other awards keep the bare code.
val isZone = type == AwardType.WAZ val isZone = type == AwardType.WAZ
val codeLabel = when { val codeLabel = when {
isZone -> "" isZone -> ""
type == AwardType.DXCC -> "DXCC ${region.code} · " type == AwardType.DXCC -> "${region.pfx.ifBlank { region.code }} · "
else -> "${region.code} · " else -> "${region.code} · "
} }
androidx.compose.ui.window.Dialog(onDismissRequest = onDismiss) { androidx.compose.ui.window.Dialog(onDismissRequest = onDismiss) {
@@ -18,8 +18,10 @@
package com.rtbishop.look4sat.feature.map package com.rtbishop.look4sat.feature.map
import java.io.File import java.io.File
import org.json.JSONArray
import org.json.JSONObject import org.json.JSONObject
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue import org.junit.Assert.assertTrue
import org.junit.Test import org.junit.Test
@@ -60,8 +62,10 @@ class DxccAssetTest {
first { it.getInt("code") == code } first { it.getInt("code") == code }
/** Even-odd ray casting across all rings of a region (rings are disjoint parts). */ /** Even-odd ray casting across all rings of a region (rings are disjoint parts). */
private fun regionContains(region: JSONObject, lon: Double, lat: Double): Boolean { private fun regionContains(region: JSONObject, lon: Double, lat: Double): Boolean =
val rings = region.getJSONArray("rings") ringsContain(region.getJSONArray("rings"), lon, lat)
private fun ringsContain(rings: JSONArray, lon: Double, lat: Double): Boolean {
var inside = false var inside = false
for (r in 0 until rings.length()) { for (r in 0 until rings.length()) {
val ring = rings.getJSONArray(r) val ring = rings.getJSONArray(r)
@@ -220,4 +224,123 @@ class DxccAssetTest {
!regionContains(regions.byCode(c.outside), c.lon, c.lat)) !regionContains(regions.byCode(c.outside), c.lon, c.lat))
} }
} }
@Test
fun `every entity carries its primary call prefix`() {
// pfx = the ARRL DXCC entity prefix (Club Log cty.csv primary prefix),
// shown in the QSO detail dialog instead of the numeric code. When an
// entity owns several prefixes (China: BA/BG/BY..., USA: K/AA/W...) the
// entity prefix is the conventional one. cty rows may list sub-entities
// first (Vienna Intl Ctr before Austria under code 206), so the plain
// entity row wins.
val regions = loadRegions()
regions.forEach { region ->
assertTrue("${region.getString("name")}: blank pfx",
region.getString("pfx").isNotBlank())
}
val expected = mapOf(
318 to "BY", 291 to "K", 339 to "JA", 230 to "DL", 266 to "LA",
206 to "OE", 259 to "JW", 232 to "6O", 215 to "5B", 302 to "S0",
54 to "UA", 15 to "UA9", 126 to "UA2", 223 to "G"
)
for ((code, pfx) in expected) {
assertEquals("pfx for $code", pfx, regions.byCode(code).getString("pfx"))
}
}
@Test
fun `merged map units are framed by their DXCC parent`() {
// Natural Earth draws Somaliland and N. Cyprus as separate map units.
// Both belong to their DXCC entity (6O Somalia / 5B Cyprus) and are
// merged into the parent rings — without the merge the northern
// territories had no boundary at all (nothing drawn, never greened).
val regions = loadRegions()
val checks = listOf(
Triple("Hargeisa", 44.06 to 9.56, 232),
Triple("Berbera", 45.02 to 10.44, 232),
Triple("Garowe", 48.48 to 8.40, 232),
Triple("Kyrenia", 33.32 to 35.34, 215),
Triple("Nicosia", 33.38 to 35.19, 215)
)
for ((city, pos, code) in checks) {
assertTrue("$city should be inside $code",
regionContains(regions.byCode(code), pos.first, pos.second))
}
}
@Test
fun `Norway keeps only its mainland ring`() {
// Norway's Natural Earth geometry carries coarse Svalbard rings (7-17 pts).
// Svalbard (259) is a separate DXCC entity with its own detailed rings, so
// the coarse copies were deleted — they drew every island twice (a simple
// and a coast-fitting outline) and greening Norway also greened JW.
val norway = loadRegions().byCode(266)
val rings = norway.getJSONArray("rings")
assertEquals("Norway ring count", 1, rings.length())
for (r in 0 until rings.length()) {
val ring = rings.getJSONArray(r)
for (p in 0 until ring.length()) {
val lat = ring.getJSONArray(p).getDouble(1)
assertTrue("Norway vertex at $lat — Svalbard ring leftover", lat <= 73.0)
}
}
}
@Test
fun `worked fill knockouts cover the vetted parent child pairs`() {
// fill_rings = parent territory minus separate child DXCC entities, so a
// worked parent (e.g. South Africa 462) never greens a child entity
// (Lesotho 432) that has its own code. Pairs were vetted geometrically
// (child area >=50% inside the parent); this test pins them so future
// asset edits re-trigger the review.
val expected = mapOf(
462 to setOf(432), // South Africa - Lesotho
318 to setOf(321), // China - Hong Kong
299 to setOf(381), // Malaysia - Singapore
227 to setOf(203, 260), // France - Andorra, Monaco
248 to setOf(278, 295), // Italy - San Marino, Vatican
206 to setOf(251), // Austria - Liechtenstein
287 to setOf(251), // Switzerland - Liechtenstein
281 to setOf(203, 233), // Spain - Andorra, Gibraltar
70 to setOf(105) // Cuba - Guantanamo Bay
)
val regions = loadRegions()
val withFill = regions.filter { it.has("fill_rings") }.map { it.getInt("code") }.toSet()
assertEquals("regions carrying fill_rings", expected.keys, withFill)
for ((parentCode, childCodes) in expected) {
val parent = regions.byCode(parentCode)
val fillRings = parent.getJSONArray("fill_rings")
assertTrue("${parent.getString("name")}: empty fill_rings", fillRings.length() >= 1)
// The parent's own anchor must survive the knockout (no over-cutting).
val pLon = parent.getDouble("label_lon")
val pLat = parent.getDouble("label_lat")
assertTrue("${parent.getString("name")}: anchor lost in fill",
ringsContain(fillRings, pLon, pLat))
for (childCode in childCodes) {
val child = regions.byCode(childCode)
val childRings = child.getJSONArray("rings")
// Probes: the label anchor plus each ring's centroid. Some anchors
// drift outside their own ring (Guantanamo Bay), so a probe only
// counts when it really sits inside the child's own geometry.
val probes = mutableListOf(
child.getDouble("label_lon") to child.getDouble("label_lat")
)
for (r in 0 until childRings.length()) {
val ring = childRings.getJSONArray(r)
var sx = 0.0; var sy = 0.0
for (p in 0 until ring.length()) {
sx += ring.getJSONArray(p).getDouble(0)
sy += ring.getJSONArray(p).getDouble(1)
}
probes.add(sx / ring.length() to sy / ring.length())
}
for ((cLon, cLat) in probes) {
if (regionContains(child, cLon, cLat) && regionContains(parent, cLon, cLat)) {
assertFalse("${parent.getString("name")} fill still covers ${child.getString("name")}",
ringsContain(fillRings, cLon, cLat))
}
}
}
}
}
} }
@@ -82,7 +82,10 @@ class MutualViewModel(
private val settingsRepo: ISettingsRepo, private val settingsRepo: ISettingsRepo,
// Injected so unit tests can run the pass computation on a test dispatcher; // Injected so unit tests can run the pass computation on a test dispatcher;
// production callers keep the CPU-bound pool default. // production callers keep the CPU-bound pool default.
private val computeDispatcher: CoroutineDispatcher = Dispatchers.Default private val computeDispatcher: CoroutineDispatcher = Dispatchers.Default,
// Injected so unit tests can pin the wall clock to the fixture TLE epoch and
// stay deterministic; production callers keep the real clock default.
private val nowMillis: () -> Long = System::currentTimeMillis
) : ViewModel() { ) : ViewModel() {
private val _uiState = MutableStateFlow(MutualUiState()) private val _uiState = MutableStateFlow(MutualUiState())
@@ -303,7 +306,7 @@ class MutualViewModel(
queryGeneration += 1 queryGeneration += 1
viewModelScope.launch { viewModelScope.launch {
val now = System.currentTimeMillis() val now = nowMillis()
// 转发器类型筛选: 使用硬编码 AMSAT Live 清单 (见 Sources): // 转发器类型筛选: 使用硬编码 AMSAT Live 清单 (见 Sources):
// FM = {SO-50, ISS ZARYA, AO-123}, Linear = {RS-44, FO-29, // FM = {SO-50, ISS ZARYA, AO-123}, Linear = {RS-44, FO-29,
// AO-7, AO-73, JO-97}. 不再依赖 AMSAT 网页同步: 任何网络下都 // AO-7, AO-73, JO-97}. 不再依赖 AMSAT 网页同步: 任何网络下都
@@ -53,7 +53,9 @@ class MutualViewModelTest {
): MutualViewModel = MutualViewModel( ): MutualViewModel = MutualViewModel(
satelliteRepo = FakeSatelliteRepo(satellites, passes), satelliteRepo = FakeSatelliteRepo(satellites, passes),
settingsRepo = FakeSettingsRepo(position), settingsRepo = FakeSettingsRepo(position),
computeDispatcher = StandardTestDispatcher(mainDispatcherRule.dispatcher.scheduler) computeDispatcher = StandardTestDispatcher(mainDispatcherRule.dispatcher.scheduler),
// Pin the clock to the fixture base so results never depend on wall time.
nowMillis = { TestOrbits.FIXTURE_BASE }
) )
private fun TestScope.queryAndSettle(vm: MutualViewModel) { private fun TestScope.queryAndSettle(vm: MutualViewModel) {
@@ -219,7 +221,7 @@ class MutualViewModelTest {
assertTrue(state.mutualPasses.all { it.catNum == 25544 }) assertTrue(state.mutualPasses.all { it.catNum == 25544 })
assertTrue(state.mutualPasses.all { it.name == "ISS (ZARYA)" }) assertTrue(state.mutualPasses.all { it.name == "ISS (ZARYA)" })
// AOS/LOS within the searched horizon // AOS/LOS within the searched horizon
val now = System.currentTimeMillis() val now = TestOrbits.FIXTURE_BASE
assertTrue(state.mutualPasses.all { it.startTime >= now - 120_000L }) assertTrue(state.mutualPasses.all { it.startTime >= now - 120_000L })
assertTrue(state.mutualPasses.all { it.endTime <= now + 12 * 3600_000L + 120_000L }) assertTrue(state.mutualPasses.all { it.endTime <= now + 12 * 3600_000L + 120_000L })
} }
@@ -48,6 +48,15 @@ object TestOrbits {
) )
) )
/**
* Fixed wall-clock base for every fixture computation: 2024-03-10T00:00Z,
* ~18 h after [ISS]'s TLE epoch, so SGP4 propagates an accurate, healthy
* orbit. Tests pin the ViewModel clock to this value as well
* (MutualViewModel.nowMillis), which makes pass windows deterministic
* instead of rotting as the fixture ages against System.currentTimeMillis().
*/
val FIXTURE_BASE: Long = 1710028800000L
/** Guangzhou (default BA7OPF grid OL62/OL63 area). */ /** Guangzhou (default BA7OPF grid OL62/OL63 area). */
val GUANGZHOU = GeoPos(23.13, 113.26) val GUANGZHOU = GeoPos(23.13, 113.26)
@@ -85,7 +94,7 @@ object TestOrbits {
fun findPassWindows( fun findPassWindows(
sat: OrbitalObject = ISS, sat: OrbitalObject = ISS,
pos: GeoPos = GUANGZHOU, pos: GeoPos = GUANGZHOU,
time: Long = System.currentTimeMillis(), time: Long = FIXTURE_BASE,
hoursAhead: Int = 12 hoursAhead: Int = 12
): List<OrbitalPass> { ): List<OrbitalPass> {
if (!sat.willBeSeen(pos)) return emptyList() if (!sat.willBeSeen(pos)) return emptyList()
@@ -262,7 +262,9 @@ fun LogPage(
preview = preview, preview = preview,
busy = logUiState.busy, busy = logUiState.busy,
onConfirm = logViewModel::confirmUpload, onConfirm = logViewModel::confirmUpload,
onDismiss = logViewModel::dismissPreview onDismiss = logViewModel::dismissPreview,
onRewriteCallsign = logViewModel::rewriteCallsignConflicts,
onSwitchCertificate = logViewModel::switchCertificateForConflicts
) )
} }
} }
@@ -417,7 +419,9 @@ private fun UploadPreviewDialog(
preview: com.rtbishop.look4sat.core.domain.repository.LoTWUploadPreview, preview: com.rtbishop.look4sat.core.domain.repository.LoTWUploadPreview,
busy: Boolean, busy: Boolean,
onConfirm: () -> Unit, onConfirm: () -> Unit,
onDismiss: () -> Unit onDismiss: () -> Unit,
onRewriteCallsign: () -> Unit,
onSwitchCertificate: () -> Unit
) { ) {
AlertDialog( AlertDialog(
onDismissRequest = onDismiss, onDismissRequest = onDismiss,
@@ -427,7 +431,10 @@ private fun UploadPreviewDialog(
text = { text = {
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text("${preview.callsign} DXCC ${preview.dxcc} Grid ${preview.grid}", fontSize = 13.sp) Text("${preview.callsign} DXCC ${preview.dxcc} Grid ${preview.grid}", fontSize = 13.sp)
Text("${preview.count} QSO(s) · ${ val range = if (preview.firstUtc.isBlank()) {
// A blocked batch (shown for its conflict actions) has no date range to show.
"${preview.count} QSO(s)"
} else "${preview.count} QSO(s) · ${
java.text.SimpleDateFormat("MM-dd HH:mm", java.util.Locale.US).apply { java.text.SimpleDateFormat("MM-dd HH:mm", java.util.Locale.US).apply {
timeZone = java.util.TimeZone.getTimeZone("UTC") timeZone = java.util.TimeZone.getTimeZone("UTC")
}.format(java.util.Date(preview.firstUtc.let { }.format(java.util.Date(preview.firstUtc.let {
@@ -443,7 +450,8 @@ private fun UploadPreviewDialog(
timeZone = java.util.TimeZone.getTimeZone("UTC") timeZone = java.util.TimeZone.getTimeZone("UTC")
}.parse(it).time }.getOrDefault(System.currentTimeMillis()) }.parse(it).time }.getOrDefault(System.currentTimeMillis())
})) }))
}Z", fontSize = 13.sp) }Z"
Text(range, fontSize = 13.sp)
if (preview.skipped > 0 || preview.unknownSkipped > 0 || preview.unavailableSkipped > 0) { if (preview.skipped > 0 || preview.unknownSkipped > 0 || preview.unavailableSkipped > 0) {
val parts = buildList { val parts = buildList {
// skipped counts every record left out of this batch: previously-uploaded // skipped counts every record left out of this batch: previously-uploaded
@@ -464,11 +472,38 @@ private fun UploadPreviewDialog(
} }
Text(parts.joinToString(" · "), fontSize = 12.sp, color = MaterialTheme.colorScheme.error) Text(parts.joinToString(" · "), fontSize = 12.sp, color = MaterialTheme.colorScheme.error)
} }
if (preview.missingCallsign > 0) {
// Logged while no certificate was installed; signed with the certificate's
// callsign instead of being refused.
Text(
stringResource(R.string.prefs_logbook_callsign_missing, preview.missingCallsign, preview.callsign),
fontSize = 12.sp,
color = MaterialTheme.colorScheme.primary
)
}
if (preview.callsignConflicts.isNotEmpty()) {
// These name another callsign than the certificate: rewrite them with this
// certificate's callsign, or upload them under the other certificate.
Text(
stringResource(R.string.prefs_logbook_callsign_different, preview.callsignConflicts.size, preview.callsign),
fontSize = 12.sp,
color = MaterialTheme.colorScheme.error
)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
TextButton(onClick = onRewriteCallsign, enabled = !busy) {
Text(stringResource(R.string.prefs_logbook_callsign_rewrite, preview.callsign), fontSize = 12.sp)
}
TextButton(onClick = onSwitchCertificate, enabled = !busy) {
Text(stringResource(R.string.prefs_logbook_callsign_switch), fontSize = 12.sp)
}
}
}
Text(preview.contacts.joinToString("\n") { it }, fontSize = 12.sp, maxLines = 8) Text(preview.contacts.joinToString("\n") { it }, fontSize = 12.sp, maxLines = 8)
} }
}, },
confirmButton = { confirmButton = {
TextButton(onClick = onConfirm, enabled = !busy) { Text("确认上传") } // Nothing uploadable (a conflict-only batch): the actions above are the way out.
TextButton(onClick = onConfirm, enabled = !busy && preview.count > 0) { Text("确认上传") }
}, },
dismissButton = { dismissButton = {
TextButton(onClick = onDismiss) { Text("Cancel") } TextButton(onClick = onDismiss) { Text("Cancel") }
@@ -18,6 +18,7 @@ import com.rtbishop.look4sat.core.domain.logbook.QsoRecord
import com.rtbishop.look4sat.core.domain.logbook.QsoStatus import com.rtbishop.look4sat.core.domain.logbook.QsoStatus
import com.rtbishop.look4sat.core.domain.logbook.displayMode import com.rtbishop.look4sat.core.domain.logbook.displayMode
import com.rtbishop.look4sat.core.domain.logbook.frequencyBand import com.rtbishop.look4sat.core.domain.logbook.frequencyBand
import com.rtbishop.look4sat.core.domain.logbook.needsPreviewForConflicts
import com.rtbishop.look4sat.core.domain.logbook.officialSatelliteName import com.rtbishop.look4sat.core.domain.logbook.officialSatelliteName
import com.rtbishop.look4sat.core.domain.logbook.satelliteIdentity import com.rtbishop.look4sat.core.domain.logbook.satelliteIdentity
import com.rtbishop.look4sat.core.domain.logbook.unavailableUploadSummary import com.rtbishop.look4sat.core.domain.logbook.unavailableUploadSummary
@@ -286,7 +287,10 @@ class LogViewModel(
// LoTW-imported confirmations are the feedback side. // LoTW-imported confirmations are the feedback side.
val pending = all.filter { !it.lotwConfirmed && !it.lotwUploaded && it.status == QsoStatus.COMPLETE } val pending = all.filter { !it.lotwConfirmed && !it.lotwUploaded && it.status == QsoStatus.COMPLETE }
val audit = lotwUploadRepository.audit(pending) val audit = lotwUploadRepository.audit(pending)
if (audit.pending == 0) { // A batch held back only by callsign conflicts still gets the preview: that dialog
// carries the rewrite / use-another-certificate actions. Without this the operator
// gets a dead-end message and those records can never be uploaded.
if (audit.pending == 0 && !audit.needsPreviewForConflicts()) {
val msg = when { val msg = when {
audit.unavailable > 0 -> unavailableUploadSummary( audit.unavailable > 0 -> unavailableUploadSummary(
audit.unavailable, audit.reasons, audit.details, audit.duplicates, audit.incomplete audit.unavailable, audit.reasons, audit.details, audit.duplicates, audit.incomplete
@@ -343,7 +347,7 @@ class LogViewModel(
if (result is LoTWUploadResult.Accepted && lastUploadedIds.isNotEmpty()) { if (result is LoTWUploadResult.Accepted && lastUploadedIds.isNotEmpty()) {
// Mark the submitted QSOs as uploaded (distinct from confirmed) and stamp // Mark the submitted QSOs as uploaded (distinct from confirmed) and stamp
// the station grids this batch went out under. // the station grids this batch went out under.
qsoRepository.markUploaded(lastUploadedIds, preview.grids) qsoRepository.markUploaded(lastUploadedIds, preview.grids, preview.callsign)
lastUploadedIds = emptyList() lastUploadedIds = emptyList()
} }
val msg = when (result) { val msg = when (result) {
@@ -368,6 +372,34 @@ class LogViewModel(
fun dismissPreview() = _uiState.update { it.copy(preview = null) } fun dismissPreview() = _uiState.update { it.copy(preview = null) }
/**
* Operator chose "rewrite" on the callsign conflict: the refused records get this certificate's
* callsign so the next upload can sign them. The prepared batch is dropped — nothing was sent.
*/
fun rewriteCallsignConflicts() {
val preview = _uiState.value.preview ?: return
val ids = preview.callsignConflicts
if (ids.isEmpty()) return
viewModelScope.launch {
_uiState.update { it.copy(busy = true) }
val changed = qsoRepository.rewriteMyCallsign(ids, preview.callsign)
lotwUploadRepository.discardPreview()
lastUploadedIds = emptyList()
_uiState.update {
it.copy(busy = false, preview = null, message = "$changed record(s) rewritten to ${preview.callsign} — upload again")
}
}
}
/** Operator chose "use another certificate": that certificate is imported on the settings page. */
fun switchCertificateForConflicts() {
lotwUploadRepository.discardPreview()
lastUploadedIds = emptyList()
_uiState.update {
it.copy(preview = null, message = "Import the other callsign's certificate in Settings → LoTW upload, then upload again")
}
}
fun dismissWavelogPreview() = _uiState.update { it.copy(wavelogPreview = null) } fun dismissWavelogPreview() = _uiState.update { it.copy(wavelogPreview = null) }
/** /**
@@ -326,9 +326,52 @@ private fun SatellitesPreview() {
@Composable @Composable
private fun SatellitesCard(items: List<SatItem>, onSelected: (Int, Boolean) -> Unit) { private fun SatellitesCard(items: List<SatItem>, onSelected: (Int, Boolean) -> Unit) {
val selectedItems = items.filter { it.isSelected }
val availableItems = items.filterNot { it.isSelected }
LazyVerticalGrid(columns = GridCells.Adaptive(320.dp)) { LazyVerticalGrid(columns = GridCells.Adaptive(320.dp)) {
items(items = items, key = { item -> item.catnum }) { entry -> stickyHeader(key = "sat_summary_header") {
Satellite(entry, onSelected, Modifier.animateItem()) SummaryHeader(
selectedText = stringResource(R.string.sat_group_selected_count, selectedItems.size),
availableText = stringResource(R.string.sat_group_available_count, availableItems.size)
)
}
if (selectedItems.isNotEmpty()) {
items(items = selectedItems, key = { item -> item.catnum }) { entry ->
Satellite(entry, onSelected, Modifier.animateItem())
}
}
if (availableItems.isNotEmpty()) {
items(items = availableItems, key = { item -> item.catnum }) { entry ->
Satellite(entry, onSelected, Modifier.animateItem())
}
} }
} }
} }
@Composable
private fun SummaryHeader(selectedText: String, availableText: String) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier
.fillMaxWidth()
.background(MaterialTheme.colorScheme.surfaceContainerHighest)
.padding(horizontal = 12.dp, vertical = 4.dp)
) {
Text(
text = selectedText,
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = availableText,
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
@@ -103,6 +103,10 @@ fun LogbookDialog(
onDismissMessage: () -> Unit, onDismissMessage: () -> Unit,
onIgnorePositionWarning: () -> Unit = {}, onIgnorePositionWarning: () -> Unit = {},
onFixGrid: (List<String>) -> Unit = {}, onFixGrid: (List<String>) -> Unit = {},
/** Rewrite the records whose own callsign differs from the certificate's. */
onRewriteCallsign: () -> Unit = {},
/** Leave the upload to import a different certificate instead. */
onSwitchCertificate: () -> Unit = {},
selectionMode: Boolean = false, selectionMode: Boolean = false,
selectedIds: Set<Long> = emptySet(), selectedIds: Set<Long> = emptySet(),
onStartSelection: (Long) -> Unit = {}, onStartSelection: (Long) -> Unit = {},
@@ -246,7 +250,9 @@ fun LogbookDialog(
preview = preview, preview = preview,
busy = uploadBusy, busy = uploadBusy,
onConfirm = onConfirmUpload, onConfirm = onConfirmUpload,
onDismiss = onDismissPreview onDismiss = onDismissPreview,
onRewriteCallsign = onRewriteCallsign,
onSwitchCertificate = onSwitchCertificate
) )
} }
if (uploadMessage.isNotBlank()) { if (uploadMessage.isNotBlank()) {
@@ -280,7 +286,9 @@ private fun LogbookUploadPreviewDialog(
preview: com.rtbishop.look4sat.core.domain.repository.LoTWUploadPreview, preview: com.rtbishop.look4sat.core.domain.repository.LoTWUploadPreview,
busy: Boolean, busy: Boolean,
onConfirm: () -> Unit, onConfirm: () -> Unit,
onDismiss: () -> Unit onDismiss: () -> Unit,
onRewriteCallsign: () -> Unit,
onSwitchCertificate: () -> Unit
) { ) {
AlertDialog( AlertDialog(
onDismissRequest = onDismiss, onDismissRequest = onDismiss,
@@ -291,7 +299,9 @@ private fun LogbookUploadPreviewDialog(
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text("${preview.callsign} DXCC ${preview.dxcc} Grid ${preview.grid}", fontSize = 13.sp) Text("${preview.callsign} DXCC ${preview.dxcc} Grid ${preview.grid}", fontSize = 13.sp)
Text( Text(
"${preview.count} QSO(s) · ${preview.firstUtc} – ${preview.lastUtc}", // A blocked batch (shown for its conflict actions) has no date range to show.
if (preview.firstUtc.isBlank()) "${preview.count} QSO(s)"
else "${preview.count} QSO(s) · ${preview.firstUtc} – ${preview.lastUtc}",
fontSize = 13.sp fontSize = 13.sp
) )
if (preview.skipped > 0 || preview.unknownSkipped > 0 || preview.unavailableSkipped > 0) { if (preview.skipped > 0 || preview.unknownSkipped > 0 || preview.unavailableSkipped > 0) {
@@ -319,11 +329,39 @@ private fun LogbookUploadPreviewDialog(
color = MaterialTheme.colorScheme.primary color = MaterialTheme.colorScheme.primary
) )
} }
if (preview.missingCallsign > 0) {
// Logged while no certificate was installed; signed with the certificate's
// callsign instead of being refused.
Text(
stringResource(R.string.prefs_logbook_callsign_missing, preview.missingCallsign, preview.callsign),
fontSize = 12.sp,
color = MaterialTheme.colorScheme.primary
)
}
if (preview.callsignConflicts.isNotEmpty()) {
// These name another callsign than the certificate: the operator either rewrites
// them with the certificate's callsign or uploads them under that other one.
Text(
stringResource(R.string.prefs_logbook_callsign_different, preview.callsignConflicts.size, preview.callsign),
fontSize = 12.sp,
color = MaterialTheme.colorScheme.error
)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
TextButton(onClick = onRewriteCallsign, enabled = !busy) {
Text(stringResource(R.string.prefs_logbook_callsign_rewrite, preview.callsign), fontSize = 12.sp)
}
TextButton(onClick = onSwitchCertificate, enabled = !busy) {
Text(stringResource(R.string.prefs_logbook_callsign_switch), fontSize = 12.sp)
}
}
}
Text(preview.contacts.joinToString("\n") { it }, fontSize = 12.sp, maxLines = 8) Text(preview.contacts.joinToString("\n") { it }, fontSize = 12.sp, maxLines = 8)
} }
}, },
confirmButton = { confirmButton = {
TextButton(onClick = onConfirm, enabled = !busy) { // Nothing uploadable (a conflict-only batch): the actions above are the way out, so the
// upload button stays disabled instead of failing with an expired preview.
TextButton(onClick = onConfirm, enabled = !busy && preview.count > 0) {
Text(stringResource(R.string.prefs_logbook_upload_confirm)) Text(stringResource(R.string.prefs_logbook_upload_confirm))
} }
}, },
@@ -308,6 +308,15 @@ private fun SettingsScreen(
onConfirmUpload = { onAction(SettingsAction.ConfirmLogbookUpload) }, onConfirmUpload = { onAction(SettingsAction.ConfirmLogbookUpload) },
onDismissPreview = { onAction(SettingsAction.DismissLogbookPreview) }, onDismissPreview = { onAction(SettingsAction.DismissLogbookPreview) },
onDismissMessage = { onAction(SettingsAction.ClearLogbookMessage) }, onDismissMessage = { onAction(SettingsAction.ClearLogbookMessage) },
onRewriteCallsign = { onAction(SettingsAction.RewriteLogbookCallsigns) },
onSwitchCertificate = {
// The other callsign's certificate lives in the LoTW upload settings: leave the
// logbook and open that dialog.
onAction(SettingsAction.DismissLogbookPreview)
onAction(SettingsAction.ExitLogbookSelection)
dialogs.logbook = false
dialogs.lotwUpload = true
},
onIgnorePositionWarning = { onAction(SettingsAction.IgnoreLogbookPositionWarning) }, onIgnorePositionWarning = { onAction(SettingsAction.IgnoreLogbookPositionWarning) },
onFixGrid = { grids -> onFixGrid = { grids ->
onAction(SettingsAction.AbandonLogbookForGridFix) onAction(SettingsAction.AbandonLogbookForGridFix)
@@ -193,6 +193,7 @@ sealed interface SettingsAction {
data object PrepareLogbookUpload : SettingsAction data object PrepareLogbookUpload : SettingsAction
data object ConfirmLogbookUpload : SettingsAction data object ConfirmLogbookUpload : SettingsAction
data object DismissLogbookPreview : SettingsAction data object DismissLogbookPreview : SettingsAction
data object RewriteLogbookCallsigns : SettingsAction
/** The operator acknowledged the position mismatch and wants to upload anyway. */ /** The operator acknowledged the position mismatch and wants to upload anyway. */
data object IgnoreLogbookPositionWarning : SettingsAction data object IgnoreLogbookPositionWarning : SettingsAction
/** The operator chose to fix the station location first; the preview is discarded. */ /** The operator chose to fix the station location first; the preview is discarded. */
@@ -46,6 +46,7 @@ import com.rtbishop.look4sat.core.domain.repository.WavelogUploadOutcome
import com.rtbishop.look4sat.core.domain.repository.WavelogUploadPreview import com.rtbishop.look4sat.core.domain.repository.WavelogUploadPreview
import com.rtbishop.look4sat.core.domain.usecase.IShowToast import com.rtbishop.look4sat.core.domain.usecase.IShowToast
import com.rtbishop.look4sat.core.domain.utility.VersionComparator import com.rtbishop.look4sat.core.domain.utility.VersionComparator
import com.rtbishop.look4sat.core.domain.logbook.needsPreviewForConflicts
import com.rtbishop.look4sat.core.domain.logbook.resubmitCandidates import com.rtbishop.look4sat.core.domain.logbook.resubmitCandidates
import com.rtbishop.look4sat.core.domain.logbook.toConfirmedRecord import com.rtbishop.look4sat.core.domain.logbook.toConfirmedRecord
import com.rtbishop.look4sat.core.domain.logbook.unavailableUploadSummary import com.rtbishop.look4sat.core.domain.logbook.unavailableUploadSummary
@@ -257,6 +258,7 @@ class SettingsViewModel(
SettingsAction.PrepareLogbookUpload -> prepareLogbookUpload() SettingsAction.PrepareLogbookUpload -> prepareLogbookUpload()
SettingsAction.ConfirmLogbookUpload -> confirmLogbookUpload() SettingsAction.ConfirmLogbookUpload -> confirmLogbookUpload()
SettingsAction.DismissLogbookPreview -> dismissLogbookPreview() SettingsAction.DismissLogbookPreview -> dismissLogbookPreview()
SettingsAction.RewriteLogbookCallsigns -> rewriteLogbookCallsigns()
SettingsAction.IgnoreLogbookPositionWarning -> ignoreLogbookPositionWarning() SettingsAction.IgnoreLogbookPositionWarning -> ignoreLogbookPositionWarning()
SettingsAction.AbandonLogbookForGridFix -> abandonLogbookForGridFix() SettingsAction.AbandonLogbookForGridFix -> abandonLogbookForGridFix()
is SettingsAction.StartLogbookSelection -> startLogbookSelection(action.id) is SettingsAction.StartLogbookSelection -> startLogbookSelection(action.id)
@@ -575,7 +577,10 @@ class SettingsViewModel(
// LoTW-imported confirmations are the feedback side. // LoTW-imported confirmations are the feedback side.
val pending = all.filter { !it.lotwConfirmed && !it.lotwUploaded && it.status == com.rtbishop.look4sat.core.domain.logbook.QsoStatus.COMPLETE } val pending = all.filter { !it.lotwConfirmed && !it.lotwUploaded && it.status == com.rtbishop.look4sat.core.domain.logbook.QsoStatus.COMPLETE }
val audit = lotwUploadRepository.audit(pending) val audit = lotwUploadRepository.audit(pending)
if (audit.pending == 0) { // A batch held back only by callsign conflicts still gets the preview: that dialog
// carries the rewrite / use-another-certificate actions. Without this the operator
// gets a dead-end message and those records can never be uploaded.
if (audit.pending == 0 && !audit.needsPreviewForConflicts()) {
val msg = when { val msg = when {
audit.unavailable > 0 -> com.rtbishop.look4sat.core.domain.logbook.unavailableUploadSummary( audit.unavailable > 0 -> com.rtbishop.look4sat.core.domain.logbook.unavailableUploadSummary(
audit.unavailable, audit.reasons, audit.details, audit.duplicates, audit.incomplete audit.unavailable, audit.reasons, audit.details, audit.duplicates, audit.incomplete
@@ -648,7 +653,7 @@ class SettingsViewModel(
val result = lotwUploadRepository.upload(preview.id) val result = lotwUploadRepository.upload(preview.id)
val accepted = result is com.rtbishop.look4sat.core.domain.repository.LoTWUploadResult.Accepted val accepted = result is com.rtbishop.look4sat.core.domain.repository.LoTWUploadResult.Accepted
if (accepted && lastLogbookUploadIds.isNotEmpty()) { if (accepted && lastLogbookUploadIds.isNotEmpty()) {
qsoRepository.markUploaded(lastLogbookUploadIds, preview.grids) qsoRepository.markUploaded(lastLogbookUploadIds, preview.grids, preview.callsign)
lastLogbookUploadIds = emptyList() lastLogbookUploadIds = emptyList()
} }
val msg = when (result) { val msg = when (result) {
@@ -673,6 +678,29 @@ class SettingsViewModel(
private fun dismissLogbookPreview() = _uiState.update { it.copy(logbookPreview = null, logbookWavelogPreview = null) } private fun dismissLogbookPreview() = _uiState.update { it.copy(logbookPreview = null, logbookWavelogPreview = null) }
/**
* Operator chose "rewrite" on the callsign conflict: the refused records get this certificate's
* callsign so the next upload can sign them. The batch is discarded — nothing was uploaded.
*/
private fun rewriteLogbookCallsigns() {
val preview = _uiState.value.logbookPreview ?: return
val ids = preview.callsignConflicts
if (ids.isEmpty()) return
viewModelScope.launch {
_uiState.update { it.copy(logbookUploadBusy = true) }
val changed = qsoRepository.rewriteMyCallsign(ids, preview.callsign)
lotwUploadRepository.discardPreview()
lastLogbookUploadIds = emptyList()
_uiState.update {
it.copy(
logbookUploadBusy = false,
logbookPreview = null,
logbookUploadMessage = "$changed record(s) rewritten to ${preview.callsign} — upload again"
)
}
}
}
/** Operator chose "ignore" on the position check: keep the prepared preview. */ /** Operator chose "ignore" on the position check: keep the prepared preview. */
private fun ignoreLogbookPositionWarning() = _uiState.update { it.copy(logbookPositionWarning = null) } private fun ignoreLogbookPositionWarning() = _uiState.update { it.copy(logbookPositionWarning = null) }
+2 -2
View File
@@ -1,8 +1,8 @@
[versions] [versions]
#noinspection UnusedVersionCatalogEntry #noinspection UnusedVersionCatalogEntry
appVersionCode = "574" appVersionCode = "580"
#noinspection UnusedVersionCatalogEntry #noinspection UnusedVersionCatalogEntry
appVersionName = "4.4.8-ba7opf.10.1" appVersionName = "4.4.8-ba7opf.11.5"
#noinspection UnusedVersionCatalogEntry #noinspection UnusedVersionCatalogEntry
compileSdk = "37" compileSdk = "37"
#noinspection UnusedVersionCatalogEntry #noinspection UnusedVersionCatalogEntry