From 3be2e4b9e85537faec4c897e43906c4ba574163c Mon Sep 17 00:00:00 2001 From: atsunatsu Date: Sat, 26 Sep 2026 18:33:25 +0800 Subject: [PATCH] feat(lotw): PBES2 .p12 parsing, station region fields, upload error codes --- .../look4sat/core/data/lotw/LoTWConfig.kt | 43 ++- .../core/data/lotw/LoTWKeyMaterial.kt | 52 +++- .../core/data/lotw/LoTWUploadRepository.kt | 19 +- .../look4sat/core/data/lotw/Pkcs12Reader.kt | 255 ++++++++++++++++++ .../repository/ILoTWUploadRepository.kt | 26 +- .../src/main/res/values-zh/strings.xml | 8 +- .../src/main/res/values/strings.xml | 8 +- .../settings/LoTWUploadConfigDialog.kt | 116 +++++++- 8 files changed, 504 insertions(+), 23 deletions(-) create mode 100644 core/data/src/main/java/com/rtbishop/look4sat/core/data/lotw/Pkcs12Reader.kt diff --git a/core/data/src/main/java/com/rtbishop/look4sat/core/data/lotw/LoTWConfig.kt b/core/data/src/main/java/com/rtbishop/look4sat/core/data/lotw/LoTWConfig.kt index 1f9f0e10..a3ca551d 100644 --- a/core/data/src/main/java/com/rtbishop/look4sat/core/data/lotw/LoTWConfig.kt +++ b/core/data/src/main/java/com/rtbishop/look4sat/core/data/lotw/LoTWConfig.kt @@ -3,7 +3,11 @@ package com.rtbishop.look4sat.core.data.lotw import com.rtbishop.look4sat.core.domain.logbook.QsoRecord import com.rtbishop.look4sat.core.domain.logbook.displayMode import com.rtbishop.look4sat.core.domain.repository.LoTWProblem +import com.rtbishop.look4sat.core.domain.repository.LoTWRegionField +import com.rtbishop.look4sat.core.domain.repository.LoTWRegionOption import com.rtbishop.look4sat.core.domain.repository.LoTWStation +import com.rtbishop.look4sat.core.domain.repository.LoTWStationMeta +import com.rtbishop.look4sat.core.domain.repository.LoTWZonePair import org.w3c.dom.Element import org.xml.sax.InputSource import org.xml.sax.SAXException @@ -28,6 +32,8 @@ internal class LoTWConfig(input: InputStream) { private val spec = config.elements("sigspec").single { it.getAttribute("version") == "2.0" } val stationOrder = spec.elements("tSTATION").single().childElements().map { it.tagName } val contactOrder = spec.elements("tCONTACT").single().childElements().map { it.tagName } + private val primaryRegionFields = setOf("US_STATE", "CA_PROVINCE", "RU_OBLAST", "CN_PROVINCE", "AU_STATE", "JA_PREFECTURE", "FI_KUNTA") + private val secondaryRegionFields = setOf("US_COUNTY", "JA_CITY_GUN_KU") private val bands = config.elements("bands").single().elements("band") private val satellites = config.elements("satellite").associateBy { it.getAttribute("name").uppercase(Locale.US) } private val modes = config.elements("modes").single().elements("mode").map { it.textContent }.toSet() @@ -78,6 +84,39 @@ internal class LoTWConfig(input: InputStream) { return normalized } + /** Region-field metadata and the national zonemap for one DXCC entity. */ + fun stationMeta(dxcc: Int): LoTWStationMeta { + val pageFields = config.elements("page").filter { it.getAttribute("dependency") == dxcc.toString() } + .flatMap { it.elements("pageField") }.map { it.textContent } + val primary = pageFields.firstOrNull { it in primaryRegionFields } + val regionField = primary?.let { id -> + val field = config.elements("field").single { it.getAttribute("Id") == id } + val dependency = if (field.getAttribute("dependsOn") == "DXCC") dxcc.toString() else null + val options = field.elements("enums").filter { + it.getAttribute("dependency").isBlank() || it.getAttribute("dependency") == dependency + }.flatMap { it.elements("enum") }.map { enum -> + LoTWRegionOption( + code = enum.getAttribute("value").uppercase(Locale.US), + name = enum.textContent.trim().ifBlank { enum.getAttribute("value") }, + zones = parseZonemap(enum.getAttribute("zonemap")) + ) + } + LoTWRegionField(id = id, label = field.getAttribute("label"), options = options) + } + val entity = config.elements("dxcc").flatMap { it.elements("entity") } + .firstOrNull { it.getAttribute("arrlId") == dxcc.toString() } + val countryZones = entity?.getAttribute("zonemap")?.let(::parseZonemap).orEmpty() + return LoTWStationMeta(regionField, countryZones) + } + + private fun parseZonemap(zonemap: String): List = zonemap.split(',').mapNotNull { pair -> + val parts = pair.split(':') + if (parts.size != 2) return@mapNotNull null + val itu = parts[0].trim().toIntOrNull() ?: return@mapNotNull null + val cq = parts[1].trim().toIntOrNull() ?: return@mapNotNull null + LoTWZonePair(itu, cq) + } + fun stationFields(station: LoTWStation, dxcc: Int): Map { fun normalized(value: String) = value.trim().uppercase(Locale.US) val grids = station.grid.split(',').map(::normalized).filter(String::isNotBlank).distinct() @@ -103,8 +142,8 @@ internal class LoTWConfig(input: InputStream) { } val pageFields = config.elements("page").filter { it.getAttribute("dependency") == dxcc.toString() } .flatMap { it.elements("pageField") }.map { it.textContent } - val primary = pageFields.firstOrNull { it in setOf("US_STATE", "CA_PROVINCE", "RU_OBLAST", "CN_PROVINCE", "AU_STATE", "JA_PREFECTURE", "FI_KUNTA") } - val secondary = pageFields.firstOrNull { it in setOf("US_COUNTY", "JA_CITY_GUN_KU") } + val primary = pageFields.firstOrNull { it in primaryRegionFields } + val secondary = pageFields.firstOrNull { it in secondaryRegionFields } fun region(name: String?, value: String) { if (value.isBlank()) return if (name == null) fail(LoTWProblem.STATION_REGION) diff --git a/core/data/src/main/java/com/rtbishop/look4sat/core/data/lotw/LoTWKeyMaterial.kt b/core/data/src/main/java/com/rtbishop/look4sat/core/data/lotw/LoTWKeyMaterial.kt index e89503ce..48e1183e 100644 --- a/core/data/src/main/java/com/rtbishop/look4sat/core/data/lotw/LoTWKeyMaterial.kt +++ b/core/data/src/main/java/com/rtbishop/look4sat/core/data/lotw/LoTWKeyMaterial.kt @@ -17,16 +17,40 @@ internal data class LoTWKeyMaterial(val key: PrivateKey, val certificate: X509Ce companion object { fun read(bytes: ByteArray, password: CharArray, now: Long): 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) { fail(LoTWProblem.CERTIFICATE_PASSWORD) } - val aliases = Collections.list(store.aliases()).filter { store.isKeyEntry(it) } - if (aliases.size != 1) fail(LoTWProblem.CERTIFICATE_INVALID) - val alias = aliases.single() - val key = try { store.getKey(alias, password) as? PrivateKey } - catch (_: Exception) { fail(LoTWProblem.CERTIFICATE_PASSWORD) } - ?: fail(LoTWProblem.CERTIFICATE_INVALID) - val cert = store.getCertificate(alias) as? X509Certificate ?: fail(LoTWProblem.CERTIFICATE_INVALID) + } 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 (_: Exception) { + fail(if (password.isNotEmpty()) LoTWProblem.CERTIFICATE_FORMAT else LoTWProblem.CERTIFICATE_PASSWORD) + } + } else { + fail(LoTWProblem.CERTIFICATE_PASSWORD) + } + } + if (store is Pair<*, *>) { + @Suppress("UNCHECKED_CAST") + key = store.first as PrivateKey + @Suppress("UNCHECKED_CAST") + cert = store.second as X509Certificate + } else { + 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) + } if (key.algorithm != "RSA" || cert.publicKey.algorithm != "RSA") fail(LoTWProblem.CERTIFICATE_INVALID) try { cert.checkValidity(Date(now)) } catch (_: Exception) { fail(LoTWProblem.CERTIFICATE_EXPIRED) } val info = try { metadata(cert) } catch (_: Exception) { fail(LoTWProblem.CERTIFICATE_INVALID) } @@ -38,6 +62,18 @@ internal data class LoTWKeyMaterial(val key: PrivateKey, val certificate: X509Ce return LoTWKeyMaterial(key, cert, info) } + /** 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 { + val oid = byteArrayOf(0x2a, 0x86.toByte(), 0x48, 0x86.toByte(), 0xf7.toByte(), 0x0d, 0x01, 0x05, 0x0d) + if (bytes.size < oid.size) return false + outer@ for (i in 0..bytes.size - oid.size) { + for (j in oid.indices) if (bytes[i + j] != oid[j]) continue@outer + return true + } + return false + } + private fun metadata(cert: X509Certificate): LoTWCertificate { val oid = byteArrayOf(0x2b, 0x06, 0x01, 0x04, 0x01, 0xe0.toByte(), 0x3c, 0x01, 0x01) val subject = DerValue.read(cert.subjectX500Principal.encoded).single() diff --git a/core/data/src/main/java/com/rtbishop/look4sat/core/data/lotw/LoTWUploadRepository.kt b/core/data/src/main/java/com/rtbishop/look4sat/core/data/lotw/LoTWUploadRepository.kt index 742b4a49..0ad4a637 100644 --- a/core/data/src/main/java/com/rtbishop/look4sat/core/data/lotw/LoTWUploadRepository.kt +++ b/core/data/src/main/java/com/rtbishop/look4sat/core/data/lotw/LoTWUploadRepository.kt @@ -8,6 +8,7 @@ import com.rtbishop.look4sat.core.domain.repository.LoTWCertificate 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.LoTWStationMeta import com.rtbishop.look4sat.core.domain.repository.LoTWUploadAudit import com.rtbishop.look4sat.core.domain.repository.LoTWUploadPreview import com.rtbishop.look4sat.core.domain.repository.LoTWUploadResult @@ -124,8 +125,18 @@ class LoTWUploadRepository internal constructor( val stored = storage.read("certificate") ?: fail(LoTWProblem.CERTIFICATE_MISSING) val bundle = try { readBundle(stored) } finally { stored.fill(0) } val normalized = station.normalized() - try { config().stationFields(normalized, bundle.info.dxcc) } - finally { + try { + config().stationFields(normalized, bundle.info.dxcc) + } catch (e: LoTWOperationException) { + // A region saved under a different certificate no longer applies to + // this DXCC entity — drop it instead of failing the whole save. + if (e.reason != LoTWProblem.STATION_REGION) throw e + val cleaned = normalized.copy(region = "", county = "") + config().stationFields(cleaned, bundle.info.dxcc) + writeStation(cleaned) + discardPreview() + return@withLock cleaned + } finally { bundle.p12.fill(0) bundle.password?.fill(0) } @@ -139,6 +150,10 @@ class LoTWUploadRepository internal constructor( mutex.withLock { discardPreview(); storage.delete("certificate") } } + override suspend fun stationMeta(dxcc: Int): LoTWStationMeta = withContext(Dispatchers.IO) { + config().stationMeta(dxcc) + } + override suspend fun audit(records: List): LoTWUploadAudit = withContext(Dispatchers.IO) { mutex.withLock { val signing = signingContext() diff --git a/core/data/src/main/java/com/rtbishop/look4sat/core/data/lotw/Pkcs12Reader.kt b/core/data/src/main/java/com/rtbishop/look4sat/core/data/lotw/Pkcs12Reader.kt new file mode 100644 index 00000000..87a6d9ff --- /dev/null +++ b/core/data/src/main/java/com/rtbishop/look4sat/core/data/lotw/Pkcs12Reader.kt @@ -0,0 +1,255 @@ +/* + * 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 . + */ + +package com.rtbishop.look4sat.core.data.lotw + +import java.io.ByteArrayInputStream +import java.security.KeyFactory +import java.security.PrivateKey +import java.security.cert.CertificateFactory +import java.security.cert.X509Certificate +import java.security.spec.PKCS8EncodedKeySpec +import javax.crypto.Cipher +import javax.crypto.SecretKeyFactory +import javax.crypto.spec.IvParameterSpec +import javax.crypto.spec.PBEKeySpec +import javax.crypto.spec.SecretKeySpec + +/** + * Minimal PKCS#12 reader for the PBES2/AES-CBC format that OpenSSL 3 and modern + * TQSL produce by default. Android's legacy bundled Bouncy Castle PKCS12 parser + * cannot handle PBES2, so we parse the DER structure ourselves and decrypt with + * the platform JCE (PBKDF2WithHmacSHA1/256 + AES/CBC), which ships on Android. + * + * Handles both layouts: + * - OpenSSL `-certpbe NONE`: plaintext certificate bags + a PBES2-shrouded key + * inside a plaintext `data` ContentInfo. + * - Modern TQSL: the certificate SafeContents wrapped in an `encryptedData` + * ContentInfo (PBES2), plus the PBES2-shrouded key in a plaintext `data` + * ContentInfo. + */ +internal object Pkcs12Reader { + + private val OID_DATA = byteArrayOf(0x2a, 0x86.toByte(), 0x48, 0x86.toByte(), 0xf7.toByte(), 0x0d, 0x01, 0x07, 0x01) + private val OID_ENCRYPTED_DATA = byteArrayOf(0x2a, 0x86.toByte(), 0x48, 0x86.toByte(), 0xf7.toByte(), 0x0d, 0x01, 0x07, 0x06) + private val OID_PKCS8_SHROUDED_KEY_BAG = byteArrayOf(0x2a, 0x86.toByte(), 0x48, 0x86.toByte(), 0xf7.toByte(), 0x0d, 0x01, 0x0c, 0x0a, 0x01, 0x02) + private val OID_CERT_BAG = byteArrayOf(0x2a, 0x86.toByte(), 0x48, 0x86.toByte(), 0xf7.toByte(), 0x0d, 0x01, 0x0c, 0x0a, 0x01, 0x03) + private val OID_X509_CERT = byteArrayOf(0x2a, 0x86.toByte(), 0x48, 0x86.toByte(), 0xf7.toByte(), 0x0d, 0x01, 0x09, 0x16, 0x01) + private val OID_PBES2 = byteArrayOf(0x2a, 0x86.toByte(), 0x48, 0x86.toByte(), 0xf7.toByte(), 0x0d, 0x01, 0x05, 0x0d) + private val OID_HMAC_SHA1 = byteArrayOf(0x2a, 0x86.toByte(), 0x48, 0x86.toByte(), 0xf7.toByte(), 0x0d, 0x02, 0x07) + private val OID_AES_256_CBC = byteArrayOf(0x60, 0x86.toByte(), 0x48, 0x01, 0x65, 0x03, 0x04, 0x01, 0x2a) + private val OID_AES_128_CBC = byteArrayOf(0x60, 0x86.toByte(), 0x48, 0x01, 0x65, 0x03, 0x04, 0x01, 0x02) + + fun read(bytes: ByteArray, password: CharArray): Pair { + val pfx = DerReader.read(bytes) + require(pfx.tag == 0x30) { "not a PFX" } + val pfxChildren = DerReader.children(pfx.content) + require(pfxChildren.size >= 2) { "PFX too small" } + // authSafe ContentInfo + val authSafeCi = DerReader.children(pfxChildren[1].content) + require(authSafeCi.size >= 2) { "authSafe malformed" } + // content: [0] EXPLICIT OCTET STRING -> AuthenticatedSafe + val explicit = DerReader.children(authSafeCi[1].content).first() + val octet = DerReader.read(explicit.content) + // octet.content is the AuthenticatedSafe body: a sequence of ContentInfo. + var privateKey: PrivateKey? = null + val certs = mutableListOf() + for (info in DerReader.children(octet.content)) { + val parts = DerReader.children(info.content) + if (parts.isEmpty()) continue + when { + parts[0].content.contentEquals(OID_DATA) -> + parseSafeContentsContent(parts, password, certs) { privateKey = it } + parts[0].content.contentEquals(OID_ENCRYPTED_DATA) -> + parseEncryptedData(parts, password, certs) + } + } + val key = privateKey ?: error("no private key bag found") + require(certs.isNotEmpty()) { "no certificate bag found" } + return key to certs[0] + } + + /** A plaintext ContentInfo: [0] EXPLICIT OCTET STRING -> full SafeContents DER. */ + private fun parseSafeContentsContent( + parts: List, + password: CharArray, + certs: MutableList, + onKey: (PrivateKey) -> Unit + ) { + val explicit = DerReader.children(parts[1].content).first() + parseSafeBags(explicit.content, password, certs, onKey) + } + + /** EncryptedData ContentInfo: version, EncryptedContentInfo{ oid, PBES2 alg, [0] IMPLICIT OCTET }. */ + private fun parseEncryptedData( + parts: List, + password: CharArray, + certs: MutableList + ) { + // parts[0] = encryptedData OID; parts[1] = [0] EXPLICIT EncryptedData SEQ + val explicit = DerReader.children(parts[1].content).first() + val eciParts = DerReader.children(explicit.content) + // eciParts = [version INT, EncryptedContentInfo SEQ] + require(eciParts.size >= 2) { "EncryptedData malformed" } + val eci = DerReader.children(eciParts[1].content) + // eci = [contentType OID, contentEncryptionAlgorithm, [0] IMPLICIT OCTET STRING] + require(eci.size >= 3) { "EncryptedContentInfo malformed" } + val alg = eci[1] + val ciphertext = eci[2].content // [0] IMPLICIT OCTET STRING -> raw bytes + val safeContents = decryptPbes2(alg, ciphertext, password) + // The decrypted SafeContents holds certificate bags. + parseSafeBags(safeContents, password, certs) {} + } + + /** Decrypt a PBES2-encrypted blob given its AlgorithmIdentifier. */ + private fun decryptPbes2(algorithm: Der, encrypted: ByteArray, password: CharArray): ByteArray { + val algParts = DerReader.children(algorithm.content) + require(algParts.size >= 2 && algParts[0].content.contentEquals(OID_PBES2)) { "not PBES2" } + // PBES2-params ::= SEQUENCE { kdf AlgorithmIdentifier, enc AlgorithmIdentifier } + val pbes2 = DerReader.children(algParts[1].content) + val kdf = DerReader.children(pbes2[0].content) + val kdfParams = DerReader.children(kdf[1].content) + val salt = kdfParams[0].content + val iterations = readInt(kdfParams[1].content) + require(iterations in 1..10_000_000) { "implausible PBKDF2 iteration count" } + // Optional PBKDF2-params elements: keyLength (INTEGER) and/or prf (SEQUENCE), + // in either order. Distinguish by DER tag — mistaking prf for keyLength yields + // an absurd key size and a PBKDF2 that runs for hours. + var keyBits = 256 + var prfName = "PBKDF2WithHmacSHA1" + for (i in 2 until kdfParams.size) { + val param = kdfParams[i] + when (param.tag) { + 0x02 -> keyBits = readInt(param.content) * 8 + 0x30 -> { + val prf = DerReader.children(param.content) + prfName = if (prf.isNotEmpty() && prf[0].content.contentEquals(OID_HMAC_SHA1)) + "PBKDF2WithHmacSHA1" else "PBKDF2WithHmacSHA256" + } + } + } + require(keyBits in 128..512) { "implausible PBKDF2 key size" } + val enc = DerReader.children(pbes2[1].content) + val encOid = enc[0].content + val iv = enc[1].content + require(encOid.contentEquals(OID_AES_256_CBC) || encOid.contentEquals(OID_AES_128_CBC)) { "unsupported cipher" } + + val spec = PBEKeySpec(password, salt, iterations, keyBits) + val secretKey = SecretKeyFactory.getInstance(prfName).generateSecret(spec) + // PBKDF2 factories return a PBE key; wrap the raw bytes as an AES key. + val aesKey = SecretKeySpec(secretKey.encoded, "AES") + val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding") + cipher.init(Cipher.DECRYPT_MODE, aesKey, IvParameterSpec(iv)) + return cipher.doFinal(encrypted) + } + + private fun parseSafeBags( + safeContentsDer: ByteArray, + password: CharArray, + certs: MutableList, + onKey: (PrivateKey) -> Unit + ) { + val safeContents = DerReader.read(safeContentsDer) + require(safeContents.tag == 0x30) { "SafeContents malformed" } + for (bag in DerReader.children(safeContents.content)) { + val bagParts = DerReader.children(bag.content) + if (bagParts.size < 2) continue + val bagOid = bagParts[0].content + val bagValue = DerReader.children(bagParts[1].content).first() + when { + bagOid.contentEquals(OID_PKCS8_SHROUDED_KEY_BAG) -> + onKey(decryptShroudedKeyBag(bagValue, password)) + bagOid.contentEquals(OID_CERT_BAG) -> parseCertBag(bagValue, certs) + } + } + } + + private fun decryptShroudedKeyBag(bagValue: Der, password: CharArray): PrivateKey { + // bagValue = the SafeBag value SEQ (EncryptedPrivateKeyInfo): children are + // [AlgorithmIdentifier, encryptedData OCTET STRING]. + val parts = DerReader.children(bagValue.content) + require(parts.size == 2) { "EncryptedPrivateKeyInfo malformed" } + val pkcs8 = decryptPbes2(parts[0], parts[1].content, password) + return KeyFactory.getInstance("RSA").generatePrivate(PKCS8EncodedKeySpec(pkcs8)) + } + + private fun parseCertBag(bagValue: Der, certs: MutableList) { + // bagValue = the SafeBag value SEQ (CertBag): children are + // [certType OID, [0] EXPLICIT OCTET STRING (full X.509 DER)]. + val parts = DerReader.children(bagValue.content) + if (parts.size < 2) return + if (!parts[0].content.contentEquals(OID_X509_CERT)) return + // [0] EXPLICIT -> OCTET STRING -> full X.509 certificate DER. + val explicit = DerReader.children(parts[1].content).first() + val certDer = explicit.content + val factory = CertificateFactory.getInstance("X.509") + certs.add(factory.generateCertificate(ByteArrayInputStream(certDer)) as X509Certificate) + } + + private fun readInt(bytes: ByteArray): Int { + var value = 0 + var start = 0 + if (bytes.size > 1 && bytes[0].toInt() == 0x00) start = 1 // strip leading zero for positive + for (i in start until bytes.size) value = (value shl 8) or (bytes[i].toInt() and 0xff) + return value + } + + /** Minimal DER element parser. */ + private data class Der(val tag: Int, val content: ByteArray) + + private object DerReader { + fun read(der: ByteArray, offset: Int = 0): Der { + require(offset < der.size) { "DER truncated" } + val tag = der[offset].toInt() and 0xff + var i = offset + 1 + var len = der[i].toInt() and 0xff + i++ + if (len and 0x80 != 0) { + val numBytes = len and 0x7f + len = 0 + repeat(numBytes) { + len = (len shl 8) or (der[i].toInt() and 0xff) + i++ + } + } + require(i + len <= der.size) { "DER length overflow" } + return Der(tag, der.copyOfRange(i, i + len)) + } + + fun children(content: ByteArray): List { + val out = mutableListOf() + var off = 0 + while (off < content.size) { + val d = read(content, off) + out.add(d) + val step = headerSize(content, off) + d.content.size + if (step <= 0) break // defensive: never spin on malformed input + off += step + } + return out + } + + private fun headerSize(der: ByteArray, offset: Int): Int { + var i = offset + 1 + var len = der[i].toInt() and 0xff + i++ + if (len and 0x80 != 0) i += len and 0x7f + return i - offset + } + } +} diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/ILoTWUploadRepository.kt b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/ILoTWUploadRepository.kt index 230fb8ca..24aaef77 100644 --- a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/ILoTWUploadRepository.kt +++ b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/ILoTWUploadRepository.kt @@ -9,6 +9,8 @@ interface ILoTWUploadRepository { suspend fun saveCertificatePassword(password: CharArray): LoTWCertificate suspend fun saveStation(station: LoTWStation): LoTWStation suspend fun removeCertificate() + /** Region field (State/Province/Prefecture…) and national CQZ/ITUZ map for a DXCC entity. */ + suspend fun stationMeta(dxcc: Int): LoTWStationMeta suspend fun audit(records: List): LoTWUploadAudit suspend fun prepare(records: List, resubmit: Boolean): LoTWUploadPreview suspend fun upload(previewId: String): LoTWUploadResult @@ -35,6 +37,25 @@ data class LoTWStation( val iota: String = "" ) +/** One (ITU:CQ) pair from a LoTW zonemap, e.g. Guangdong is 44:24. */ +data class LoTWZonePair(val itu: Int, val cq: Int) + +/** One selectable region code with the zones it maps to (cross-zone regions carry several pairs). */ +data class LoTWRegionOption(val code: String, val name: String, val zones: List) + +/** Country-dependent region field (US_STATE, CN_PROVINCE, …) with its selectable options. */ +data class LoTWRegionField(val id: String, val label: String, val options: List) + +/** + * Everything the station-location UI needs for one DXCC entity: + * the region field to show (null when the country has none) and the national + * zonemap (used to prefill CQZ/ITUZ when the country has a single zone pair). + */ +data class LoTWStationMeta( + val regionField: LoTWRegionField? = null, + val countryZones: List = emptyList() +) + data class LoTWUploadPreview( val id: String, val callsign: String, @@ -69,6 +90,7 @@ class LoTWOperationException(val reason: LoTWProblem, val detail: String = "") : enum class LoTWProblem { CERTIFICATE_PASSWORD, CERTIFICATE_INVALID, CERTIFICATE_EXPIRED, CERTIFICATE_MISSING, - STORAGE, EMPTY_SELECTION, CALLSIGN_MISMATCH, QSO_DATE, STATION_GRID, STATION_REGION, - STATION_ZONE, STATION_IOTA, MODE, BAND, SATELLITE, INVALID_CONTACT, LOCATION_MISMATCH, TOO_MANY_CONTACTS + CERTIFICATE_FORMAT, STORAGE, EMPTY_SELECTION, CALLSIGN_MISMATCH, QSO_DATE, STATION_GRID, + STATION_REGION, STATION_ZONE, STATION_IOTA, MODE, BAND, SATELLITE, INVALID_CONTACT, + LOCATION_MISMATCH, TOO_MANY_CONTACTS } diff --git a/core/presentation/src/main/res/values-zh/strings.xml b/core/presentation/src/main/res/values-zh/strings.xml index 91de84a7..c8b6f8bc 100644 --- a/core/presentation/src/main/res/values-zh/strings.xml +++ b/core/presentation/src/main/res/values-zh/strings.xml @@ -191,11 +191,15 @@ 本次同步 %1$d 条 QSO,预计还需 %2$d 秒 使用梅登黑德网格设置站点位置 + 删除 日志本 %1$d 条记录 — 本地通联与 LoTW 确认 暂无记录 — 在雷达页 Log 标签记录通联 关闭 - LoTW 上传 + 上传 + 上传到 LoTW + 确认上传 + LoTW 上传证书 证书已导入 · 台址网格 %1$s 未配置 — 导入 TrustedQSL 证书后即可上传通联 处理中… @@ -206,10 +210,12 @@ 证书密码错误,请检查 TQSL 导出 .p12 时设置的密码 证书已过期或尚未生效 不是有效的 LoTW 证书文件 + 该 .p12 是新版 PBES2/AES-256 格式(OpenSSL 3 / 新版 TQSL 导出),Android 无法直接读取。请用旧格式重新导出(TQSL 旧加密导出或 OpenSSL -legacy 转换)后再导入 导入失败,请重试 %1$s · DXCC %2$d · 有效期至 %3$s 移除证书 台址 + 未选择 网格(逗号分隔,可多格) 保存 关闭 diff --git a/core/presentation/src/main/res/values/strings.xml b/core/presentation/src/main/res/values/strings.xml index 6ca6bfdb..c5c0f47f 100644 --- a/core/presentation/src/main/res/values/strings.xml +++ b/core/presentation/src/main/res/values/strings.xml @@ -222,11 +222,15 @@ Syncing %1$d QSOs, ~%2$d s remaining Set station\'s position using locator + Delete Logbook %1$d records — local QSOs and LoTW confirmations No records yet — record QSOs from the Radar Log tab Close - LoTW upload + Upload + Upload to LoTW + Upload + LoTW upload certificate Certificate imported · station grid %1$s Not configured — import your TrustedQSL certificate to upload QSOs Working… @@ -237,10 +241,12 @@ Incorrect certificate password. Check the password you set when exporting the .p12 from TQSL. Certificate is expired or not yet valid. Not a valid LoTW certificate file. + This .p12 uses the new PBES2/AES-256 format (OpenSSL 3 / recent TQSL), which Android cannot read directly. Re-export it as a legacy format (TQSL "export with legacy encryption" or an OpenSSL -legacy conversion), then import again. Import failed. Try again. %1$s · DXCC %2$d · expires %3$s Remove certificate Station location + Not selected Grid(s), comma-separated Save Close diff --git a/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/LoTWUploadConfigDialog.kt b/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/LoTWUploadConfigDialog.kt index 12d6415d..a6c93b3e 100644 --- a/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/LoTWUploadConfigDialog.kt +++ b/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/LoTWUploadConfigDialog.kt @@ -13,27 +13,37 @@ import android.net.Uri import android.provider.OpenableColumns import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.background +import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ElevatedCard import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.KeyboardCapitalization @@ -43,7 +53,9 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.rtbishop.look4sat.core.domain.repository.LoTWCertificate import com.rtbishop.look4sat.core.domain.repository.LoTWStation +import com.rtbishop.look4sat.core.domain.repository.LoTWStationMeta import com.rtbishop.look4sat.core.presentation.CardButton +import com.rtbishop.look4sat.core.presentation.LocalSpacing import com.rtbishop.look4sat.core.presentation.R import com.rtbishop.look4sat.core.presentation.SharedDialog @@ -77,6 +89,7 @@ fun LoTWUploadCard( fun LoTWUploadConfigDialog( certificate: LoTWCertificate?, station: LoTWStation?, + stationMeta: LoTWStationMeta?, busy: Boolean, error: LoTWUploadError?, onDismiss: () -> Unit, @@ -90,6 +103,26 @@ fun LoTWUploadConfigDialog( var cqZone by remember { mutableStateOf(station?.cqZone.orEmpty()) } var ituZone by remember { mutableStateOf(station?.ituZone.orEmpty()) } var iota by remember { mutableStateOf(station?.iota.orEmpty()) } + var region by remember { mutableStateOf(station?.region.orEmpty()) } + // Countries whose national zonemap has exactly one pair (e.g. Germany, India) get + // their CQZ/ITUZ prefilled; multi-zone countries are left blank to avoid a wrong + // default that would mislead (e.g. Guangdong would show the Heilongjiang zone). + LaunchedEffect(stationMeta) { + val meta = stationMeta ?: return@LaunchedEffect + val zones = meta.countryZones + if (zones.size == 1 && cqZone.isBlank() && ituZone.isBlank()) { + cqZone = zones[0].cq.toString() + ituZone = zones[0].itu.toString() + } + // A region saved under a previous certificate (different DXCC) no longer + // applies — clear it so saving doesn't fail validation. + val metaRegion = meta.regionField + if (metaRegion != null && region.isNotBlank() && metaRegion.options.none { it.code == region }) { + region = "" + } + } + val regionField = stationMeta?.regionField + val selectedRegionName = regionField?.options?.firstOrNull { it.code == region }?.name.orEmpty() val context = LocalContext.current // Pick the file first, then ask for the password — matches normal usage. @@ -109,13 +142,17 @@ fun LoTWUploadConfigDialog( onCancel = onDismiss, onAccept = null ) { - if (busy) { - Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { - CircularProgressIndicator(modifier = Modifier.height(20.dp), strokeWidth = 2.dp) - Text(stringResource(R.string.prefs_lotw_upload_busy), fontSize = 13.sp) + Column( + modifier = Modifier.fillMaxWidth().padding(horizontal = LocalSpacing.current.large), + verticalArrangement = Arrangement.spacedBy(6.dp) + ) { + if (busy) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + CircularProgressIndicator(modifier = Modifier.height(20.dp), strokeWidth = 2.dp) + Text(stringResource(R.string.prefs_lotw_upload_busy), fontSize = 13.sp) + } } - } - if (certificate == null) { + if (certificate == null) { Text(stringResource(R.string.prefs_lotw_upload_cert_hint), fontSize = 13.sp) CardButton( onClick = { filePicker.launch(arrayOf("*/*")) }, @@ -130,6 +167,7 @@ fun LoTWUploadConfigDialog( LoTWUploadError.PASSWORD -> R.string.prefs_lotw_upload_error_password LoTWUploadError.EXPIRED -> R.string.prefs_lotw_upload_error_expired LoTWUploadError.INVALID_FILE -> R.string.prefs_lotw_upload_error_invalid + LoTWUploadError.FORMAT -> R.string.prefs_lotw_upload_error_format LoTWUploadError.UNKNOWN -> R.string.prefs_lotw_upload_error_unknown } ), @@ -185,6 +223,69 @@ fun LoTWUploadConfigDialog( keyboardOptions = androidx.compose.foundation.text.KeyboardOptions(capitalization = KeyboardCapitalization.Characters), modifier = Modifier.fillMaxWidth() ) + // Country-specific region field (US_STATE, CN_PROVINCE, …) shown only when + // the certificate's DXCC entity defines one; selecting it fills CQZ/ITUZ. + // Drawn as a plain Box (not OutlinedTextField): a read-only text field's + // internal gesture handler consumes the tap, so clickable never fires. + // Options expand inline inside the sheet (no Popup/Dialog window stacking). + if (regionField != null) { + var regionExpanded by remember { mutableStateOf(false) } + val fieldShape = MaterialTheme.shapes.extraSmall + Box( + modifier = Modifier + .fillMaxWidth() + .clip(fieldShape) + .background(MaterialTheme.colorScheme.surface) + .border(1.dp, MaterialTheme.colorScheme.outline, fieldShape) + .clickable { regionExpanded = !regionExpanded } + .padding(horizontal = 12.dp, vertical = 8.dp) + ) { + Column { + Text(regionField.label, fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurfaceVariant) + Spacer(modifier = Modifier.height(3.dp)) + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = if (region.isBlank()) { + stringResource(R.string.prefs_lotw_upload_region_hint) + } else { + "$region — $selectedRegionName" + }, + fontSize = 16.sp, + color = if (region.isBlank()) { + MaterialTheme.colorScheme.onSurfaceVariant + } else { + MaterialTheme.colorScheme.onSurface + }, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f) + ) + Text( + text = if (regionExpanded) "▴" else "▾", + fontSize = 14.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + AnimatedVisibility(visible = regionExpanded) { + LazyColumn(modifier = Modifier.fillMaxWidth().heightIn(max = 280.dp)) { + items(regionField.options, key = { it.code }) { option -> + DropdownMenuItem( + text = { Text("${option.code} — ${option.name}", fontSize = 13.sp) }, + onClick = { + region = option.code + regionExpanded = false + option.zones.firstOrNull()?.let { zone -> + cqZone = zone.cq.toString() + ituZone = zone.itu.toString() + } + } + ) + } + } + } + } Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { OutlinedTextField( value = cqZone, @@ -209,10 +310,11 @@ fun LoTWUploadConfigDialog( ) } CardButton( - onClick = { onSaveStation(LoTWStation(grid, cqZone, ituZone, "", "", iota)) }, + onClick = { onSaveStation(LoTWStation(grid, cqZone, ituZone, region, "", iota)) }, text = stringResource(R.string.prefs_lotw_upload_save), isEnabled = grid.isNotBlank() && !busy, modifier = Modifier.fillMaxWidth() ) + } } }