Compare commits

..
Author SHA1 Message Date
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
24 changed files with 645 additions and 75 deletions

No files matched your search

@@ -33,53 +33,38 @@ import java.util.TimeZone
internal data class LoTWKeyMaterial(val key: PrivateKey, val certificate: X509Certificate, val info: LoTWCertificate) {
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)
val key: PrivateKey
val cert: X509Certificate
val store = try {
KeyStore.getInstance("PKCS12").apply { bytes.inputStream().use { load(it, password) } }
} catch (_: Exception) {
// Modern TQSL / OpenSSL 3 exports use PBES2+AES-CBC which Android's legacy
// Bouncy Castle parser cannot read. Fall back to our own PBES2 reader; if
// that fails too, report the real reason (format vs password).
if (isPbes2(bytes)) {
try {
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
val outcome = platformStore(bytes, password)?.let { platformKeyAndCertificate(it, password) }
if (outcome is PlatformOutcome.Ok) {
key = outcome.key
cert = outcome.certificate
} else if (isPbes2(bytes)) {
val parsed = readWithPbes2Reader(bytes, password)
key = parsed.first
cert = parsed.second
} else {
val ks = store as KeyStore
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)
fail((outcome as? PlatformOutcome.Unusable)?.problem ?: LoTWProblem.CERTIFICATE_PASSWORD)
}
if (key.algorithm != "RSA" || cert.publicKey.algorithm != "RSA") fail(LoTWProblem.CERTIFICATE_INVALID)
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)
}
/** 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
* algorithm of OpenSSL 3 / modern TQSL. Android's legacy BC parser can't read it. */
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)
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)
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")
// 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
@@ -267,16 +267,29 @@ class LoTWUploadRepository internal constructor(
var duplicates = 0
val unique = hashSetOf<String>()
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 ->
coroutineContext.ensureActive()
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 {
signing.signer.contact(record, signing.key, signing.location, now())
signing.signer.contact(signable, signing.key, signing.location, now())
} catch (e: LoTWOperationException) {
// One un-signable record must not abort the whole batch:
// skip it, count the real reason, and let the rest upload.
unavailable++
reasons[e.reason] = (reasons[e.reason] ?: 0) + 1
if (e.reason == LoTWProblem.CALLSIGN_MISMATCH) callsignConflicts += record.id
return@mapNotNull null
}
val previous = ledger[contact.fingerprint]
@@ -299,6 +312,7 @@ class LoTWUploadRepository internal constructor(
signing.location["MY_VUCC_GRIDS"]?.split(',')
?.map(String::trim)?.filter(String::isNotBlank)?.let(::addAll)
}.distinct()
val submittedIds = contacts.map { it.record.id }
val preview = LoTWUploadPreview(
UUID.randomUUID().toString(), signing.key.info.callsign, signing.key.info.dxcc, signing.location.getValue("GRIDSQUARE"),
contacts.size, skipped,
@@ -309,9 +323,12 @@ class LoTWUploadRepository internal constructor(
unavailable,
reasons.toMap(),
duplicates,
contacts.map { it.record.id },
submittedIds,
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(
preview,
@@ -82,18 +82,39 @@ class QsoRepository(
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
dao.markUploaded(ids)
// 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.
val normalized = grids.map { it.trim().uppercase(Locale.US).take(4) }
.filter { it.length >= 4 }.distinct().sorted()
if (normalized.isEmpty()) return@withContext
val stamped = ids.mapNotNull { id ->
dao.find(id)?.toDomain()?.takeIf { it.vuccGrids != normalized }?.copy(vuccGrids = normalized)
if (normalized.isNotEmpty()) {
val stamped = ids.mapNotNull { id ->
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) {
@@ -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
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.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 org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
@@ -39,7 +44,9 @@ class LoTWUploadRepositoryPreviewTest {
private class MemStorage : LoTWStorage {
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 delete(name: String) { files.remove(name) }
}
@@ -79,4 +86,104 @@ class LoTWUploadRepositoryPreviewTest {
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)
}
@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
fun markWavelogUploaded_setsTheFlagAndTheStationStamp() = runBlocking {
val id = dao.save(loggedInApp("SO-50", lotwUploaded = false).toEntity())
@@ -22,7 +22,14 @@ interface IQsoRepository {
* 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.
*/
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
@@ -18,6 +18,7 @@
package com.rtbishop.look4sat.core.domain.logbook
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.
@@ -69,3 +70,14 @@ fun unavailableUploadSummary(
val detail = parts.joinToString(", ").ifBlank { "check the logbook" }
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
@@ -107,7 +107,13 @@ data class LoTWUploadPreview(
val grids: List<String> = emptyList(),
/** 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. */
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
@@ -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)
}
)
}
@@ -216,6 +216,10 @@
<string name="prefs_logbook_selected">已选 %1$d 条</string>
<string name="prefs_logbook_select_hint">长按记录行可多选重传</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_all">全部</string>
<string name="prefs_logbook_upload_wavelog_title">上传到 Wavelog</string>
@@ -245,6 +245,10 @@
<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_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_all">All</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
@@ -49,6 +49,9 @@ data class AwardRegion(
val labelLon: Double,
val labelLat: Double,
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
@@ -110,6 +113,7 @@ object AwardBoundaryData {
nameEn = o.optString("name_en").takeIf { it.isNotEmpty() },
labelLon = o.optDouble("label_lon", 0.0),
labelLat = o.optDouble("label_lat", 0.0),
pfx = o.optString("pfx"),
rings = rings,
fillRings = o.optJSONArray("fill_rings")?.let { parseRings(it) } ?: emptyList(),
forceLabel = o.optBoolean("force_label", false)
@@ -778,13 +778,13 @@ private fun RegionQsoDialog(
) {
// 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
// the code is not repeated in the subtitle. DXCC carries its award prefix
// in the subtitle ("DXCC 318", same request); other awards keep the bare
// code.
// the code is not repeated in the subtitle. DXCC shows the entity's call
// prefix instead of the numeric code ("BY", user request 2026-10-06);
// other awards keep the bare code.
val isZone = type == AwardType.WAZ
val codeLabel = when {
isZone -> ""
type == AwardType.DXCC -> "DXCC ${region.code} · "
type == AwardType.DXCC -> "${region.pfx.ifBlank { region.code }} · "
else -> "${region.code} · "
}
androidx.compose.ui.window.Dialog(onDismissRequest = onDismiss) {
@@ -225,6 +225,29 @@ class DxccAssetTest {
}
}
@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.
@@ -262,7 +262,9 @@ fun LogPage(
preview = preview,
busy = logUiState.busy,
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,
busy: Boolean,
onConfirm: () -> Unit,
onDismiss: () -> Unit
onDismiss: () -> Unit,
onRewriteCallsign: () -> Unit,
onSwitchCertificate: () -> Unit
) {
AlertDialog(
onDismissRequest = onDismiss,
@@ -427,7 +431,10 @@ private fun UploadPreviewDialog(
text = {
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
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 {
timeZone = java.util.TimeZone.getTimeZone("UTC")
}.format(java.util.Date(preview.firstUtc.let {
@@ -443,7 +450,8 @@ private fun UploadPreviewDialog(
timeZone = java.util.TimeZone.getTimeZone("UTC")
}.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) {
val parts = buildList {
// 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)
}
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)
}
},
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 = {
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.displayMode
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.satelliteIdentity
import com.rtbishop.look4sat.core.domain.logbook.unavailableUploadSummary
@@ -286,7 +287,10 @@ class LogViewModel(
// LoTW-imported confirmations are the feedback side.
val pending = all.filter { !it.lotwConfirmed && !it.lotwUploaded && it.status == QsoStatus.COMPLETE }
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 {
audit.unavailable > 0 -> unavailableUploadSummary(
audit.unavailable, audit.reasons, audit.details, audit.duplicates, audit.incomplete
@@ -343,7 +347,7 @@ class LogViewModel(
if (result is LoTWUploadResult.Accepted && lastUploadedIds.isNotEmpty()) {
// Mark the submitted QSOs as uploaded (distinct from confirmed) and stamp
// the station grids this batch went out under.
qsoRepository.markUploaded(lastUploadedIds, preview.grids)
qsoRepository.markUploaded(lastUploadedIds, preview.grids, preview.callsign)
lastUploadedIds = emptyList()
}
val msg = when (result) {
@@ -368,6 +372,34 @@ class LogViewModel(
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) }
/**
@@ -103,6 +103,10 @@ fun LogbookDialog(
onDismissMessage: () -> Unit,
onIgnorePositionWarning: () -> 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,
selectedIds: Set<Long> = emptySet(),
onStartSelection: (Long) -> Unit = {},
@@ -246,7 +250,9 @@ fun LogbookDialog(
preview = preview,
busy = uploadBusy,
onConfirm = onConfirmUpload,
onDismiss = onDismissPreview
onDismiss = onDismissPreview,
onRewriteCallsign = onRewriteCallsign,
onSwitchCertificate = onSwitchCertificate
)
}
if (uploadMessage.isNotBlank()) {
@@ -280,7 +286,9 @@ private fun LogbookUploadPreviewDialog(
preview: com.rtbishop.look4sat.core.domain.repository.LoTWUploadPreview,
busy: Boolean,
onConfirm: () -> Unit,
onDismiss: () -> Unit
onDismiss: () -> Unit,
onRewriteCallsign: () -> Unit,
onSwitchCertificate: () -> Unit
) {
AlertDialog(
onDismissRequest = onDismiss,
@@ -291,7 +299,9 @@ private fun LogbookUploadPreviewDialog(
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text("${preview.callsign} DXCC ${preview.dxcc} Grid ${preview.grid}", fontSize = 13.sp)
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
)
if (preview.skipped > 0 || preview.unknownSkipped > 0 || preview.unavailableSkipped > 0) {
@@ -319,11 +329,39 @@ private fun LogbookUploadPreviewDialog(
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)
}
},
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))
}
},
@@ -308,6 +308,15 @@ private fun SettingsScreen(
onConfirmUpload = { onAction(SettingsAction.ConfirmLogbookUpload) },
onDismissPreview = { onAction(SettingsAction.DismissLogbookPreview) },
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) },
onFixGrid = { grids ->
onAction(SettingsAction.AbandonLogbookForGridFix)
@@ -193,6 +193,7 @@ sealed interface SettingsAction {
data object PrepareLogbookUpload : SettingsAction
data object ConfirmLogbookUpload : SettingsAction
data object DismissLogbookPreview : SettingsAction
data object RewriteLogbookCallsigns : SettingsAction
/** The operator acknowledged the position mismatch and wants to upload anyway. */
data object IgnoreLogbookPositionWarning : SettingsAction
/** 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.usecase.IShowToast
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.toConfirmedRecord
import com.rtbishop.look4sat.core.domain.logbook.unavailableUploadSummary
@@ -257,6 +258,7 @@ class SettingsViewModel(
SettingsAction.PrepareLogbookUpload -> prepareLogbookUpload()
SettingsAction.ConfirmLogbookUpload -> confirmLogbookUpload()
SettingsAction.DismissLogbookPreview -> dismissLogbookPreview()
SettingsAction.RewriteLogbookCallsigns -> rewriteLogbookCallsigns()
SettingsAction.IgnoreLogbookPositionWarning -> ignoreLogbookPositionWarning()
SettingsAction.AbandonLogbookForGridFix -> abandonLogbookForGridFix()
is SettingsAction.StartLogbookSelection -> startLogbookSelection(action.id)
@@ -575,7 +577,10 @@ class SettingsViewModel(
// 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 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 {
audit.unavailable > 0 -> com.rtbishop.look4sat.core.domain.logbook.unavailableUploadSummary(
audit.unavailable, audit.reasons, audit.details, audit.duplicates, audit.incomplete
@@ -648,7 +653,7 @@ class SettingsViewModel(
val result = lotwUploadRepository.upload(preview.id)
val accepted = result is com.rtbishop.look4sat.core.domain.repository.LoTWUploadResult.Accepted
if (accepted && lastLogbookUploadIds.isNotEmpty()) {
qsoRepository.markUploaded(lastLogbookUploadIds, preview.grids)
qsoRepository.markUploaded(lastLogbookUploadIds, preview.grids, preview.callsign)
lastLogbookUploadIds = emptyList()
}
val msg = when (result) {
@@ -673,6 +678,29 @@ class SettingsViewModel(
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. */
private fun ignoreLogbookPositionWarning() = _uiState.update { it.copy(logbookPositionWarning = null) }
+2 -2
View File
@@ -1,8 +1,8 @@
[versions]
#noinspection UnusedVersionCatalogEntry
appVersionCode = "575"
appVersionCode = "579"
#noinspection UnusedVersionCatalogEntry
appVersionName = "4.4.8-ba7opf.11"
appVersionName = "4.4.8-ba7opf.11.4"
#noinspection UnusedVersionCatalogEntry
compileSdk = "37"
#noinspection UnusedVersionCatalogEntry