mirror of
https://github.com/atsunatsu/Look4Sat.git
synced 2026-10-02 03:15:37 +00:00
feat(lotw): PBES2 .p12 parsing, station region fields, upload error codes
This commit is contained in:
1 parent
5957f30af2
commit
3be2e4b9e8
8 files changed
+494
-13
No files matched your search
@@ -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.QsoRecord
|
||||||
import com.rtbishop.look4sat.core.domain.logbook.displayMode
|
import com.rtbishop.look4sat.core.domain.logbook.displayMode
|
||||||
import com.rtbishop.look4sat.core.domain.repository.LoTWProblem
|
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.LoTWStation
|
||||||
|
import com.rtbishop.look4sat.core.domain.repository.LoTWStationMeta
|
||||||
|
import com.rtbishop.look4sat.core.domain.repository.LoTWZonePair
|
||||||
import org.w3c.dom.Element
|
import org.w3c.dom.Element
|
||||||
import org.xml.sax.InputSource
|
import org.xml.sax.InputSource
|
||||||
import org.xml.sax.SAXException
|
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" }
|
private val spec = config.elements("sigspec").single { it.getAttribute("version") == "2.0" }
|
||||||
val stationOrder = spec.elements("tSTATION").single().childElements().map { it.tagName }
|
val stationOrder = spec.elements("tSTATION").single().childElements().map { it.tagName }
|
||||||
val contactOrder = spec.elements("tCONTACT").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 bands = config.elements("bands").single().elements("band")
|
||||||
private val satellites = config.elements("satellite").associateBy { it.getAttribute("name").uppercase(Locale.US) }
|
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()
|
private val modes = config.elements("modes").single().elements("mode").map { it.textContent }.toSet()
|
||||||
@@ -78,6 +84,39 @@ internal class LoTWConfig(input: InputStream) {
|
|||||||
return normalized
|
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<LoTWZonePair> = 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<String, String> {
|
fun stationFields(station: LoTWStation, dxcc: Int): Map<String, String> {
|
||||||
fun normalized(value: String) = value.trim().uppercase(Locale.US)
|
fun normalized(value: String) = value.trim().uppercase(Locale.US)
|
||||||
val grids = station.grid.split(',').map(::normalized).filter(String::isNotBlank).distinct()
|
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() }
|
val pageFields = config.elements("page").filter { it.getAttribute("dependency") == dxcc.toString() }
|
||||||
.flatMap { it.elements("pageField") }.map { it.textContent }
|
.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 primary = pageFields.firstOrNull { it in primaryRegionFields }
|
||||||
val secondary = pageFields.firstOrNull { it in setOf("US_COUNTY", "JA_CITY_GUN_KU") }
|
val secondary = pageFields.firstOrNull { it in secondaryRegionFields }
|
||||||
fun region(name: String?, value: String) {
|
fun region(name: String?, value: String) {
|
||||||
if (value.isBlank()) return
|
if (value.isBlank()) return
|
||||||
if (name == null) fail(LoTWProblem.STATION_REGION)
|
if (name == null) fail(LoTWProblem.STATION_REGION)
|
||||||
|
|||||||
@@ -17,16 +17,40 @@ internal data class LoTWKeyMaterial(val key: PrivateKey, val certificate: X509Ce
|
|||||||
companion object {
|
companion object {
|
||||||
fun read(bytes: ByteArray, password: CharArray, now: Long): LoTWKeyMaterial {
|
fun read(bytes: ByteArray, password: CharArray, now: Long): LoTWKeyMaterial {
|
||||||
if (bytes.isEmpty() || bytes.size > MAX_CERTIFICATE_BYTES) fail(LoTWProblem.CERTIFICATE_INVALID)
|
if (bytes.isEmpty() || bytes.size > MAX_CERTIFICATE_BYTES) fail(LoTWProblem.CERTIFICATE_INVALID)
|
||||||
|
val key: PrivateKey
|
||||||
|
val cert: X509Certificate
|
||||||
val store = try {
|
val store = try {
|
||||||
KeyStore.getInstance("PKCS12").apply { bytes.inputStream().use { load(it, password) } }
|
KeyStore.getInstance("PKCS12").apply { bytes.inputStream().use { load(it, password) } }
|
||||||
} catch (_: Exception) { fail(LoTWProblem.CERTIFICATE_PASSWORD) }
|
} catch (_: Exception) {
|
||||||
val aliases = Collections.list(store.aliases()).filter { store.isKeyEntry(it) }
|
// 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)
|
if (aliases.size != 1) fail(LoTWProblem.CERTIFICATE_INVALID)
|
||||||
val alias = aliases.single()
|
val alias = aliases.single()
|
||||||
val key = try { store.getKey(alias, password) as? PrivateKey }
|
key = try { ks.getKey(alias, password) as? PrivateKey }
|
||||||
catch (_: Exception) { fail(LoTWProblem.CERTIFICATE_PASSWORD) }
|
catch (_: Exception) { fail(LoTWProblem.CERTIFICATE_PASSWORD) }
|
||||||
?: fail(LoTWProblem.CERTIFICATE_INVALID)
|
?: fail(LoTWProblem.CERTIFICATE_INVALID)
|
||||||
val cert = store.getCertificate(alias) as? X509Certificate ?: fail(LoTWProblem.CERTIFICATE_INVALID)
|
cert = ks.getCertificate(alias) as? X509Certificate ?: fail(LoTWProblem.CERTIFICATE_INVALID)
|
||||||
|
}
|
||||||
if (key.algorithm != "RSA" || cert.publicKey.algorithm != "RSA") fail(LoTWProblem.CERTIFICATE_INVALID)
|
if (key.algorithm != "RSA" || cert.publicKey.algorithm != "RSA") fail(LoTWProblem.CERTIFICATE_INVALID)
|
||||||
try { cert.checkValidity(Date(now)) } catch (_: Exception) { fail(LoTWProblem.CERTIFICATE_EXPIRED) }
|
try { cert.checkValidity(Date(now)) } catch (_: Exception) { fail(LoTWProblem.CERTIFICATE_EXPIRED) }
|
||||||
val info = try { metadata(cert) } catch (_: Exception) { fail(LoTWProblem.CERTIFICATE_INVALID) }
|
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)
|
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 {
|
private fun metadata(cert: X509Certificate): LoTWCertificate {
|
||||||
val oid = byteArrayOf(0x2b, 0x06, 0x01, 0x04, 0x01, 0xe0.toByte(), 0x3c, 0x01, 0x01)
|
val oid = byteArrayOf(0x2b, 0x06, 0x01, 0x04, 0x01, 0xe0.toByte(), 0x3c, 0x01, 0x01)
|
||||||
val subject = DerValue.read(cert.subjectX500Principal.encoded).single()
|
val subject = DerValue.read(cert.subjectX500Principal.encoded).single()
|
||||||
|
|||||||
+17
-2
@@ -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.LoTWOperationException
|
||||||
import com.rtbishop.look4sat.core.domain.repository.LoTWProblem
|
import com.rtbishop.look4sat.core.domain.repository.LoTWProblem
|
||||||
import com.rtbishop.look4sat.core.domain.repository.LoTWStation
|
import com.rtbishop.look4sat.core.domain.repository.LoTWStation
|
||||||
|
import com.rtbishop.look4sat.core.domain.repository.LoTWStationMeta
|
||||||
import com.rtbishop.look4sat.core.domain.repository.LoTWUploadAudit
|
import com.rtbishop.look4sat.core.domain.repository.LoTWUploadAudit
|
||||||
import com.rtbishop.look4sat.core.domain.repository.LoTWUploadPreview
|
import com.rtbishop.look4sat.core.domain.repository.LoTWUploadPreview
|
||||||
import com.rtbishop.look4sat.core.domain.repository.LoTWUploadResult
|
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 stored = storage.read("certificate") ?: fail(LoTWProblem.CERTIFICATE_MISSING)
|
||||||
val bundle = try { readBundle(stored) } finally { stored.fill(0) }
|
val bundle = try { readBundle(stored) } finally { stored.fill(0) }
|
||||||
val normalized = station.normalized()
|
val normalized = station.normalized()
|
||||||
try { config().stationFields(normalized, bundle.info.dxcc) }
|
try {
|
||||||
finally {
|
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.p12.fill(0)
|
||||||
bundle.password?.fill(0)
|
bundle.password?.fill(0)
|
||||||
}
|
}
|
||||||
@@ -139,6 +150,10 @@ class LoTWUploadRepository internal constructor(
|
|||||||
mutex.withLock { discardPreview(); storage.delete("certificate") }
|
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<QsoRecord>): LoTWUploadAudit = withContext(Dispatchers.IO) {
|
override suspend fun audit(records: List<QsoRecord>): LoTWUploadAudit = withContext(Dispatchers.IO) {
|
||||||
mutex.withLock {
|
mutex.withLock {
|
||||||
val signing = signingContext()
|
val signing = signingContext()
|
||||||
|
|||||||
@@ -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 <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
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<PrivateKey, X509Certificate> {
|
||||||
|
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<X509Certificate>()
|
||||||
|
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<Der>,
|
||||||
|
password: CharArray,
|
||||||
|
certs: MutableList<X509Certificate>,
|
||||||
|
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<Der>,
|
||||||
|
password: CharArray,
|
||||||
|
certs: MutableList<X509Certificate>
|
||||||
|
) {
|
||||||
|
// 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<X509Certificate>,
|
||||||
|
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<X509Certificate>) {
|
||||||
|
// 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<Der> {
|
||||||
|
val out = mutableListOf<Der>()
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+24
-2
@@ -9,6 +9,8 @@ interface ILoTWUploadRepository {
|
|||||||
suspend fun saveCertificatePassword(password: CharArray): LoTWCertificate
|
suspend fun saveCertificatePassword(password: CharArray): LoTWCertificate
|
||||||
suspend fun saveStation(station: LoTWStation): LoTWStation
|
suspend fun saveStation(station: LoTWStation): LoTWStation
|
||||||
suspend fun removeCertificate()
|
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<QsoRecord>): LoTWUploadAudit
|
suspend fun audit(records: List<QsoRecord>): LoTWUploadAudit
|
||||||
suspend fun prepare(records: List<QsoRecord>, resubmit: Boolean): LoTWUploadPreview
|
suspend fun prepare(records: List<QsoRecord>, resubmit: Boolean): LoTWUploadPreview
|
||||||
suspend fun upload(previewId: String): LoTWUploadResult
|
suspend fun upload(previewId: String): LoTWUploadResult
|
||||||
@@ -35,6 +37,25 @@ data class LoTWStation(
|
|||||||
val iota: String = ""
|
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<LoTWZonePair>)
|
||||||
|
|
||||||
|
/** Country-dependent region field (US_STATE, CN_PROVINCE, …) with its selectable options. */
|
||||||
|
data class LoTWRegionField(val id: String, val label: String, val options: List<LoTWRegionOption>)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<LoTWZonePair> = emptyList()
|
||||||
|
)
|
||||||
|
|
||||||
data class LoTWUploadPreview(
|
data class LoTWUploadPreview(
|
||||||
val id: String,
|
val id: String,
|
||||||
val callsign: String,
|
val callsign: String,
|
||||||
@@ -69,6 +90,7 @@ class LoTWOperationException(val reason: LoTWProblem, val detail: String = "") :
|
|||||||
|
|
||||||
enum class LoTWProblem {
|
enum class LoTWProblem {
|
||||||
CERTIFICATE_PASSWORD, CERTIFICATE_INVALID, CERTIFICATE_EXPIRED, CERTIFICATE_MISSING,
|
CERTIFICATE_PASSWORD, CERTIFICATE_INVALID, CERTIFICATE_EXPIRED, CERTIFICATE_MISSING,
|
||||||
STORAGE, EMPTY_SELECTION, CALLSIGN_MISMATCH, QSO_DATE, STATION_GRID, STATION_REGION,
|
CERTIFICATE_FORMAT, STORAGE, EMPTY_SELECTION, CALLSIGN_MISMATCH, QSO_DATE, STATION_GRID,
|
||||||
STATION_ZONE, STATION_IOTA, MODE, BAND, SATELLITE, INVALID_CONTACT, LOCATION_MISMATCH, TOO_MANY_CONTACTS
|
STATION_REGION, STATION_ZONE, STATION_IOTA, MODE, BAND, SATELLITE, INVALID_CONTACT,
|
||||||
|
LOCATION_MISMATCH, TOO_MANY_CONTACTS
|
||||||
}
|
}
|
||||||
@@ -191,11 +191,15 @@
|
|||||||
<string name="lotw_sync_progress_qso">本次同步 %1$d 条 QSO,预计还需 %2$d 秒</string>
|
<string name="lotw_sync_progress_qso">本次同步 %1$d 条 QSO,预计还需 %2$d 秒</string>
|
||||||
<string name="prefs_locator_text">使用梅登黑德网格设置站点位置</string>
|
<string name="prefs_locator_text">使用梅登黑德网格设置站点位置</string>
|
||||||
|
|
||||||
|
<string name="btn_delete">删除</string>
|
||||||
<string name="prefs_logbook_title">日志本</string>
|
<string name="prefs_logbook_title">日志本</string>
|
||||||
<string name="prefs_logbook_count">%1$d 条记录 — 本地通联与 LoTW 确认</string>
|
<string name="prefs_logbook_count">%1$d 条记录 — 本地通联与 LoTW 确认</string>
|
||||||
<string name="prefs_logbook_empty">暂无记录 — 在雷达页 Log 标签记录通联</string>
|
<string name="prefs_logbook_empty">暂无记录 — 在雷达页 Log 标签记录通联</string>
|
||||||
<string name="prefs_logbook_close">关闭</string>
|
<string name="prefs_logbook_close">关闭</string>
|
||||||
<string name="prefs_lotw_upload_title">LoTW 上传</string>
|
<string name="prefs_logbook_upload">上传</string>
|
||||||
|
<string name="prefs_logbook_upload_title">上传到 LoTW</string>
|
||||||
|
<string name="prefs_logbook_upload_confirm">确认上传</string>
|
||||||
|
<string name="prefs_lotw_upload_title">LoTW 上传证书</string>
|
||||||
<string name="prefs_lotw_upload_configured">证书已导入 · 台址网格 %1$s</string>
|
<string name="prefs_lotw_upload_configured">证书已导入 · 台址网格 %1$s</string>
|
||||||
<string name="prefs_lotw_upload_not_configured">未配置 — 导入 TrustedQSL 证书后即可上传通联</string>
|
<string name="prefs_lotw_upload_not_configured">未配置 — 导入 TrustedQSL 证书后即可上传通联</string>
|
||||||
<string name="prefs_lotw_upload_busy">处理中…</string>
|
<string name="prefs_lotw_upload_busy">处理中…</string>
|
||||||
@@ -206,10 +210,12 @@
|
|||||||
<string name="prefs_lotw_upload_error_password">证书密码错误,请检查 TQSL 导出 .p12 时设置的密码</string>
|
<string name="prefs_lotw_upload_error_password">证书密码错误,请检查 TQSL 导出 .p12 时设置的密码</string>
|
||||||
<string name="prefs_lotw_upload_error_expired">证书已过期或尚未生效</string>
|
<string name="prefs_lotw_upload_error_expired">证书已过期或尚未生效</string>
|
||||||
<string name="prefs_lotw_upload_error_invalid">不是有效的 LoTW 证书文件</string>
|
<string name="prefs_lotw_upload_error_invalid">不是有效的 LoTW 证书文件</string>
|
||||||
|
<string name="prefs_lotw_upload_error_format">该 .p12 是新版 PBES2/AES-256 格式(OpenSSL 3 / 新版 TQSL 导出),Android 无法直接读取。请用旧格式重新导出(TQSL 旧加密导出或 OpenSSL -legacy 转换)后再导入</string>
|
||||||
<string name="prefs_lotw_upload_error_unknown">导入失败,请重试</string>
|
<string name="prefs_lotw_upload_error_unknown">导入失败,请重试</string>
|
||||||
<string name="prefs_lotw_upload_cert_info">%1$s · DXCC %2$d · 有效期至 %3$s</string>
|
<string name="prefs_lotw_upload_cert_info">%1$s · DXCC %2$d · 有效期至 %3$s</string>
|
||||||
<string name="prefs_lotw_upload_remove">移除证书</string>
|
<string name="prefs_lotw_upload_remove">移除证书</string>
|
||||||
<string name="prefs_lotw_upload_station_title">台址</string>
|
<string name="prefs_lotw_upload_station_title">台址</string>
|
||||||
|
<string name="prefs_lotw_upload_region_hint">未选择</string>
|
||||||
<string name="prefs_lotw_upload_grid">网格(逗号分隔,可多格)</string>
|
<string name="prefs_lotw_upload_grid">网格(逗号分隔,可多格)</string>
|
||||||
<string name="prefs_lotw_upload_save">保存</string>
|
<string name="prefs_lotw_upload_save">保存</string>
|
||||||
<string name="prefs_lotw_upload_close">关闭</string>
|
<string name="prefs_lotw_upload_close">关闭</string>
|
||||||
|
|||||||
@@ -222,11 +222,15 @@
|
|||||||
<string name="lotw_sync_progress_qso">Syncing %1$d QSOs, ~%2$d s remaining</string>
|
<string name="lotw_sync_progress_qso">Syncing %1$d QSOs, ~%2$d s remaining</string>
|
||||||
<string name="prefs_locator_text">Set station\'s position using locator</string>
|
<string name="prefs_locator_text">Set station\'s position using locator</string>
|
||||||
|
|
||||||
|
<string name="btn_delete">Delete</string>
|
||||||
<string name="prefs_logbook_title">Logbook</string>
|
<string name="prefs_logbook_title">Logbook</string>
|
||||||
<string name="prefs_logbook_count">%1$d records — local QSOs and LoTW confirmations</string>
|
<string name="prefs_logbook_count">%1$d records — local QSOs and LoTW confirmations</string>
|
||||||
<string name="prefs_logbook_empty">No records yet — record QSOs from the Radar Log tab</string>
|
<string name="prefs_logbook_empty">No records yet — record QSOs from the Radar Log tab</string>
|
||||||
<string name="prefs_logbook_close">Close</string>
|
<string name="prefs_logbook_close">Close</string>
|
||||||
<string name="prefs_lotw_upload_title">LoTW upload</string>
|
<string name="prefs_logbook_upload">Upload</string>
|
||||||
|
<string name="prefs_logbook_upload_title">Upload to LoTW</string>
|
||||||
|
<string name="prefs_logbook_upload_confirm">Upload</string>
|
||||||
|
<string name="prefs_lotw_upload_title">LoTW upload certificate</string>
|
||||||
<string name="prefs_lotw_upload_configured">Certificate imported · station grid %1$s</string>
|
<string name="prefs_lotw_upload_configured">Certificate imported · station grid %1$s</string>
|
||||||
<string name="prefs_lotw_upload_not_configured">Not configured — import your TrustedQSL certificate to upload QSOs</string>
|
<string name="prefs_lotw_upload_not_configured">Not configured — import your TrustedQSL certificate to upload QSOs</string>
|
||||||
<string name="prefs_lotw_upload_busy">Working…</string>
|
<string name="prefs_lotw_upload_busy">Working…</string>
|
||||||
@@ -237,10 +241,12 @@
|
|||||||
<string name="prefs_lotw_upload_error_password">Incorrect certificate password. Check the password you set when exporting the .p12 from TQSL.</string>
|
<string name="prefs_lotw_upload_error_password">Incorrect certificate password. Check the password you set when exporting the .p12 from TQSL.</string>
|
||||||
<string name="prefs_lotw_upload_error_expired">Certificate is expired or not yet valid.</string>
|
<string name="prefs_lotw_upload_error_expired">Certificate is expired or not yet valid.</string>
|
||||||
<string name="prefs_lotw_upload_error_invalid">Not a valid LoTW certificate file.</string>
|
<string name="prefs_lotw_upload_error_invalid">Not a valid LoTW certificate file.</string>
|
||||||
|
<string name="prefs_lotw_upload_error_format">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.</string>
|
||||||
<string name="prefs_lotw_upload_error_unknown">Import failed. Try again.</string>
|
<string name="prefs_lotw_upload_error_unknown">Import failed. Try again.</string>
|
||||||
<string name="prefs_lotw_upload_cert_info">%1$s · DXCC %2$d · expires %3$s</string>
|
<string name="prefs_lotw_upload_cert_info">%1$s · DXCC %2$d · expires %3$s</string>
|
||||||
<string name="prefs_lotw_upload_remove">Remove certificate</string>
|
<string name="prefs_lotw_upload_remove">Remove certificate</string>
|
||||||
<string name="prefs_lotw_upload_station_title">Station location</string>
|
<string name="prefs_lotw_upload_station_title">Station location</string>
|
||||||
|
<string name="prefs_lotw_upload_region_hint">Not selected</string>
|
||||||
<string name="prefs_lotw_upload_grid">Grid(s), comma-separated</string>
|
<string name="prefs_lotw_upload_grid">Grid(s), comma-separated</string>
|
||||||
<string name="prefs_lotw_upload_save">Save</string>
|
<string name="prefs_lotw_upload_save">Save</string>
|
||||||
<string name="prefs_lotw_upload_close">Close</string>
|
<string name="prefs_lotw_upload_close">Close</string>
|
||||||
|
|||||||
+103
-1
@@ -13,27 +13,37 @@ import android.net.Uri
|
|||||||
import android.provider.OpenableColumns
|
import android.provider.OpenableColumns
|
||||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
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.clickable
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.heightIn
|
||||||
import androidx.compose.foundation.layout.padding
|
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.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.DropdownMenuItem
|
||||||
import androidx.compose.material3.ElevatedCard
|
import androidx.compose.material3.ElevatedCard
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.OutlinedButton
|
import androidx.compose.material3.OutlinedButton
|
||||||
import androidx.compose.material3.OutlinedTextField
|
import androidx.compose.material3.OutlinedTextField
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.input.KeyboardCapitalization
|
import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||||
@@ -43,7 +53,9 @@ import androidx.compose.ui.unit.dp
|
|||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import com.rtbishop.look4sat.core.domain.repository.LoTWCertificate
|
import com.rtbishop.look4sat.core.domain.repository.LoTWCertificate
|
||||||
import com.rtbishop.look4sat.core.domain.repository.LoTWStation
|
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.CardButton
|
||||||
|
import com.rtbishop.look4sat.core.presentation.LocalSpacing
|
||||||
import com.rtbishop.look4sat.core.presentation.R
|
import com.rtbishop.look4sat.core.presentation.R
|
||||||
import com.rtbishop.look4sat.core.presentation.SharedDialog
|
import com.rtbishop.look4sat.core.presentation.SharedDialog
|
||||||
|
|
||||||
@@ -77,6 +89,7 @@ fun LoTWUploadCard(
|
|||||||
fun LoTWUploadConfigDialog(
|
fun LoTWUploadConfigDialog(
|
||||||
certificate: LoTWCertificate?,
|
certificate: LoTWCertificate?,
|
||||||
station: LoTWStation?,
|
station: LoTWStation?,
|
||||||
|
stationMeta: LoTWStationMeta?,
|
||||||
busy: Boolean,
|
busy: Boolean,
|
||||||
error: LoTWUploadError?,
|
error: LoTWUploadError?,
|
||||||
onDismiss: () -> Unit,
|
onDismiss: () -> Unit,
|
||||||
@@ -90,6 +103,26 @@ fun LoTWUploadConfigDialog(
|
|||||||
var cqZone by remember { mutableStateOf(station?.cqZone.orEmpty()) }
|
var cqZone by remember { mutableStateOf(station?.cqZone.orEmpty()) }
|
||||||
var ituZone by remember { mutableStateOf(station?.ituZone.orEmpty()) }
|
var ituZone by remember { mutableStateOf(station?.ituZone.orEmpty()) }
|
||||||
var iota by remember { mutableStateOf(station?.iota.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
|
val context = LocalContext.current
|
||||||
|
|
||||||
// Pick the file first, then ask for the password — matches normal usage.
|
// Pick the file first, then ask for the password — matches normal usage.
|
||||||
@@ -108,6 +141,10 @@ fun LoTWUploadConfigDialog(
|
|||||||
onDismissRequest = onDismiss,
|
onDismissRequest = onDismiss,
|
||||||
onCancel = onDismiss,
|
onCancel = onDismiss,
|
||||||
onAccept = null
|
onAccept = null
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(horizontal = LocalSpacing.current.large),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(6.dp)
|
||||||
) {
|
) {
|
||||||
if (busy) {
|
if (busy) {
|
||||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
@@ -130,6 +167,7 @@ fun LoTWUploadConfigDialog(
|
|||||||
LoTWUploadError.PASSWORD -> R.string.prefs_lotw_upload_error_password
|
LoTWUploadError.PASSWORD -> R.string.prefs_lotw_upload_error_password
|
||||||
LoTWUploadError.EXPIRED -> R.string.prefs_lotw_upload_error_expired
|
LoTWUploadError.EXPIRED -> R.string.prefs_lotw_upload_error_expired
|
||||||
LoTWUploadError.INVALID_FILE -> R.string.prefs_lotw_upload_error_invalid
|
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
|
LoTWUploadError.UNKNOWN -> R.string.prefs_lotw_upload_error_unknown
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
@@ -185,6 +223,69 @@ fun LoTWUploadConfigDialog(
|
|||||||
keyboardOptions = androidx.compose.foundation.text.KeyboardOptions(capitalization = KeyboardCapitalization.Characters),
|
keyboardOptions = androidx.compose.foundation.text.KeyboardOptions(capitalization = KeyboardCapitalization.Characters),
|
||||||
modifier = Modifier.fillMaxWidth()
|
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)) {
|
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = cqZone,
|
value = cqZone,
|
||||||
@@ -209,10 +310,11 @@ fun LoTWUploadConfigDialog(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
CardButton(
|
CardButton(
|
||||||
onClick = { onSaveStation(LoTWStation(grid, cqZone, ituZone, "", "", iota)) },
|
onClick = { onSaveStation(LoTWStation(grid, cqZone, ituZone, region, "", iota)) },
|
||||||
text = stringResource(R.string.prefs_lotw_upload_save),
|
text = stringResource(R.string.prefs_lotw_upload_save),
|
||||||
isEnabled = grid.isNotBlank() && !busy,
|
isEnabled = grid.isNotBlank() && !busy,
|
||||||
modifier = Modifier.fillMaxWidth()
|
modifier = Modifier.fillMaxWidth()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user