Compare commits

...
22 Commits
Author SHA1 Message Date
atsunatsu 8d9208b063 build: release version 4.4.8-ba7opf.11.8 (583) 2026-10-10 01:53:03 +08:00
atsunatsu a0ebe14d07 fix(i18n): 繁体中文回退到简中(values-b+zh+Hant + localeFilters 补繁体地区)
Android 7+ 按脚本解析中文:无限定符的 values-zh 视为 zh-Hans,zh-TW/zh-HK/zh-MO(zh-Hant)
设备匹配不到它、掉到默认英文。新增 values-b+zh+Hant(values-zh 逐字副本,非新译文),并把
zh-rTW/zh-rHK/zh-rMO 补进 localeFilters——否则 aapt2 会把该配置从 APK 里裁掉,修复静默失效
(这也解释了 AndroidX 自带繁体资源为何一直不出现)。aapt2 只接受 zh-rTW 写法,zh-Hant 会
构建报错。加 TraditionalChineseFallbackTest 钉死两份文案一致与 filter 内容。
2026-10-10 01:52:59 +08:00
atsunatsu e162de7c19 build: release version 4.4.8-ba7opf.11.7 (582) 2026-10-09 03:05:03 +08:00
atsunatsu 63e2ea88b7 feat(satellites): AMSAT Live FM 组补 IO-86 / LAPAN-A2
Sources.amSatFmCatnums 加 IO-86 / LAPAN-A2 (40931):SatNOGS 有 V/U FM 话音转发器
"V/U FM Voice (PL 88.5Hz)" 145.880 / 435.880;R4UAB 名 "LAPAN-A2 (IO-86)"、
SatNOGS 3LE 名 "LAPAN-A2"、AMSAT 名 "IO-86"。LoTW 别名表早就有 LAPAN-A2 → IO-86
(ARRL 官方名),所以这颗星只是筛选里看不到,上传链路本来通。

同一集合共用三处:卫星页与互通页的 FM 筛选、雷达页 Log 的 useRepeater 判定
(voiceRepeater() 排序下唯一含 "voice" 的 FM 双工行命中,记账固定取
145.880 / 435.880 名义对;APRS/SSDV 两行是 AFSK、Mode X 无上行,都进不了候选)。

测试:SelectionRepoSearchTest 的 FM 虚拟类型用例扩到三颗(67291 / 98205 / 40931)。
2026-10-09 03:04:59 +08:00
atsunatsu ddca54e7e6 build: release version 4.4.8-ba7opf.11.6 (581) 2026-10-09 01:54:56 +08:00
atsunatsu e19d22714b feat(satellites): AMSAT Live FM 组补 RS95S 与 UNNE-1B
Sources.amSatFmCatnums 加两颗:RS95S / QMR-KWT-2 (67291)、UNNE-1B (98205)。
两星在 SatNOGS 都有 V/U FM 转发器("V/U FM Transponder CTCSS 67.0 Hz"
145.920/436.950,与 "Mode V/U - FM Repeater (Voice + (A)FSK)"),因此归入
卫星页「AMSAT Live FM」虚拟类型筛选。该集合是三处共用的一份口径:卫星页与
互通页的 FM 筛选、以及雷达页 Log(FM 星按名义 voice repeater 频率对记账,
两颗星都有该行,不会回落到算器/所选频点)。

98205 是 SatNOGS 的分析编号(国际编号 26227BL,T-18 由 OTV OSSIE 释放),
Space-Track 正式编目后会换号,届时需回改这一条;已在 KDoc 标注,ephem-watch
cron(6b1544af4470)会盯到编号变化。

测试:SelectionRepoSearchTest 新增一例,断言 FM 虚拟类型命中 67291 与 98205
(全量 ./gradlew test 通过)。
2026-10-09 01:47:52 +08:00
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
atsunatsu 7a7d742b54 build: release version 4.4.8-ba7opf.10.1 (574) 2026-10-06 03:19:28 +08:00
atsunatsu 146d47455f feat(map): 海上群岛按整体包络命中(点岛间海面即弹实体详情)
- 新增第三类命中目标:≥2 分离环且单岛跨度 ≤2.5° 的散布群岛,整体包络即命中区
  (±40px 触点余量),点岛链之间的海面归该实体
- 优先级:小实体 80px 目标 > 陆地多边形点内 > 群岛包络;主大陆+零星岛(日/英/印尼)
  不进群岛模式,行为不变
- 包络按单一展开经度框计算,反经线群岛(斐济式)不横扫
- 测试 6→10:包络命中、包络内小实体优先、主大陆排除、反经线包络连续
2026-10-06 03:18:57 +08:00
49 changed files with 1859 additions and 122 deletions

No files matched your search

