Compare commits

..
Author SHA1 Message Date
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
40 changed files with 882 additions and 97 deletions

No files matched your search

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)
}
)
}
@@ -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>
@@ -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 = "576"
appVersionCode = "582"
#noinspection UnusedVersionCatalogEntry
appVersionName = "4.4.8-ba7opf.11.1"
appVersionName = "4.4.8-ba7opf.11.7"
#noinspection UnusedVersionCatalogEntry
compileSdk = "37"
#noinspection UnusedVersionCatalogEntry