@@ -74,7 +74,13 @@ internal fun Project.setupAndroidApp() {
}
androidResources {
generateLocaleConfig = true
localeFilters.addAll(listOf("en", "es", "ru", "si", "tr", "uk", "zh"))
// "zh" alone is stripped down to the Hans config: without the Traditional
// region entries below, values-b+zh+Hant is filtered out of the APK and a
// zh-TW / zh-HK / zh-MO device falls back to English. aapt2 only accepts
// the legacy "zh-rTW" spelling here ("zh-Hant" fails the build).
localeFilters.addAll(
listOf("en", "es", "ru", "si", "tr", "uk", "zh", "zh-rTW", "zh-rHK", "zh-rMO")
)
}
packaging { resources { excludes += listOf("META-INF/*") } }
}
Binary file not shown.
@@ -36,7 +36,7 @@ import java.util.Locale
import java.util.zip.GZIPInputStream
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) {
private val root = input.use { source -> GZIPInputStream(source).use { gzip ->
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) {
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) {
@@ -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 ->
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
@@ -87,6 +87,7 @@ class LoTWConfigTest {
assertEquals("AO-91", config.resolveSatellite("FOX-1B"))
assertEquals("IO-86", config.resolveSatellite("LAPAN-A2"))
assertEquals("RS-44", config.resolveSatellite("DOSAAF-85"))
assertEquals("RS95S", config.resolveSatellite("QMR-KWT 2"))
assertEquals("BO-102", config.resolveSatellite("CAS-7B"))
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
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())
@@ -124,6 +124,24 @@ class SelectionRepoSearchTest {
assertEquals(setOf(25544, 61781), results.map { it.catnum }.toSet())
}
@Test
fun `FM virtual type includes the BA7OPF RS95S UNNE-1B and IO-86 additions`() = runTest {
// RS95S / QMR-KWT-2 (67291), UNNE-1B (98205) and IO-86 / LAPAN-A2 (40931)
// joined the hardcoded AMSAT Live FM set on 2026-10-09; each carries a V/U
// FM repeater in SatNOGS ("V/U FM Transponder CTCSS 67.0 Hz",
// "Mode V/U - FM Repeater (Voice + (A)FSK)", "V/U FM Voice (PL 88.5Hz)").
// sampleItems contributes ISS (25544) only.
val items = sampleItems + listOf(
SatItem(catnum = 67291, name = "QMR-KWT-2 (RS95S)"),
SatItem(catnum = 98205, name = "UNNE-1B"),
SatItem(catnum = 40931, name = "LAPAN-A2 (IO-86)")
)
val repo = createRepo(items)
repo.setTypes(listOf("AMSAT Live FM"))
val results = repo.getEntriesFlow().first()
assertEquals(setOf(25544, 67291, 98205, 40931), results.map { it.catnum }.toSet())
}
@Test
fun `Linear virtual type shows only hardcoded linear satellites`() = runTest {
val repo = createRepo(sampleItems)
@@ -284,6 +302,24 @@ class SelectionRepoSearchTest {
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(
items: List<SatItem>,
sstvIds: List<Int> = emptyList()
@@ -14,9 +14,15 @@ import java.util.Date
import java.util.Locale
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 {
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 {
append(field("ADIF_VER", "3.1.7"))
@@ -36,7 +42,17 @@ object AdifCodec {
appendOptional("MODE", record.mode)
appendOptional("SUBMODE", record.submode)
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)
// 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_RCVD", record.receivedReport)
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.lotwReceived) append(field("LOTW_QSL_SENT", "Y"))
appendOptional("LOTW_QSLRDATE", record.lotwQslDate)
appendOptional("VUCC_GRIDS", record.vuccGrids.joinToString(","))
record.dxcc?.let { append(field("DXCC", it.toString())) }
appendOptional("COUNTRY", record.country)
record.cqZone?.let { append(field("CQZ", it.toString())) }
@@ -80,7 +95,9 @@ object AdifCodec {
theirCallsign = call,
myCallsign = values["STATION_CALLSIGN"].orEmpty().uppercase(Locale.US),
theirGrid = values["GRIDSQUARE"].orEmpty().uppercase(Locale.US),
theirVuccGrids = decodeGrids(values["VUCC_GRIDS"]),
myGrid = values["MY_GRIDSQUARE"].orEmpty().uppercase(Locale.US),
vuccGrids = decodeGrids(values["MY_VUCC_GRIDS"]),
sentReport = values["RST_SENT"].orEmpty(),
receivedReport = values["RST_RCVD"].orEmpty(),
txFrequencyHz = mhzToHz(values["FREQ"]),
@@ -100,8 +117,6 @@ object AdifCodec {
lotwConfirmed = values["LOTW_QSL_RCVD"].equals("Y", true),
lotwReceived = values["LOTW_QSL_SENT"].equals("Y", true),
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(),
country = values["COUNTRY"].orEmpty(),
cqZone = values["CQZ"]?.toIntOrNull()?.takeIf { it in 1..40 },
@@ -140,6 +155,12 @@ object AdifCodec {
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) {
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
* 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
@@ -34,6 +34,8 @@ import java.util.Locale
* - 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),
* - 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
* between objects and would sign the wrong satellite. LoTWConfigTest asserts that every target
* 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",
"OSCAR7" to "AO-7", "PACSAT" to "AO-16", "PARKINSONSAT" to "NO-84",
"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",
"RS0ISS" to "ARISS", "RS14" to "AO-21", "RS15" to "RS-15",
"RS44" to "RS-44", "RS64S" to "AO-123", "SAUDISAT1C" to "SO-50",
"SO121" to "SO-121", "SO124" to "SO-124", "SO125" to "SO-125",
"RS44" to "RS-44", "RS64S" to "AO-123", "RS95S" to "RS95S",
"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",
"TAURUS1" to "TAURUS", "TEVEL21" to "TEV2-1", "TEVEL22" to "TEV2-2",
"TEVEL23" to "TEV2-3", "TEVEL24" to "TEV2-4", "TEVEL25" to "TEV2-5",
@@ -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
@@ -48,6 +48,8 @@ data class QsoRecord(
val wavelogStation: String = "",
val lotwReceived: Boolean = false,
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 dxcc: Int? = null,
val country: String = "",
@@ -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
@@ -50,11 +50,30 @@ object Sources {
)
/**
* Hardcoded AMSAT Live FM satellites (NORAD catnums):
* SO-50 (27607), ISS ZARYA (25544), AO-123 ASRTU-1 (61781).
* SO-50 (27607), ISS ZARYA (25544), AO-123 ASRTU-1 (61781),
* RS95S/QMR-KWT-2 (67291), UNNE-1B (98205).
* Replaces the AMSAT live-page fetch: stable on any network, no
* sync-time dependency, no stale-list or timeout failure modes.
*
* BA7OPF additions (2026-10-09):
* - RS95S / QMR-KWT-2 (67291): V/U FM transponder 145.920/436.950 CTCSS 67.0
* (SatNOGS "QMR-KWT-2 (RS95S)", R4UAB "QMR-KWT-2 (RS95S)", AMSAT "RS95S
* (QMR-KWT-2)"; the LoTW alias table maps the tracker name to ARRL's "RS95S").
* - UNNE-1B (98205): AMSAT-EA 1.5P PocketQube released from the T-18 OTV
* "OSSIE" with a V/U FM repeater ("Voice + (A)FSK").
* - IO-86 / LAPAN-A2 (40931): V/U FM voice repeater 145.880/435.880 PL 88.5
* (SatNOGS "V/U FM Voice (PL 88.5Hz)"; R4UAB "LAPAN-A2 (IO-86)", SatNOGS
* 3LE "LAPAN-A2", AMSAT "IO-86"; the LoTW alias table maps LAPAN-A2 to
* ARRL's "IO-86"). This fork's first TLE source keeps its elements fresh.
* ⚠ 98205 is SatNOGS' analysis number (designator 26227BL) for the freshly
* released object — Space-Track assigns the permanent catalogue number later,
* so this entry must be re-pointed then; the ephem-watch cron job tracks it.
*
* The set is shared: the satellite page / mutual page "AMSAT Live FM" filters
* and the radar Log page (FM satellites are logged on the fixed nominal
* voice-repeater pair).
*/
val amSatFmCatnums = setOf(27607, 25544, 61781)
val amSatFmCatnums = setOf(27607, 25544, 61781, 67291, 98205, 40931)
/**
* Hardcoded AMSAT Live linear (SSB/CW) satellites:
@@ -27,7 +27,7 @@ import org.junit.Test
/** Alias table that maps tracker catalogue names onto the names ARRL's config.tq6 uses. */
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
fun `lookup maps SatNOGS catalogue names to ARRL names`() {
@@ -37,6 +37,7 @@ class LoTWSatelliteAliasesTest {
assertEquals("AO-91", LoTWSatelliteAliases.lookup("FOX-1B"))
assertEquals("RS-44", LoTWSatelliteAliases.lookup("DOSAAF-85"))
assertEquals("IO-117", LoTWSatelliteAliases.lookup("GREENCUBE"))
assertEquals("RS95S", LoTWSatelliteAliases.lookup("QMR-KWT 2"))
}
@Test
@@ -59,6 +60,8 @@ class LoTWSatelliteAliasesTest {
assertEquals("SO-50", LoTWSatelliteAliases.resolve("SAUDISAT 1C (SO-50)", catalogue))
assertEquals("ARISS", LoTWSatelliteAliases.resolve("ISS (ZARYA)", 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
@@ -10,6 +10,7 @@
package com.rtbishop.look4sat.core.domain.logbook
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
@@ -22,8 +23,10 @@ class AdifCodecTest {
endUtcMillis = 1_700_000_000_100L,
theirCallsign = "BG5JVM",
myCallsign = "BA7OPF",
theirGrid = "OM60",
theirGrid = "EN52",
theirVuccGrids = listOf("EN52", "EN53"),
myGrid = "OL62TI",
vuccGrids = listOf("OL62", "OL61"),
sentReport = "59",
receivedReport = "59",
txFrequencyHz = 145_850_000L,
@@ -36,8 +39,7 @@ class AdifCodecTest {
propagationMode = "SAT",
status = QsoStatus.COMPLETE,
lotwConfirmed = true,
lotwQslDate = "2026-09-20",
vuccGrids = listOf("OM60", "OM50")
lotwQslDate = "2026-09-20"
)
val decoded = AdifCodec.decode(AdifCodec.encode(listOf(record))).single()
assertEquals(record.startUtcMillis, decoded.startUtcMillis)
@@ -47,12 +49,18 @@ class AdifCodecTest {
assertEquals("2M", decoded.band)
assertEquals("70CM", decoded.rxBand)
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
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 = """
<EOH>
<CALL:6>BH6RJD
@@ -64,8 +72,9 @@ class AdifCodecTest {
<BAND:3>2M
<BAND_RX:4>70CM
<GRIDSQUARE:4>OM60
<MY_GRIDSQUARE:8>OM60IL70
<VUCC_GRIDS:9>OM60,OM50
<MY_GRIDSQUARE:6>OL62TI
<MY_VUCC_GRIDS:9>OL62,OL61
<STATION_CALLSIGN:6>BH6RJD
<DXCC:3>318
<COUNTRY:5>China
@@ -78,12 +87,27 @@ class AdifCodecTest {
assertEquals("BH6RJD", record.theirCallsign)
assertEquals("SO-50", record.satelliteName)
assertEquals("FM", record.displayMode)
assertEquals("OM60IL70", record.myGrid)
assertEquals(listOf("OM60", "OM50"), record.vuccGrids)
assertEquals("OM60", record.theirGrid)
assertEquals(listOf("OM60", "OM50"), record.theirVuccGrids)
assertEquals("OL62TI", record.myGrid)
assertEquals(listOf("OL62", "OL61"), record.vuccGrids)
assertTrue(record.lotwConfirmed)
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
fun encodeOmitsStationCallsignForWavelog() {
val record = QsoRecord(
@@ -99,4 +123,44 @@ class AdifCodecTest {
assertTrue(adif.contains("<CALL:6>N0CALL"))
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)
}
)
}
@@ -0,0 +1,529 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!--
Traditional-Chinese (zh-Hant) devices fall back to the Simplified Chinese
strings in values-zh/ instead of the English default. Android 7+ resolves
Chinese by script, so an unqualified values-zh is treated as zh-Hans and is
never matched by a zh-TW / zh-HK / zh-MO (Hant) device.
This file is a verbatim copy of values-zh/strings.xml — do NOT translate it
here. Edit values-zh/strings.xml and copy the change over; the unit test
TraditionalChineseFallbackTest fails if the two files ever diverge.
-->
<!-- Commmon -->
<string name="btn_accept">确定</string>
<string name="btn_cancel">取消</string>
<string name="empty_list_message">没有数据可显示</string>
<!-- Navigation -->
<string name="nav_sat">卫星</string>
<string name="nav_pass">过境</string>
<string name="nav_radar">雷达</string>
<string name="nav_mutual">匹配</string>
<string name="nav_map">地图</string>
<string name="nav_prefs">设置</string>
<!-- Satellites screen -->
<string name="sat_type_hint">类型:%s</string>
<string name="sat_type_title">选择卫星类型</string>
<string name="sat_search_hint">Id - 名称</string>
<string name="sat_search_clear">清空</string>
<string name="sat_clear_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_warning_title">警告\!</string>
<string name="sat_warning_message">此应用收录了超过9000颗卫星信息\n同时追踪所有卫星并不现实\n建议始终通过搜索和分类筛选功能仅勾选您感兴趣的卫星</string>
<!-- Passes screen -->
<string name="pass_filter_title">筛选过境</string>
<string name="pass_filter_elev">仰角值</string>
<string name="pass_filter_hours">提前时间</string>
<string name="pass_filter_history_hours">历史过境时间</string>
<string name="pass_filter_aos_time">AOS 时间段</string>
<string name="pass_filter_invert_time">反选 AOS 时间段</string>
<string name="pass_filter_deep_space">DeepSpace(周期 &gt;225分钟)</string>
<string name="pass_modes_title">选择调制类型</string>
<string name="pass_elevation">仰角:%.1f°</string>
<string name="pass_altitude">高度:%d km</string>
<string name="pass_empty_list_message">请确保您的过滤器设置正确,并且已选择卫星</string>
<string name="pass_error_message">没有即将过境的卫星</string>
<string name="pass_welcome_title">欢迎来到 Look4Sat\!</string>
<string name="pass_welcome_message">请务必在设置中通过GPS、经纬度或QTH定位您的位置\n建议至少每周更新一次数据库,以确保预测结果的准确性</string>
<!-- AMSAT Status screen -->
<string name="nav_amsat">AMSAT</string>
<string name="amsat_title">AMSAT 卫星状态</string>
<string name="amsat_refresh">刷新</string>
<string name="amsat_retry">重试</string>
<string name="amsat_updated">更新于:</string>
<string name="amsat_active">活跃</string>
<string name="amsat_tlm">遥测/信标</string>
<string name="amsat_not_heard">未听到</string>
<string name="amsat_conflict">冲突</string>
<string name="amsat_name">名称</string>
<string name="amsat_load_failed">加载失败 - 检查网络后重试</string>
<string name="amsat_no_reports">此时间段无报告</string>
<string name="amsat_close">关闭</string>
<string name="amsat_upload">上传</string>
<string name="amsat_upload_back">返回</string>
<string name="amsat_upload_report">上传报告到 AMSAT</string>
<string name="amsat_upload_status">状态</string>
<string name="amsat_upload_callsign">呼号</string>
<string name="amsat_upload_grid">网格</string>
<string name="amsat_upload_time_now">时间:当前 UTC %s</string>
<string name="amsat_upload_submit">提交到 AMSAT</string>
<string name="amsat_upload_confirm">确认上传</string>
<string name="amsat_upload_confirm_title">确认上传到 AMSAT</string>
<string name="amsat_upload_confirm_message">这条公开报告将上传到 AMSAT。\n卫星:%1$s\n状态:%2$s\n呼号:%3$s\n网格:%4$s</string>
<string name="amsat_upload_grid_none">未填写</string>
<string name="amsat_upload_success">报告已上传</string>
<string name="amsat_upload_failed">上传失败:%s</string>
<string name="amsat_upload_callsign_required">请填写呼号</string>
<string name="amsat_upload_grid_invalid">网格应为 4 或 6 位 Maidenhead 定位符</string>
<string name="amsat_upload_active">活跃</string>
<string name="amsat_upload_tlm">遥测/信标</string>
<string name="amsat_upload_not_heard">未听到</string>
<string name="amsat_upload_crew_active">ISS 语音</string>
<!-- Radar screen -->
<string name="radar_back">后退</string>
<string name="radar_notify">通知</string>
<string name="radar_az_text">方位角</string>
<string name="radar_el_text">仰角</string>
<string name="radar_alt_text">高度</string>
<string name="radar_dist_text">距离</string>
<string name="radar_eclipsed">地影中</string>
<string name="radar_downlink">下行链路</string>
<string name="radar_uplink">上行链路</string>
<string name="radar_no_data">这颗卫星没有收发器</string>
<string name="radar_add">添加</string>
<string name="radar_data">卫星收发器:</string>
<string name="radar_inverted">倒置:%s</string>
<string name="radar_mode">调制:%s</string>
<string name="radar_string_no">否</string>
<string name="radar_string_yes">是</string>
<string name="radar_visible">日照中</string>
<string name="radar_doppler_calc">多普勒频率计算器</string>
<string name="radar_doppler_tx_hint">输入上行频率 (MHz)</string>
<string name="radar_doppler_rx_hint">输入下行频率 (MHz)</string>
<string name="radar_doppler_offset_hint">偏移 (kHz)</string>
<string name="radar_doppler_info">线性转发器:输入一个频率即可算出另一个</string>
<string name="radar_cw_decoder">CW 解码器</string>
<string name="radar_cw_start">开始</string>
<string name="radar_cw_stop">停止</string>
<string name="radar_cw_reset">清空</string>
<!-- Map screen -->
<string name="map_prev">上一个</string>
<string name="map_next">下一个</string>
<string name="map_grid_mode">网格模式</string>
<string name="map_satellite_mode">卫星模式</string>
<string name="map_first_call_mode">首通呼号</string>
<string name="map_azimuth">方位角:%.1f°</string>
<string name="map_elevation">仰角:%.1f°</string>
<string name="map_altitude">高度:%.0f km</string>
<string name="map_distance">距离:%.0f km</string>
<string name="map_latitude">纬度:%.1f°</string>
<string name="map_longitude">经度:%.1f°</string>
<string name="map_phase">相位:%.1f°</string>
<string name="map_eclipsed">地影中</string>
<string name="map_period">周期:% .0f min</string>
<string name="map_velocity">速度:%.2f km/s</string>
<string name="map_visibility">能见度:%s</string>
<!-- Worked-grid QSO dialog -->
<string name="grid_qso_calls_count" formatted="false">%d 个呼号 · %d 次通联</string>
<string name="region_qso_empty">该区域暂无已确认通联</string>
<string name="grid_qso_count_one">%d QSO</string>
<string name="grid_qso_count_many">%d QSOs</string>
<string name="grid_qso_first">首通</string>
<string name="grid_qso_no_sat">卫星未知</string>
<string name="grid_qso_match">匹配</string>
<!-- 未通联网格中的“标记想通联的电台”提醒 -->
<string name="grid_mark_station">标记你想通联的电台</string>
<string name="grid_mark_callsign">呼号</string>
<string name="grid_mark_confirm">标记</string>
<string name="grid_mark_label">标记</string>
<string name="grid_mark_delete">删除标记</string>
<string name="grid_mark_pin">置顶</string>
<string name="map_visible">日照中</string>
<!-- Settings screen -->
<string name="prefs_privacy_title">隐私政策</string>
<string name="prefs_updated_title">更新:%s</string>
<string name="prefs_updated_time">yyyy年M月d日 HH:mm:ss</string>
<string name="prefs_loc_title">站点位置</string>
<string name="prefs_loc_gps_title">GPS 定位</string>
<string name="prefs_loc_gps_error">检查您的位置权限</string>
<string name="prefs_loc_input_title">输入位置</string>
<string name="prefs_loc_input_error">输入的位置无效</string>
<string name="prefs_loc_qth_title">QTH 网格</string>
<string name="prefs_loc_qth_error">无效的 QTH 网格</string>
<string name="prefs_loc_success">位置更新成功</string>
<string name="prefs_station_title">站点位置设置</string>
<string name="prefs_station_lat_text">输入站点的纬度</string>
<string name="prefs_station_lon_text">输入站点的经度</string>
<string name="prefs_locator_title">QTH 网格</string>
<string name="prefs_wavelog_configure">配置</string>
<string name="prefs_wavelog_url">Wavelog 地址</string>
<string name="prefs_wavelog_syncing">同步中…</string>
<string name="prefs_wavelog_upload_title">Wavelog 配置</string>
<string name="prefs_wavelog_exclusive">与 LoTW 二选一配置,两者不兼容。</string>
<string name="prefs_wavelog_sync_incremental">增量同步</string>
<string name="prefs_wavelog_sync_full">全量同步</string>
<string name="prefs_wavelog_clear">清除配置</string>
<string name="prefs_wavelog_clear_title">清除 Wavelog 配置?</string>
<string name="prefs_wavelog_clear_message">清除后将切回 LoTW 模式,LoTW 功能恢复可用。</string>
<string name="prefs_wavelog_switch_title">配置提醒</string>
<string name="prefs_wavelog_switch_message">已配置 LoTW。配置 Wavelog 后,App 内 LoTW 的上传与同步将不可用(两者二选一,不兼容)。继续?</string>
<string name="prefs_wavelog_switch_continue">继续</string>
<string name="prefs_lotw_disabled_wavelog">Wavelog 模式中不可用(二选一)</string>
<string name="prefs_wavelog_upload_not_configured">未配置 — 把本机通联记录上传到 Wavelog</string>
<string name="prefs_wavelog_upload_configured">站点 %1$s — 可以上传</string>
<string name="prefs_wavelog_upload_apikey">API key(读写权限)</string>
<string name="prefs_wavelog_upload_fetch">获取站点</string>
<string name="prefs_wavelog_upload_probing">检测中…</string>
<string name="prefs_wavelog_upload_station_none">未选择站点 — 点「获取站点」</string>
<string name="prefs_wavelog_upload_rights_rw">密钥:读写 — 可上传</string>
<string name="prefs_wavelog_upload_rights_r">密钥只读 — 请在 Wavelog → API 生成「读写」密钥</string>
<string name="prefs_wavelog_upload_hint">点「获取站点」选择上传使用的台站档案;密钥需要「读写」权限。</string>
<string name="prefs_lotw_title">LoTW 已确认网格</string>
<string name="prefs_lotw_callsign">LoTW 呼号</string>
<string name="prefs_lotw_password">LoTW 密码</string>
<string name="prefs_lotw_hint">直接从 ARRL LoTW 拉取该呼号已确认 QSO 的网格并覆盖本地已存集合(当前 %1$d 个)。密码仅保存在手机本地。点"同步"会自动保存凭据并拉取。</string>
<string name="prefs_lotw_sync">同步</string>
<string name="prefs_lotw_syncing">同步中…</string>
<string name="prefs_lotw_configured">已从 LoTW 同步 — 共 %1$d 个网格</string>
<string name="prefs_lotw_not_configured">未配置 — 直接登录 ARRL LoTW 拉取已确认 QSO 的网格,无需自建服务器</string>
<string name="lotw_sync_error_not_configured">LoTW 呼号/密码未配置</string>
<string name="lotw_sync_error_credentials">LoTW 同步失败——呼号或密码错误</string>
<string name="lotw_sync_error_rate_limited">LoTW 同步失败——服务器限流,请等几分钟后再试</string>
<string name="lotw_sync_error_timeout">LoTW 同步失败——连接超时,请更换网络重试</string>
<string name="lotw_sync_error_network">LoTW 同步失败——网络错误(%1$s)</string>
<string name="lotw_sync_full">全量同步</string>
<string name="lotw_sync_incremental">增量合并</string>
<string name="lotw_sync_rate_hint">下载速度约每 100 条 QSO 需 8 秒(另有 5–40 秒服务器生成时间)</string>
<string name="lotw_sync_progress_connecting">正在连接 LoTW 服务器…</string>
<string name="lotw_sync_progress_downloading">正在下载报告…</string>
<string name="lotw_sync_progress_saving">正在处理下载的数据…</string>
<string name="lotw_sync_progress_count">本次同步 %1$d 条 QSO…</string>
<string name="lotw_sync_progress_qso">本次同步 %1$d 条 QSO,预计还需 %2$d 秒</string>
<string name="prefs_locator_text">使用梅登黑德网格设置站点位置</string>
<string name="btn_delete">删除</string>
<string name="prefs_logbook_title">日志本</string>
<string name="prefs_logbook_count">%1$d 条记录 — 本地通联与 LoTW 确认</string>
<string name="prefs_logbook_empty">暂无记录 — 在雷达页 Log 标签记录通联</string>
<string name="prefs_logbook_close">关闭</string>
<string name="prefs_logbook_upload">上传</string>
<string name="prefs_logbook_upload_title">上传到 LoTW</string>
<string name="prefs_logbook_upload_confirm">确认上传</string>
<string name="prefs_logbook_resubmit">重传选中 (%1$d)</string>
<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>
<string name="lotw_upload_grid_title">上传前核对</string>
<string name="lotw_upload_grid_fix">修改台址</string>
<string name="lotw_upload_grid_ignore">忽略</string>
<string name="lotw_upload_grid_mismatch">当前定位 %1$s 不在证书台址网格 %2$s 内——如果正在此处操作,请先更新台址再上传。</string>
<string name="lotw_upload_pos_hint">当前定位 %1$s 不在证书台址网格 %2$s 内 · 点此更新</string>
<string name="prefs_lotw_upload_title">LoTW 上传证书</string>
<string name="prefs_lotw_upload_configured">证书已导入 · 台址网格 %1$s</string>
<string name="prefs_lotw_upload_not_configured">未配置 — 导入 TrustedQSL 证书后即可上传通联</string>
<string name="prefs_lotw_upload_busy">处理中…</string>
<string name="prefs_lotw_upload_cert_hint">导入你的 TrustedQSL .p12 证书(与 TQSL 同款)用于签名上传通联。密码仅加密保存在手机本地。</string>
<string name="prefs_lotw_upload_password">证书密码</string>
<string name="prefs_lotw_upload_password_hint">证书无密码时留空即可</string>
<string name="prefs_lotw_upload_import">选择 .p12 文件</string>
<string name="prefs_lotw_upload_import_confirm">导入</string>
<string name="prefs_lotw_upload_error_password">证书密码错误,请检查 TQSL 导出 .p12 时设置的密码</string>
<string name="prefs_lotw_upload_error_expired">证书已过期或尚未生效</string>
<string name="prefs_lotw_upload_error_invalid">不是有效的 LoTW 证书文件</string>
<string name="prefs_lotw_upload_error_format">不支持的 .p12 加密,请用 TQSL 默认加密重新导出后再试</string>
<string name="prefs_lotw_upload_error_format_alg">不支持的 .p12 加密(%1$s),请用 TQSL 默认加密重新导出后再试</string>
<string name="prefs_lotw_upload_error_unknown">导入失败,请重试</string>
<string name="prefs_lotw_upload_cert_info">%1$s · DXCC %2$d · 有效期至 %3$s</string>
<string name="prefs_lotw_upload_remove">移除证书</string>
<string name="prefs_lotw_upload_station_title">台址</string>
<string name="prefs_lotw_upload_region_hint">未选择</string>
<string name="prefs_lotw_upload_region_label">省份</string>
<string name="prefs_lotw_upload_region_pending">导入证书后可选</string>
<string name="prefs_lotw_upload_grid">网格(逗号分隔,可多格)</string>
<string name="prefs_lotw_upload_save">保存</string>
<string name="prefs_lotw_upload_close">关闭</string>
<string name="prefs_data_title">卫星数据更新</string>
<string name="prefs_data_entries">卫星:%s</string>
<string name="prefs_data_radios">收发器:%s</string>
<string name="prefs_data_update">在线更新</string>
<string name="prefs_data_import">导入文件</string>
<string name="prefs_data_clear">清空</string>
<string name="prefs_data_clear_success">数据清空成功</string>
<string name="prefs_data_update_success">更新成功</string>
<string name="prefs_data_import_satellites_error">未导入卫星。请选择有效的 TLE/3LE (.txt) 或 OMM (.csv) 文件。</string>
<string name="prefs_data_import_transceivers_error">未导入收发器。请选择有效的 SatNOGS (.json) 文件。</string>
<string name="prefs_data_sources_title">自定义数据源</string>
<string name="prefs_data_sources_sat_hint">轨道数据优先取列表最新星历,此顺序仅影响卫星名称</string>
<string name="prefs_data_sources_transceivers_hint">靠前优先:收发器数据</string>
<string name="prefs_data_sources_restore">恢复默认数据源</string>
<string name="prefs_data_sources_tle_switch">自定义TLE URL</string>
<string name="prefs_data_sources_transceivers_switch">自定义收发器URL</string>
<string name="prefs_data_sources_url_title" translatable="false">URL</string>
<string name="prefs_data_sources_satellites_label">卫星数据</string>
<string name="prefs_data_sources_transceivers_label">收发器数据</string>
<string name="prefs_data_output_title">数据输出</string>
<string name="prefs_net_output">网络</string>
<string name="prefs_bt_output">蓝牙</string>
<string name="prefs_cat_output">CAT</string>
<string name="prefs_net_title">网络数据输出</string>
<string name="prefs_net_rotator_switch">启用方位俯仰角输出</string>
<string name="prefs_net_rotator_address_hint">IP:端口</string>
<string name="prefs_net_rotator_format_hint">数据格式</string>
<string name="prefs_net_frequency_switch">启用多普勒频率输出</string>
<string name="prefs_net_frequency_address_hint">IP:端口</string>
<string name="prefs_net_frequency_format_hint">数据格式</string>
<string name="prefs_net_frequency_offset_hint" translatable="false">频率偏移 (Hz)</string>
<string name="prefs_net_frequency_offset_help" translatable="false">范围:-50000 到 50000 Hz。正值提高上报频率,负值降低上报频率。</string>
<string name="prefs_bt_title">蓝牙数据输出</string>
<string name="prefs_bt_rotator_switch">启用方位俯仰角输出</string>
<string name="prefs_bt_rotator_device_hint">蓝牙设备 ID</string>
<string name="prefs_bt_rotator_output_hint">数据格式</string>
<string name="prefs_bt_frequency_switch">启用多普勒频率输出</string>
<string name="prefs_bt_frequency_device_hint">蓝牙设备 ID</string>
<string name="prefs_bt_frequency_output_hint">数据格式</string>
<string name="prefs_bt_perm_error">请检查蓝牙权限</string>
<string name="prefs_net_perm_error">请检查网络权限</string>
<!-- Radio Control -->
<string name="nav_radiocontrol">电台控制</string>
<string name="rc_settings_title">CAT 电台控制</string>
<string name="rc_radio_model">电台型号</string>
<string name="rc_tx_device_hint">发射电台蓝牙地址</string>
<string name="rc_rx_device_hint">接收电台蓝牙地址</string>
<string name="rc_tx_name_hint">发射电台名称</string>
<string name="rc_rx_name_hint">接收电台名称</string>
<string name="rc_enable_switch">启用 CAT 控制</string>
<string name="prefs_other_title">其他设置</string>
<string name="prefs_map_title">地图设置</string>
<string name="prefs_map_configure">配置地图</string>
<string name="prefs_map_source_title">地图来源</string>
<string name="prefs_map_source_osm">离线 OSM 地图</string>
<string name="prefs_map_source_tianditu_vector">天地图(矢量)</string>
<string name="prefs_map_source_tianditu_image">天地图(影像)</string>
<string name="prefs_map_tianditu_key">天地图访问密钥</string>
<string name="prefs_map_tianditu_key_help">使用天地图前,需先在天地图开发者控制台申请服务端应用 Key。</string>
<string name="prefs_map_tianditu_key_required">选择天地图需要填写访问密钥;保存密钥前将继续使用离线 OSM 地图。</string>
<string name="prefs_other_switch_utc">以 UTC 时间显示</string>
<string name="prefs_other_switch_update">启用卫星数据自动更新</string>
<string name="prefs_other_switch_lotw_sync">自动同步 LoTW 已确认网格</string>
<string name="prefs_other_switch_sweep">启用雷达扫描动画</string>
<string name="prefs_other_switch_sensors">使用传感器旋转雷达视图</string>
<string name="prefs_compass_calibration_button">校准指南针</string>
<string name="prefs_compass_calibration_title">指南针校准</string>
<string name="prefs_compass_calibration_instruction">缓慢移动手机画“8”字,直到精度提高。请远离磁铁和金属物体。</string>
<string name="prefs_compass_accuracy">精度:%s</string>
<string name="prefs_compass_accuracy_high">高——校准完成</string>
<string name="prefs_compass_accuracy_medium">中——请继续移动</string>
<string name="prefs_compass_accuracy_low">低——请继续移动</string>
<string name="prefs_compass_accuracy_unreliable">不可靠——请开始“8”字动作</string>
<string name="prefs_compass_accuracy_unavailable">设备没有可用的磁场传感器</string>
<string name="prefs_compass_heading">校正后航向:%.1f°</string>
<string name="prefs_compass_offset">手动航向偏置(°)</string>
<string name="prefs_compass_offset_support">范围 −180° 至 +180°;正值为顺时针偏移。</string>
<string name="prefs_compass_offset_reset">偏置归零</string>
<string name="prefs_other_switch_light_theme">浅色主题</string>
<string name="prefs_other_switch_night_mode">启用红色夜间模式</string>
<string name="prefs_other_switch_map_grid">地图显示 Maidenhead 网格</string>
<string name="prefs_highlight_title">仰角高亮</string>
<string name="prefs_highlight_low">低 &lt; %1$d°</string>
<string name="prefs_highlight_mid">中 %1$d-%2$d°</string>
<string name="prefs_highlight_high">高 &gt; %1$d°</string>
<string name="prefs_outro_title">致谢与声明</string>
<string name="prefs_project_wiki">Project Wiki</string>
<string name="project_wiki_intro">Look4Sat-BA7OPF 是基于 Look4Sat 的定制版,保留上游全部功能,并新增:</string>
<string name="project_wiki_section_logbook">卫星日志本与 LoTW</string>
<string name="project_wiki_logbook_1">卫星通联日志本——通联时一键记录呼号、时间、频率、模式、信号报告和网格。</string>
<string name="project_wiki_logbook_2">LoTW 直传——导入 ARRL 证书后,直接在 App 内把日志上传到 LoTW,无需电脑。</string>
<string name="project_wiki_logbook_3">确认同步——一键同步 LoTW 官方确认,日志自动标记“已上传 / 已确认”;修改已上传的记录会自动新建一条,原记录不受影响。</string>
<string name="project_wiki_logbook_how">用法:通联后点“记录”→ 日志页点“上传”→ 设置里“同步”查看确认。</string>
<string name="project_wiki_section_mutual">双站过境匹配</string>
<string name="project_wiki_mutual_1">友台共视匹配——输入你和友台的位置,找出双方同时可见的卫星过境,点一下直接进雷达图。</string>
<string name="project_wiki_mutual_how">用法:Match 页 → 设置两站位置 → 查询。</string>
<string name="project_wiki_section_grids">通联网格地图</string>
<string name="project_wiki_grids_1">已通联网格——LoTW/Wavelog 同步的网格在地图上标绿,支持奖状边界和首通呼号标签;点格子看该网格的通联记录,还能一键去匹配友台。</string>
<string name="project_wiki_grids_how">用法:地图页开启网格模式 → 设置里同步 LoTW/Wavelog。</string>
<string name="prefs_outro_thanks" translatable="false">
• Look4Sat 所有用户及贡献者!
\n• David A. B. Johnson (predict4java)
\n• Dave Moten (predict4java)
\n• Alexandru Csete (Gpredict)
\n• Dr T.S. Kelso (Celestrak)
\n• Libre Space Foundation (SatNOGS)
\n• xdsopl 和 Robot36 贡献者!
\n• BG7NTA BG5JSU(CW解码功能)
\n• BH2VSQ BG5JSU(天地图渲染支持)
\n• BG5JSU(LoTW上传支持)
</string>
<string name="prefs_outro_notice">Look4Sat-BA7OPF 是基于 Look4Sat(GPL-3.0)的非官方修改版,由 BA7OPF 独立维护,与原项目无隶属关系,亦未获其背书。
上游项目:https://github.com/rt-bishop/Look4Sat</string>
<string name="prefs_outro_license">基于 GNU GPL v3 许可 · 本程序不提供任何保修</string>
<!-- 匹配页 Mutual match page -->
<string name="mutual_title">过境匹配</string>
<string name="mutual_station_you">你的位置</string>
<string name="mutual_station_opposite">友台位置</string>
<string name="mutual_label_you">你</string>
<string name="mutual_label_opposite">友台</string>
<string name="mutual_status_calculating">正在计算双方共同可见窗口…</string>
<string name="mutual_status_error">请修正提示后重新查询</string>
<string name="mutual_status_matches">找到 %d 个匹配 · 点击卡片展开曲线</string>
<string name="mutual_status_none">未找到共同可见窗口,调整时间或仰角后重试</string>
<string name="mutual_status_idle">输入双方位置后点击查询</string>
<string name="mutual_chip_calculating">计算中</string>
<string name="mutual_chip_error">有错误</string>
<string name="mutual_chip_matches">%d 个匹配</string>
<string name="mutual_chip_none">无匹配</string>
<string name="mutual_chip_idle">待查询</string>
<string name="mutual_use_current">填入当前位置</string>
<string name="mutual_lat">纬度</string>
<string name="mutual_lon">经度</string>
<string name="mutual_grid">网格</string>
<string name="mutual_grid_hint">支持 4/6/8 位 Maidenhead 网格;也可直接输入经纬度</string>
<string name="mutual_min_elevation">最小仰角:%d°</string>
<string name="mutual_time_range">时间范围</string>
<string name="mutual_filter_transponder">转发器</string>
<string name="mutual_filter_fm">FM</string>
<string name="mutual_filter_linear">线性</string>
<string name="mutual_calculating">计算中…</string>
<string name="mutual_query">查询匹配</string>
<string name="mutual_elevation_you">你:%d°</string>
<string name="mutual_elevation_opposite">友台:%d°</string>
<string name="mutual_both_tracks">双方轨迹</string>
<string name="mutual_open_radar">查看雷达</string>
<string name="mutual_station_a">站点A</string>
<string name="mutual_station_b">站点B</string>
<string name="update_check_title">检查更新</string>
<string name="update_check_button">检查更新</string>
<string name="update_check_current">当前版本:%s</string>
<string name="update_check_latest">最新版本:%s</string>
<string name="update_check_up_to_date">已是最新版本</string>
<string name="update_check_available">发现新版本</string>
<string name="update_check_release_notes">更新内容</string>
<string name="update_check_download">下载并安装</string>
<string name="update_check_downloading">正在下载…</string>
<string name="update_check_error">检查更新失败,请检查网络连接。</string>
<string name="update_check_download_error">下载失败,请重试。</string>
<string name="update_check_no_apk">该版本未附带 APK 安装包。</string>
<!-- QSO edit dialog -->
<string name="qso_edit_title">编辑通联记录</string>
<string name="qso_edit_uploaded_notice">该通联已上传或已确认。保存将保留原记录不变,并新建一条记录。</string>
<string name="qso_edit_callsign">呼号</string>
<string name="qso_edit_date">日期 (UTC)</string>
<string name="qso_edit_time">时间 (UTC)</string>
<string name="qso_edit_tx">上行频率 (MHz)</string>
<string name="qso_edit_rx">下行频率 (MHz)</string>
<string name="qso_edit_band">频段:%1$s</string>
<string name="qso_edit_mode">模式</string>
<string name="qso_edit_satellite">卫星</string>
<string name="qso_edit_hint_satellite">LoTW 只接受它的官方卫星名</string>
<string name="qso_edit_sent">发送报告</string>
<string name="qso_edit_received">接收报告</string>
<string name="qso_edit_grid">对方网格</string>
<string name="qso_edit_comment">备注</string>
<string name="qso_edit_save">保存</string>
<string name="qso_edit_cancel">取消</string>
<string name="log_out_window_title">不在过境窗口</string>
<string name="log_out_window_message">当前时间不在该过境窗口内,将默认按过境中点 %1$s 录入;如需修改,请点击该条通联记录。</string>
<string name="log_out_window_confirm">录入</string>
<string name="qso_edit_err_callsign">呼号不能为空</string>
<string name="qso_edit_err_datetime">日期或时间格式不对 —— 应为 yyyy-MM-dd 与 HH:mm:ss(UTC)</string>
<string name="qso_edit_err_frequency">频率格式不对 —— 请输入 MHz,例如 145.850</string>
<string name="qso_edit_err_satellite">不是 LoTW 官方卫星名 —— 请从列表中选一个</string>
<string name="qso_edit_satellite_pick">选择</string>
<string name="qso_edit_satellite_pick_title">LoTW 官方卫星名</string>
<string name="qso_edit_satellite_search">搜索卫星名或数据源别名</string>
<string name="qso_edit_satellite_official">保存并签名时使用 %1$s</string>
<string name="qso_edit_warn_no_freq">没有上行频率:LoTW 要求 BAND,补上频率前这条无法上传</string>
<!-- 网格寻线(VUCC 网格线/网格角实地导航) -->
<string name="gridfinder_title">网格寻线</string>
<string name="gridfinder_status_corner">位于网格点上</string>
<string name="gridfinder_status_line">位于网格线上</string>
<string name="gridfinder_status_inside">在网格内部</string>
<string name="gridfinder_status_unknown">位置不可用</string>
<string name="gridfinder_status_waiting">正在获取定位…</string>
<string name="gridfinder_status_blocked">需要定位权限</string>
<string name="gridfinder_status_disabled">本机定位服务未开启</string>
<string name="gridfinder_status_confirmed">已由 %1$d 次连续满足 20 英尺规则的定位确认</string>
<string name="gridfinder_status_unconfirmed">尚未确认:精度达 6.1 m 的连续定位 %1$d/%2$d 次</string>
<string name="gridfinder_grant_permission">授予定位权限</string>
<string name="gridfinder_card_location">位置</string>
<string name="gridfinder_card_corner">最近网格点引导</string>
<string name="gridfinder_card_line">最近网格线引导</string>
<string name="gridfinder_card_lines">两条边界线</string>
<string name="gridfinder_card_map">近距离网格图</string>
<string name="gridfinder_card_fix">定位质量</string>
<string name="gridfinder_card_compass">罗盘</string>
<string name="gridfinder_label_claimable">此处可计分的网格</string>
<string name="gridfinder_label_grid4">网格 (4)</string>
<string name="gridfinder_label_grid6">网格 (6)</string>
<string name="gridfinder_label_grid8">网格 (8)</string>
<string name="gridfinder_label_latitude">纬度</string>
<string name="gridfinder_label_longitude">经度</string>
<string name="gridfinder_label_altitude">海拔</string>
<string name="gridfinder_label_accuracy">水平精度</string>
<string name="gridfinder_label_fix_age">定位时刻</string>
<string name="gridfinder_label_provider">定位来源</string>
<string name="gridfinder_value_provider_on">已开启</string>
<string name="gridfinder_value_provider_off">未开启</string>
<string name="gridfinder_label_distance">距离</string>
<string name="gridfinder_label_bearing">方位角</string>
<string name="gridfinder_label_direction">方向</string>
<string name="gridfinder_label_grids">网格</string>
<string name="gridfinder_label_lat_line">纬线</string>
<string name="gridfinder_label_lon_line">经线</string>
<string name="gridfinder_line_value">位于%2$s方向 %1$s</string>
<string name="gridfinder_line_latitude">这是纬度边界,东西走向</string>
<string name="gridfinder_line_longitude">这是经度边界,南北走向</string>
<string name="gridfinder_label_heading">朝向</string>
<string name="gridfinder_label_compass_accuracy">罗盘精度</string>
<string name="gridfinder_action_set_station">设为我的台址</string>
<string name="gridfinder_action_station_saved">台址已更新 —— 过境预报将使用这个位置</string>
<string name="gridfinder_action_calibrate">校准罗盘</string>
<string name="gridfinder_map_legend">中心点是你的位置,虚线是正北与正东,直线是两条网格边界,绿点是最近的网格点,圆环是最近网格线的垂足,红色指针是你的朝向</string>
<string name="gridfinder_compass_disabled_hint">设置里未开启罗盘,表盘按正北朝上显示。方位角数字仍可引导你</string>
<string name="gridfinder_compass_unavailable_hint">本机没有磁力传感器 —— 请按方位角数字导航</string>
<string name="gridfinder_compass_hint">请平持手机,箭头指向应走的方向</string>
<string name="gridfinder_vucc_rule_note">VUCC 20 英尺规则:%1$.1f m</string>
<string name="gridfinder_direction_north">北</string>
<string name="gridfinder_direction_south">南</string>
<string name="gridfinder_direction_east">东</string>
<string name="gridfinder_direction_west">西</string>
<string name="gridfinder_action_set_lotw_station">设为 LoTW 上传台址(%1$s)</string>
<string name="gridfinder_lotw_station_hint">预填上传台站网格:上传时 LoTW 按这些格记录(线上 2 格、角上 4 格);证书呼号保持不变。</string>
</resources>
@@ -19,6 +19,10 @@
<string name="sat_search_clear">Vaciar</string>
<string name="sat_clear_all">Vaciar 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">
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>
@@ -19,6 +19,10 @@
<string name="sat_search_clear">Очистить</string>
<string name="sat_clear_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_warning_title">Внимание\!</string>
<string name="sat_warning_message">
@@ -19,6 +19,10 @@
<string name="sat_search_clear">පිරිසිදු ක°</string>
<string name="sat_clear_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_warning_title">අවවාදයයි\!</string>
@@ -20,6 +20,10 @@
<string name="sat_search_clear">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_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_warning_title">Uyarı\!</string>
<string name="sat_warning_message">
@@ -19,6 +19,10 @@
<string name="sat_search_clear">Очистити</string>
<string name="sat_clear_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_warning_title">Увага\!</string>
@@ -20,6 +20,10 @@
<string name="sat_search_clear">清空</string>
<string name="sat_clear_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_warning_title">警告\!</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_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>
@@ -21,6 +21,10 @@
<string name="sat_search_clear">Clear</string>
<string name="sat_clear_all">Clear 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_warning_title">Warning\!</string>
<string name="sat_warning_message">
@@ -245,6 +249,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>
@@ -0,0 +1,91 @@
/*
* 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.presentation
import java.io.File
import javax.xml.parsers.DocumentBuilderFactory
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import org.w3c.dom.Element
/**
* Android 7+ resolves Chinese by script, so an unqualified values-zh is treated as
* zh-Hans and a Traditional device (zh-TW / zh-HK / zh-MO, i.e. zh-Hant) never matches
* it — it used to fall back to the English default. values-b+zh+Hant carries the
* Simplified text for those devices, and the locale filter in the convention plugin
* keeps that config in the APK.
*
* Both halves fail silently at runtime (English strings, or a Hant config dropped from
* the resource table), so they are pinned here: keep the Hant copy verbatim and keep
* the Traditional locales in the locale filter.
*/
class TraditionalChineseFallbackTest {
private val simplified = stringEntries("src/main/res/values-zh/strings.xml")
private val traditional = stringEntries("src/main/res/values-b+zh+Hant/strings.xml")
private val conventionPlugin = generateSequence(File("").absoluteFile) { it.parentFile }
.map { File(it, "build-logic/convention/src/main/java/com/rtbishop/look4sat/convention/PluginSetupUtils.kt") }
.first { it.isFile }
@Test
fun `hant fallback is a verbatim copy of the simplified strings`() {
assertTrue("values-zh/strings.xml looks empty or unparsable", simplified.size > 400)
val missing = (simplified.keys - traditional.keys).sorted()
val extra = (traditional.keys - simplified.keys).sorted()
val changed = simplified.keys.intersect(traditional.keys)
.filter { simplified[it] != traditional[it] }
.sorted()
assertEquals("keys missing from values-b+zh+Hant", emptyList<String>(), missing)
assertEquals("keys only present in values-b+zh+Hant", emptyList<String>(), extra)
assertEquals("values-b+zh+Hant diverged from values-zh (copy the change over)", emptyList<String>(), changed)
}
@Test
fun `traditional locales survive the locale filter`() {
assertTrue("convention plugin not found: ${conventionPlugin.absolutePath}", conventionPlugin.isFile)
// Comments may mention either spelling, so only the code lines are inspected.
val filters = conventionPlugin.readLines()
.filterNot { it.trimStart().startsWith("//") }
.joinToString("\n")
val traditionalLocales = listOf("zh-rTW", "zh-rHK", "zh-rMO")
val dropped = traditionalLocales.filterNot { filters.contains("\"$it\"") }
assertEquals(
"localeFilters must list the Traditional locales, otherwise aapt2 strips" +
" values-b+zh+Hant from the APK and Traditional devices get English",
emptyList<String>(),
dropped
)
// aapt2 rejects the script spelling, so it must not creep back into the filter.
assertTrue("localeFilters must use the aapt2 'zh-rTW' spelling, not 'zh-Hant'", !filters.contains("\"zh-Hant\""))
}
private fun stringEntries(path: String): Map<String, String> {
val file = File(path)
assertTrue("resource file not found: ${file.absolutePath}", file.isFile)
val document = DocumentBuilderFactory.newInstance()
.apply { isNamespaceAware = false }
.newDocumentBuilder()
.parse(file)
val nodes = document.getElementsByTagName("string")
return (0 until nodes.length).associate {
val element = nodes.item(it) as Element
element.getAttribute("name") to element.textContent.trim()
}
}
}
+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.Paint
import android.graphics.Path
import org.json.JSONArray
import org.json.JSONObject
import org.osmdroid.views.MapView
import org.osmdroid.views.Projection
@@ -48,6 +49,15 @@ 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
* 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
)
@@ -61,6 +71,20 @@ object AwardBoundaryData {
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) {
WAPC("wapc.json"),
WAJA("waja.json"),
@@ -80,16 +104,7 @@ object AwardBoundaryData {
for (i in 0 until array.length()) {
val o = array.optJSONObject(i) ?: continue
val ringsArray = o.optJSONArray("rings") ?: continue
val rings = mutableListOf<List<DoubleArray>>()
for (r in 0 until ringsArray.length()) {
val ring = ringsArray.optJSONArray(r) ?: continue
val points = mutableListOf<DoubleArray>()
for (p in 0 until ring.length()) {
val pt = ring.optJSONArray(p) ?: continue
if (pt.length() >= 2) points.add(doubleArrayOf(pt.getDouble(0), pt.getDouble(1)))
}
if (points.size >= 3) rings.add(points)
}
val rings = parseRings(ringsArray)
if (rings.isEmpty()) continue
list.add(
AwardRegion(
@@ -98,7 +113,9 @@ 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)
)
)
@@ -238,6 +255,14 @@ class AwardBoundaryOverlay : Overlay() {
val tiny = max(regionW, regionH) <= AwardHitTest.MIN_SHAPE_PX
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
for (ring in region.rings) {
// 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
}
}
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 (worked) canvas.drawPath(path, workedPaint)
if (worked) fillPath?.let { canvas.drawPath(it, workedPaint) }
linePaint.strokeWidth = if (selected) LINE_WIDTH_SELECTED else LINE_WIDTH
canvas.drawPath(path, linePaint)
linePaint.strokeWidth = LINE_WIDTH
@@ -28,7 +28,7 @@ import kotlin.math.pow
* user's finger in DXCC/WAPC/WAJA/WAZ/WAS view. Pure geometry (no osmdroid
* types) so it is unit-testable; the overlay delegates to [hitRegion].
*
* Two hit shapes, mirroring how the overlay draws:
* Three hit shapes, mirroring how the overlay draws:
* - a small region (projects to <= [MIN_TAP_TARGET_PX] at the current zoom —
* including the dot-rendered ones) is hit by an inflated square target of
* [MIN_TAP_TARGET_PX] around its bbox centre, so finger taps land even on
@@ -36,6 +36,14 @@ import kotlin.math.pow
* - a normal region is hit by an even-odd point-in-polygon test on its rings.
* Small targets win over polygon hits (Macao sits inside Guangdong's outline),
* and among polygon hits the smallest region wins (Hong Kong over China).
*
* - a scattered sea archipelago (>= 2 disjoint island rings, every island
* small — see [ARCHIPELAGO_MAX_RING_DEG]) is additionally hit anywhere in
* its overall envelope, so tapping open sea between the islands lands on
* the entity (user request 2026-10-06: 海上的群岛按照整体包络来判断 hit).
* Priority: small target > land polygon > archipelago envelope — tapping the
* Spanish coast that happens to lie in the Balearic envelope still opens
* Spain; only sea inside the envelope belongs to the archipelago.
*/
object AwardHitTest {
@@ -49,6 +57,12 @@ object AwardHitTest {
* like Hong Kong are hard to hit with a finger at their own size). */
const val MIN_TAP_TARGET_PX = 80f
/** Largest per-island span (deg) for a region to count as a scattered sea
* archipelago: every ring must be a small island, never a mainland
* (Japan/UK/Indonesia stay polygon-only). Archipelago islands are
* finger-proof at low zoom, so their hit region is the overall envelope. */
const val ARCHIPELAGO_MAX_RING_DEG = 2.5
/**
* The region under a tap at [tapLat]/[tapLon] on the map at [zoom], or
* null when the tap lands on empty space. [bounds] holds each region's
@@ -69,6 +83,8 @@ object AwardHitTest {
var bestSmallDist = Double.MAX_VALUE
var bestPoly: AwardRegion? = null
var bestPolyArea = Double.MAX_VALUE
var bestArch: AwardRegion? = null
var bestArchArea = Double.MAX_VALUE
for (i in regions.indices) {
val b = bounds.getOrNull(i) ?: continue
// Cheap bbox prefilter with the tap-target slack (longitudes wrap).
@@ -83,20 +99,93 @@ object AwardHitTest {
val dx = dLon / lonDegPerPx
val dy = (tapLat - cLat) / latDegPerPx
val dist = hypot(dx, dy)
val region = regions[i]
if (max(w, h) <= MIN_TAP_TARGET_PX) {
// Small region: hit by an inflated square target around the
// bbox centre — at least MIN_TAP_TARGET_PX on each side — so
// finger taps land even on dot-sized entities.
if (abs(dx) <= slack && abs(dy) <= slack && dist < bestSmallDist) {
bestSmall = regions[i]
bestSmall = region
bestSmallDist = dist
}
} else if (w * h < bestPolyArea && contains(regions[i], tapLat, tapLon)) {
bestPoly = regions[i]
bestPolyArea = w * h
} else if (contains(region, tapLat, tapLon)) {
// Land polygon hit: the smallest enclosing region wins.
if (w * h < bestPolyArea) {
bestPoly = region
bestPolyArea = w * h
}
} else if (isArchipelago(region)) {
// Scattered sea archipelago: the overall envelope is the hit
// region (open sea between the islands belongs to the entity).
// The envelope is measured in one unwrapped longitude frame so
// antimeridian groups (Fiji-style) keep a contiguous box.
val env = envelope(region)
val halfW = (env[2] - env[1]) / 2.0
val halfH = (env[4] - env[3]) / 2.0
val envLon = env[0] + wrap180(tapLon - env[0])
val inEnvelope =
abs(envLon - (env[1] + env[2]) / 2.0) <= halfW + slack * lonDegPerPx &&
abs(tapLat - (env[3] + env[4]) / 2.0) <= halfH + slack * latDegPerPx
val area = 4.0 * halfW * halfH
if (inEnvelope && area < bestArchArea) {
bestArch = region
bestArchArea = area
}
}
}
return bestSmall ?: bestPoly
return bestSmall ?: bestPoly ?: bestArch
}
/**
* Scattered sea archipelago: at least two disjoint island rings and every
* ring small (<= [ARCHIPELAGO_MAX_RING_DEG] in both lon and lat, measured
* in an unwrapped longitude frame) — no dominant mainland. Mainland
* countries with stray islands (Japan, UK, Indonesia) fail the size test
* and keep plain polygon hits.
*/
fun isArchipelago(region: AwardRegion): Boolean {
if (region.rings.size < 2) return false
for (ring in region.rings) {
if (ring.isEmpty()) continue
val anchor = ring[0][0]
var minX = 0.0
var maxX = 0.0
var minY = ring[0][1]
var maxY = ring[0][1]
for (p in ring) {
val x = wrap180(p[0] - anchor)
if (x < minX) minX = x
if (x > maxX) maxX = x
if (p[1] < minY) minY = p[1]
if (p[1] > maxY) maxY = p[1]
}
if (max(maxX - minX, maxY - minY) > ARCHIPELAGO_MAX_RING_DEG) return false
}
return true
}
/**
* Envelope of all rings in one longitude frame anchored at the first ring's
* first point: `[anchorLon, minX, maxX, minY, maxY]`. Longitudes past ±180
* are unwrapped into the anchor's frame, keeping antimeridian groups whole.
*/
private fun envelope(region: AwardRegion): DoubleArray {
val first = region.rings.firstOrNull { it.isNotEmpty() } ?: return doubleArrayOf(0.0, 0.0, 0.0, 0.0, 0.0)
val anchor = first[0][0]
var minX = 0.0
var maxX = 0.0
var minY = first[0][1]
var maxY = first[0][1]
for (ring in region.rings) {
for (p in ring) {
val x = wrap180(p[0] - anchor)
if (x < minX) minX = x
if (x > maxX) maxX = x
if (p[1] < minY) minY = p[1]
if (p[1] > maxY) maxY = p[1]
}
}
return doubleArrayOf(anchor, anchor + minX, anchor + maxX, minY, maxY)
}
/** Even-odd point-in-polygon across all rings (rings are disjoint parts). */
@@ -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) {
@@ -123,4 +123,83 @@ class AwardHitTestTest {
assertEquals("176", hit(regions, -18.0, -175.0, 4.0)?.code)
assertNull(hit(regions, -18.0, 165.0, 4.0))
}
/** Two 0.2 x 0.2 deg island boxes [lon] apart, as one archipelago entity. */
private fun archipelago(code: String, lon1: Double, lon2: Double, lat: Double = -10.0) =
AwardRegion(
code = code,
name = code,
labelLon = (lon1 + lon2) / 2.0,
labelLat = lat,
rings = listOf(
listOf(
doubleArrayOf(lon1 - 0.1, lat - 0.1), doubleArrayOf(lon1 + 0.1, lat - 0.1),
doubleArrayOf(lon1 + 0.1, lat + 0.1), doubleArrayOf(lon1 - 0.1, lat + 0.1)
),
listOf(
doubleArrayOf(lon2 - 0.1, lat - 0.1), doubleArrayOf(lon2 + 0.1, lat - 0.1),
doubleArrayOf(lon2 + 0.1, lat + 0.1), doubleArrayOf(lon2 - 0.1, lat + 0.1)
)
)
)
@Test
fun `sea archipelago is hit by its overall envelope between the islands`() {
// Two tiny islands 10 deg apart: at zoom 5 the bbox is ~230 px, so the
// per-region inflated target does not apply — the whole envelope does
// (user request 2026-10-06: 海上的群岛按照整体包络来判断 hit).
val regions = listOf(archipelago("191", -165.0, -155.0))
assertEquals("191", hit(regions, -10.0, -160.0, 5.0)?.code) // open sea in the middle
assertEquals("191", hit(regions, -10.0, -156.0, 5.0)?.code) // near the east island
assertNull(hit(regions, -10.0, -152.0, 5.0)) // ~2.8 deg past the envelope
}
@Test
fun `small entity inside the archipelago envelope keeps its own target`() {
val regions = listOf(
archipelago("191", -165.0, -155.0),
box("KS", -161.1, -9.6, -160.9, -9.4) // tiny standalone island in the gap
)
// Near KS the small target (80 px) wins over the archipelago envelope.
assertEquals("KS", hit(regions, -9.5, -160.4, 5.0)?.code)
// Away from it the open sea belongs to the archipelago.
assertEquals("191", hit(regions, -10.0, -156.0, 5.0)?.code)
}
@Test
fun `mainland country with a stray island is not envelope hit`() {
// Big mainland ring + one small island ring: sea inside the bbox is NOT
// a tap target (the group is not a scattered archipelago).
val regions = listOf(
AwardRegion(
code = "JP",
name = "JP",
labelLon = 133.0,
labelLat = 37.0,
rings = listOf(
listOf(
doubleArrayOf(130.0, 31.0), doubleArrayOf(135.0, 31.0),
doubleArrayOf(135.0, 42.0), doubleArrayOf(130.0, 42.0)
),
listOf(
doubleArrayOf(138.0, 34.2), doubleArrayOf(138.3, 34.2),
doubleArrayOf(138.3, 34.5), doubleArrayOf(138.0, 34.5)
)
)
)
)
assertNull(hit(regions, 35.0, 137.0, 5.0)) // sea inside the bbox
assertEquals("JP", hit(regions, 37.0, 132.0, 5.0)?.code) // on the mainland
assertEquals("JP", hit(regions, 34.35, 138.15, 5.0)?.code) // on the island itself
}
@Test
fun `archipelago envelope crossing the antimeridian stays contiguous`() {
// Two islands straddling 180: the raw bbox is 357 deg wide, but the
// unwrapped envelope is a tight 3 deg box around the date line.
val regions = listOf(archipelago("FJ", 178.65, -178.65, lat = -18.0))
assertEquals("FJ", hit(regions, -18.0, 180.0, 5.0)?.code) // sea gap at the date line
assertEquals("FJ", hit(regions, -18.0, -180.0, 5.0)?.code) // same, other sign
assertNull(hit(regions, -18.0, 176.0, 5.0)) // well west of the group
}
}
@@ -18,8 +18,10 @@
package com.rtbishop.look4sat.feature.map
import java.io.File
import org.json.JSONArray
import org.json.JSONObject
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
@@ -60,8 +62,10 @@ class DxccAssetTest {
first { it.getInt("code") == code }
/** Even-odd ray casting across all rings of a region (rings are disjoint parts). */
private fun regionContains(region: JSONObject, lon: Double, lat: Double): Boolean {
val rings = region.getJSONArray("rings")
private fun regionContains(region: JSONObject, lon: Double, lat: Double): Boolean =
ringsContain(region.getJSONArray("rings"), lon, lat)
private fun ringsContain(rings: JSONArray, lon: Double, lat: Double): Boolean {
var inside = false
for (r in 0 until rings.length()) {
val ring = rings.getJSONArray(r)
@@ -220,4 +224,123 @@ class DxccAssetTest {
!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,
// Injected so unit tests can run the pass computation on a test dispatcher;
// 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() {
private val _uiState = MutableStateFlow(MutualUiState())
@@ -303,7 +306,7 @@ class MutualViewModel(
queryGeneration += 1
viewModelScope.launch {
val now = System.currentTimeMillis()
val now = nowMillis()
// 转发器类型筛选: 使用硬编码 AMSAT Live 清单 (见 Sources):
// FM = {SO-50, ISS ZARYA, AO-123}, Linear = {RS-44, FO-29,
// AO-7, AO-73, JO-97}. 不再依赖 AMSAT 网页同步: 任何网络下都
@@ -53,7 +53,9 @@ class MutualViewModelTest {
): MutualViewModel = MutualViewModel(
satelliteRepo = FakeSatelliteRepo(satellites, passes),
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) {
@@ -219,7 +221,7 @@ class MutualViewModelTest {
assertTrue(state.mutualPasses.all { it.catNum == 25544 })
assertTrue(state.mutualPasses.all { it.name == "ISS (ZARYA)" })
// 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.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). */
val GUANGZHOU = GeoPos(23.13, 113.26)
@@ -85,7 +94,7 @@ object TestOrbits {
fun findPassWindows(
sat: OrbitalObject = ISS,
pos: GeoPos = GUANGZHOU,
time: Long = System.currentTimeMillis(),
time: Long = FIXTURE_BASE,
hoursAhead: Int = 12
): List<OrbitalPass> {
if (!sat.willBeSeen(pos)) return emptyList()
@@ -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) }
/**
@@ -326,9 +326,52 @@ private fun SatellitesPreview() {
@Composable
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)) {
items(items = items, key = { item -> item.catnum }) { entry ->
Satellite(entry, onSelected, Modifier.animateItem())
stickyHeader(key = "sat_summary_header") {
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,
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 = "573"
appVersionCode = "583"
#noinspection UnusedVersionCatalogEntry
appVersionName = "4.4.8-ba7opf.10"
appVersionName = "4.4.8-ba7opf.11.8"
#noinspection UnusedVersionCatalogEntry
compileSdk = "37"
#noinspection UnusedVersionCatalogEntry