Compare commits

...
Author SHA1 Message Date
atsunatsu 800494b978 feat(radar): fill radar in split-screen via measured window size
The previous compact-mode trigger used currentWindowAdaptiveInfo(),
which reports the full-screen size class in split-screen / multi-window,
so the radar never enlarged there. Measure the actual window bounds with
BoxWithConstraints: compact (radar weight 1f fills all space, pager fixed
132dp strip) when maxWidth < 600dp && maxHeight < 480dp.

build: release version 4.4.7-ba7opf.17.6 (529)
2026-09-27 20:13:13 +08:00
atsunatsu f1091acbbd build: release version 4.4.7-ba7opf.17.4 (527) 2026-09-27 18:32:28 +08:00
atsunatsu 99bf3d34a9 feat(radar): enlarge radar in split-screen/small windows
When the window is vertical (narrow) and too short for split-screen or a
small floating window, give the radar card 1.6/0.6 weight instead of the
usual 1:1 so the square radar plot stays large. The pager card shrinks
but all its pages are scrollable, so nothing is clipped. Full-screen
vertical layout (enough height) is unchanged.
2026-09-27 18:32:24 +08:00
atsunatsu 1dd185971f fix(lotw): precise uploaded marking; surface skipped/unavailable counts
P2 — markUploaded now only marks the records that actually made it
into the accepted TQ8 batch (preview.submittedIds), never the whole
candidate list. Previously, un-signable records skipped by prepare()
were still marked UP after any accepted upload, falsely showing them
as uploaded.

P4 — surface why records did not upload:
- When audit finds nothing pending, distinguish unavailable records
  (invalid call/date — check logbook) from unknown-result records
  (not retried automatically) instead of a blanket 'no pending'.
- Upload preview dialogs (Log tab + logbook) show a breakdown line:
  N already uploaded/duplicate · N unknown result · N un-uploadable.
2026-09-27 17:46:21 +08:00
atsunatsu fe85703e56 fix(lotw): decouple QSO records from certificate station; skip un-signable records
- Remove LOCATION_MISMATCH check: the recorded myGrid never enters the
  TQ8 (the tSTATION grid is signed from the current certificate station),
  so a portable/ADIF-imported record with a different grid must not block
  upload. Logging and uploading are now fully decoupled from the
  certificate location.
- Fingerprint now covers the CONTACT only (call, date, band, mode,
  satellite, freq), excluding station fields: changing grid/zones/county/
  IOTA no longer invalidates ledger entries, so already-uploaded contacts
  are not re-queued and rejected as duplicates.
- prepare(): catch un-signable contacts per-record instead of aborting
  the whole batch; count them as skipped and upload the rest.
- audit(): count lotwUploaded records as uploaded (ledger-loss fallback).
2026-09-27 17:35:34 +08:00
atsunatsu caddba5b81 fix(settings): load LoTW certificate status at startup
The LoTW upload card read uiState.lotwCertificate which was only
populated when the config dialog was opened (LoadLoTWUploadStatus).
On first entry to the settings screen it stayed null, showing
'certificate not imported' even though a certificate was stored.
Load certificate + station once in init so the card is correct
from the first frame.
2026-09-27 17:07:02 +08:00
atsunatsu 52b733a450 fix(lotw): support more PBES2 variants, classify import errors for users
- Pkcs12Reader: AES-192/AES-128/DES-EDE3-CBC ciphers, PBKDF2
  SHA1/SHA256/SHA384/SHA512 PRFs, keyLength defaulting per cipher,
  error messages reduced to algorithm names
- LoTWKeyMaterial: map BadPadding -> wrong password, IllegalState
  (unsupported algorithm) -> format error with algorithm name,
  IllegalArgumentException -> invalid file
- Import dialog: user-facing messages with algorithm name embedded,
  no more raw OpenSSL/BoringSSL error strings
- Tests: AES-192, DES-EDE3 fixtures, wrong-password BadPadding check
2026-09-27 01:20:07 +08:00
19 changed files with 269 additions and 72 deletions

No files matched your search

@@ -29,8 +29,20 @@ internal data class LoTWKeyMaterial(val key: PrivateKey, val certificate: X509Ce
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)
} 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)
@@ -24,14 +24,6 @@ internal class LoTWSigner(private val config: LoTWConfig) {
if (date < key.info.firstQsoDate || (key.info.lastQsoDate.isNotBlank() && date > key.info.lastQsoDate) || record.startUtcMillis > now) {
fail(LoTWProblem.QSO_DATE, call)
}
val grids = buildList {
addAll(station.getValue("GRIDSQUARE").split(',').map(String::trim))
station["MY_VUCC_GRIDS"]?.split(',')?.map(String::trim)?.let(::addAll)
}.filter(String::isNotBlank)
if (record.myGrid.isNotBlank() && grids.none { grid ->
val local = record.myGrid.trim().uppercase(Locale.US)
grid.startsWith(local) || local.startsWith(grid)
}) fail(LoTWProblem.LOCATION_MISMATCH, call)
fun mhz(hz: Long?): String = hz?.let { BigDecimal.valueOf(it, 6).stripTrailingZeros().toPlainString() }.orEmpty()
val fields = linkedMapOf(
"BAND" to config.band(record.band, record.txFrequencyHz, true),
@@ -46,8 +38,12 @@ internal class LoTWSigner(private val config: LoTWConfig) {
"SAT_NAME" to if (record.isSatellite) config.satellite(record.satelliteName, date) else ""
).filterValues { it.isNotBlank() }
val signData = (config.stationOrder.map { station[it].orEmpty() } + config.contactOrder.map { fields[it].orEmpty() }).joinToString("")
// Fingerprint identifies the CONTACT only (call, date/time, band, mode,
// satellite, frequency). Station fields (grid, zones, county, IOTA) are
// deliberately excluded: changing the station location must not change
// the fingerprint, otherwise previously-uploaded contacts would lose
// their ledger entry and be re-uploaded (and rejected as duplicates).
val identity = field("CALL", key.info.callsign) + field("DXCC", key.info.dxcc.toString()) +
station.toSortedMap().entries.joinToString("") { field(it.key, it.value) } +
fields.entries.joinToString("") { field(it.key, it.value) }
val hash = MessageDigest.getInstance("SHA-256").digest(identity.toByteArray(Charsets.UTF_8)).joinToString("") { "%02x".format(it) }
return LoTWContact(record, fields, signData, hash)
@@ -167,7 +167,7 @@ class LoTWUploadRepository internal constructor(
coroutineContext.ensureActive()
when {
record.status != QsoStatus.COMPLETE -> unavailable++
record.lotwReceived -> uploaded++
record.lotwReceived || record.lotwUploaded -> uploaded++
else -> {
val contact = try {
signing.signer.contact(record, signing.key, signing.location, now())
@@ -196,11 +196,19 @@ class LoTWUploadRepository internal constructor(
val ledger = ledger()
var skipped = 0
var unknown = 0
var unavailable = 0
val unique = hashSetOf<String>()
val contacts = records.sortedBy { it.startUtcMillis }.mapNotNull { record ->
coroutineContext.ensureActive()
if (record.status != QsoStatus.COMPLETE || (record.lotwReceived && !resubmit)) { skipped++; return@mapNotNull null }
val contact = signing.signer.contact(record, signing.key, signing.location, now())
if (record.status != QsoStatus.COMPLETE || ((record.lotwReceived || record.lotwUploaded) && !resubmit)) { skipped++; return@mapNotNull null }
val contact = try {
signing.signer.contact(record, signing.key, signing.location, now())
} catch (_: LoTWOperationException) {
// One un-signable record must not abort the whole batch:
// skip it, count it, and let the rest upload.
unavailable++
return@mapNotNull null
}
val previous = ledger[contact.fingerprint]
when {
(previous == "accepted" && !resubmit) || !unique.add(contact.fingerprint) -> { skipped++; null }
@@ -214,7 +222,9 @@ class LoTWUploadRepository internal constructor(
contacts.firstOrNull()?.record?.let { utc(it.startUtcMillis, "yyyy-MM-dd HH:mm:ss") }.orEmpty(),
contacts.lastOrNull()?.record?.let { utc(it.startUtcMillis, "yyyy-MM-dd HH:mm:ss") }.orEmpty(),
contacts.map { "${utc(it.record.startUtcMillis, "MM-dd HH:mm")} ${it.record.theirCallsign} ${it.fields["MODE"]} ${it.fields["SAT_NAME"].orEmpty()}" },
unknown
unknown,
unavailable,
contacts.map { it.record.id }
)
if (contacts.isNotEmpty()) pending = Pending(
preview,
@@ -51,9 +51,15 @@ internal object Pkcs12Reader {
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_PBKDF2 = byteArrayOf(0x2a, 0x86.toByte(), 0x48, 0x86.toByte(), 0xf7.toByte(), 0x0d, 0x01, 0x05, 0x0c)
private val OID_HMAC_SHA1 = byteArrayOf(0x2a, 0x86.toByte(), 0x48, 0x86.toByte(), 0xf7.toByte(), 0x0d, 0x02, 0x07)
private val OID_HMAC_SHA256 = byteArrayOf(0x2a, 0x86.toByte(), 0x48, 0x86.toByte(), 0xf7.toByte(), 0x0d, 0x02, 0x09)
private val OID_HMAC_SHA384 = byteArrayOf(0x2a, 0x86.toByte(), 0x48, 0x86.toByte(), 0xf7.toByte(), 0x0d, 0x02, 0x0a)
private val OID_HMAC_SHA512 = byteArrayOf(0x2a, 0x86.toByte(), 0x48, 0x86.toByte(), 0xf7.toByte(), 0x0d, 0x02, 0x0b)
private val OID_AES_256_CBC = byteArrayOf(0x60, 0x86.toByte(), 0x48, 0x01, 0x65, 0x03, 0x04, 0x01, 0x2a)
private val OID_AES_192_CBC = byteArrayOf(0x60, 0x86.toByte(), 0x48, 0x01, 0x65, 0x03, 0x04, 0x01, 0x16)
private val OID_AES_128_CBC = byteArrayOf(0x60, 0x86.toByte(), 0x48, 0x01, 0x65, 0x03, 0x04, 0x01, 0x02)
private val OID_DES_EDE3_CBC = byteArrayOf(0x2a, 0x86.toByte(), 0x48, 0x86.toByte(), 0xf7.toByte(), 0x0d, 0x03, 0x07)
fun read(bytes: ByteArray, password: CharArray): Pair<PrivateKey, X509Certificate> {
val pfx = DerReader.read(bytes)
@@ -123,6 +129,11 @@ internal object Pkcs12Reader {
// PBES2-params ::= SEQUENCE { kdf AlgorithmIdentifier, enc AlgorithmIdentifier }
val pbes2 = DerReader.children(algParts[1].content)
val kdf = DerReader.children(pbes2[0].content)
// KDF must be PBKDF2 (OpenSSL 3 also supports scrypt — not available on the
// platform JCE, so report it by name instead of a generic failure).
if (kdf.isEmpty() || !kdf[0].content.contentEquals(OID_PBKDF2)) {
error(oidName(kdf.firstOrNull()?.content))
}
val kdfParams = DerReader.children(kdf[1].content)
val salt = kdfParams[0].content
val iterations = readInt(kdfParams[1].content)
@@ -130,7 +141,7 @@ internal object Pkcs12Reader {
// 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 keyBits = -1 // -1 = not written; defaulted below from the cipher
var prfName = "PBKDF2WithHmacSHA1"
for (i in 2 until kdfParams.size) {
val param = kdfParams[i]
@@ -138,26 +149,61 @@ internal object Pkcs12Reader {
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"
prfName = when {
prf.isEmpty() || prf[0].content.contentEquals(OID_HMAC_SHA1) -> "PBKDF2WithHmacSHA1"
prf[0].content.contentEquals(OID_HMAC_SHA256) -> "PBKDF2WithHmacSHA256"
prf[0].content.contentEquals(OID_HMAC_SHA384) -> "PBKDF2WithHmacSHA384"
prf[0].content.contentEquals(OID_HMAC_SHA512) -> "PBKDF2WithHmacSHA512"
else -> error(oidName(prf[0].content))
}
}
}
}
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 (cipherName, aesKeyBits) = when {
encOid.contentEquals(OID_AES_256_CBC) -> "AES/CBC/PKCS5Padding" to 256
encOid.contentEquals(OID_AES_192_CBC) -> "AES/CBC/PKCS5Padding" to 192
encOid.contentEquals(OID_AES_128_CBC) -> "AES/CBC/PKCS5Padding" to 128
encOid.contentEquals(OID_DES_EDE3_CBC) -> "DESede/CBC/PKCS5Padding" to 192
else -> error(oidName(encOid))
}
val finalKeyBits = if (keyBits > 0) keyBits else aesKeyBits
require(finalKeyBits in 128..512) { "implausible PBKDF2 key size" }
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))
val spec = PBEKeySpec(password, salt, iterations, finalKeyBits)
val secretKey = try {
SecretKeyFactory.getInstance(prfName).generateSecret(spec)
} catch (e: java.security.NoSuchAlgorithmException) {
// e.g. PBKDF2WithHmacSHA512 needs API 26+; surface the real reason.
error(prfName.replace("PBKDF2WithHmac", "PBKDF2-HMAC-") + " (needs Android 8.0+)")
}
val cipher = Cipher.getInstance(cipherName)
cipher.init(
Cipher.DECRYPT_MODE,
if (cipherName == "AES/CBC/PKCS5Padding") SecretKeySpec(secretKey.encoded, "AES")
else SecretKeySpec(secretKey.encoded, "DESede"),
IvParameterSpec(iv)
)
return cipher.doFinal(encrypted)
}
/** Human-readable name for a known algorithm OID. */
private fun oidName(oid: ByteArray?): String = when {
oid == null -> "unknown algorithm"
oid.contentEquals(OID_PBKDF2) -> "PBKDF2"
oid.contentEquals(OID_HMAC_SHA1) -> "PBKDF2-HMAC-SHA1"
oid.contentEquals(OID_HMAC_SHA256) -> "PBKDF2-HMAC-SHA256"
oid.contentEquals(OID_HMAC_SHA384) -> "PBKDF2-HMAC-SHA384"
oid.contentEquals(OID_HMAC_SHA512) -> "PBKDF2-HMAC-SHA512"
oid.contentEquals(OID_AES_256_CBC) -> "AES-256-CBC"
oid.contentEquals(OID_AES_192_CBC) -> "AES-192-CBC"
oid.contentEquals(OID_AES_128_CBC) -> "AES-128-CBC"
oid.contentEquals(OID_DES_EDE3_CBC) -> "DES-EDE3-CBC"
else -> "unknown algorithm"
}
private fun parseSafeBags(
safeContentsDer: ByteArray,
password: CharArray,
@@ -36,6 +36,67 @@ class Pkcs12ReaderTest {
private fun fixture(): ByteArray =
javaClass.classLoader!!.getResourceAsStream("test_pbes2.p12")!!.use { it.readBytes() }
private fun fixture(name: String): ByteArray =
javaClass.classLoader!!.getResourceAsStream(name)!!.use { it.readBytes() }
@Test
fun readsAes192Variant() {
val (key, cert) = Pkcs12Reader.read(fixture("test_pbes2_aes192.p12"), "testpass123".toCharArray())
assertEquals("RSA", key.algorithm)
assertEquals("RSA", cert.publicKey.algorithm)
assertTrue("key/cert pair", keyMatches(key, cert))
}
@Test
fun readsDesEde3CbcVariant() {
val (key, cert) = Pkcs12Reader.read(fixture("test_pbes2_desede3.p12"), "testpass123".toCharArray())
assertEquals("RSA", key.algorithm)
assertEquals("RSA", cert.publicKey.algorithm)
assertTrue("key/cert pair", keyMatches(key, cert))
}
@Test
fun unsupportedCipherNamesTheAlgorithm() {
// A valid PBES2 file re-encrypted with an unsupported cipher must report the
// algorithm name (so the user can see exactly what to re-export with).
val fixtureBytes = fixture("test_pbes2.p12")
val e = assertThrows(IllegalStateException::class.java) {
// Simulate: patch the AES-256-CBC OID inside the file to an unknown OID.
val bogus = ByteArray(fixtureBytes.size) { fixtureBytes[it] }
// find AES-256-CBC OID bytes 0x60 86 48 01 65 03 04 01 2a
val oid = byteArrayOf(0x60, 0x86.toByte(), 0x48, 0x01, 0x65, 0x03, 0x04, 0x01, 0x2a)
var idx = -1
outer@ for (i in 0..bogus.size - oid.size) {
for (j in oid.indices) if (bogus[i + j] != oid[j]) continue@outer
idx = i; break
}
require(idx >= 0) { "AES-256-CBC OID not found in fixture" }
bogus[idx] = 0x7f.toByte() // corrupt the OID tag byte → unknown cipher
Pkcs12Reader.read(bogus, "testpass123".toCharArray())
}
assertTrue("mentions algorithm", e.message.orEmpty().contains("algorithm"))
}
private fun keyMatches(key: java.security.PrivateKey, cert: java.security.cert.X509Certificate): Boolean {
val challenge = "Look4Sat LoTW certificate key check".toByteArray(Charsets.US_ASCII)
val signed = Signature.getInstance("SHA1withRSA").run {
initSign(key); update(challenge); sign()
}
return Signature.getInstance("SHA1withRSA").run {
initVerify(cert); update(challenge); verify(signed)
}
}
@Test
fun wrongPasswordFailsWithBadPadding() {
// A wrong password must surface as a decryption failure (BadPadding), which
// LoTWKeyMaterial maps to CERTIFICATE_PASSWORD — not a format error.
val e = assertThrows(Exception::class.java) {
Pkcs12Reader.read(fixture(), "definitely-wrong-password".toCharArray())
}
assertTrue("BadPadding", e is javax.crypto.BadPaddingException)
}
@Test
fun readsPbes2KeyAndCertificate() {
val (key, cert) = Pkcs12Reader.read(fixture(), "testpass123".toCharArray())
Binary file not shown.
Binary file not shown.
@@ -66,7 +66,12 @@ data class LoTWUploadPreview(
val firstUtc: String,
val lastUtc: String,
val contacts: List<String>,
val unknownSkipped: Int = 0
val unknownSkipped: Int = 0,
/** Un-signable records (invalid call/date/…) skipped instead of aborting the batch. */
val unavailableSkipped: Int = 0,
/** Ids of the records that actually made it into this TQ8 batch. Only these
* may be marked "uploaded" after an accepted POST — never the full candidate list. */
val submittedIds: List<Long> = emptyList()
)
/** Local comparison against downloaded LoTW receipt flags and this app's durable upload receipts. */
@@ -210,7 +210,8 @@
<string name="prefs_lotw_upload_error_password">证书密码错误,请检查 TQSL 导出 .p12 时设置的密码</string>
<string name="prefs_lotw_upload_error_expired">证书已过期或尚未生效</string>
<string name="prefs_lotw_upload_error_invalid">不是有效的 LoTW 证书文件</string>
<string name="prefs_lotw_upload_error_format">该 .p12 是新版 PBES2/AES-256 格式(OpenSSL 3 / 新版 TQSL 导出),Android 无法直接读取。请用旧格式重新导出(TQSL 旧加密导出或 OpenSSL -legacy 转换)后再导入</string>
<string name="prefs_lotw_upload_error_format">不支持的 .p12 加密,请用 TQSL 默认加密重新导出后再试</string>
<string name="prefs_lotw_upload_error_format_alg">不支持的 .p12 加密(%1$s),请用 TQSL 默认加密重新导出后再试</string>
<string name="prefs_lotw_upload_error_unknown">导入失败,请重试</string>
<string name="prefs_lotw_upload_cert_info">%1$s · DXCC %2$d · 有效期至 %3$s</string>
<string name="prefs_lotw_upload_remove">移除证书</string>
@@ -241,7 +241,8 @@
<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_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_format">Unsupported .p12 encryption. Re-export from TQSL and try again.</string>
<string name="prefs_lotw_upload_error_format_alg">Unsupported .p12 encryption (%1$s). Re-export from TQSL and 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_remove">Remove certificate</string>
@@ -294,6 +294,14 @@ private fun UploadPreviewDialog(
}.parse(it).time }.getOrDefault(System.currentTimeMillis())
}))
}Z", fontSize = 13.sp)
if (preview.skipped > 0 || preview.unknownSkipped > 0 || preview.unavailableSkipped > 0) {
val parts = buildList {
if (preview.skipped > 0) add("${preview.skipped} already uploaded/duplicate")
if (preview.unknownSkipped > 0) add("${preview.unknownSkipped} unknown result")
if (preview.unavailableSkipped > 0) add("${preview.unavailableSkipped} un-uploadable")
}
Text(parts.joinToString(" · "), fontSize = 12.sp, color = MaterialTheme.colorScheme.error)
}
Text(preview.contacts.joinToString("\n") { it }, fontSize = 12.sp, maxLines = 8)
}
},
@@ -160,11 +160,18 @@ class LogViewModel(
val pending = all.filter { !it.lotwConfirmed && it.status == QsoStatus.COMPLETE }
val audit = lotwUploadRepository.audit(pending)
if (audit.pending == 0) {
_uiState.update { it.copy(busy = false, message = "No pending QSOs to upload") }
val msg = when {
audit.unavailable > 0 -> "${audit.unavailable} QSO(s) can't be uploaded (invalid call/date — check the logbook)"
audit.unknown > 0 -> "${audit.unknown} QSO(s) had an unknown upload result — not retried automatically"
else -> "No pending QSOs to upload"
}
_uiState.update { it.copy(busy = false, message = msg) }
return@launch
}
val preview = lotwUploadRepository.prepare(pending, false)
lastUploadedIds = pending.map { it.id }
// Only the records that actually made it into the TQ8 may be
// marked uploaded later — never the whole candidate list.
lastUploadedIds = preview.submittedIds
_uiState.update { it.copy(busy = false, preview = preview) }
} catch (e: LoTWOperationException) {
_uiState.update { it.copy(busy = false, message = "Upload unavailable: ${e.reason}") }
@@ -30,12 +30,14 @@ import androidx.compose.animation.core.tween
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.pager.HorizontalPager
@@ -74,7 +76,6 @@ import com.rtbishop.look4sat.core.presentation.TimerRow
import com.rtbishop.look4sat.core.presentation.TopBar
import com.rtbishop.look4sat.core.presentation.formatFrequency
import com.rtbishop.look4sat.core.presentation.getDefaultPass
import com.rtbishop.look4sat.core.presentation.isVerticalLayout
import com.rtbishop.look4sat.core.presentation.layoutPadding
import kotlinx.coroutines.launch
import kotlinx.coroutines.delay
@@ -87,6 +88,9 @@ private enum class RadarPage(val title: String) {
Sstv("SSTV")
}
/** Compact pager strip height in split-screen / small windows. */
private val COMPACT_PAGER_HEIGHT = 132.dp
@Composable
fun RadarDestination(navigateUp: () -> Unit) {
val context = LocalContext.current
@@ -172,36 +176,54 @@ private fun RadarScreen(
time = it.time
)
}
Column(
BoxWithConstraints(
modifier = Modifier
.layoutPadding()
.padding(bottom = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding())
.keepScreenOn(),
verticalArrangement = Arrangement.spacedBy(6.dp)
.keepScreenOn()
) {
val isVertical = isVerticalLayout()
if (isVertical) {
TopBar {
IconCard(action = navigateUp, resId = R.drawable.ic_back)
TimerRow(timeString = uiState.currentTime, isTimeAos = uiState.isTimeAos)
IconCard(action = addToCalendar, resId = R.drawable.ic_calendar)
// Measure the ACTUAL window constraints instead of the window size
// class: currentWindowAdaptiveInfo() often reports the full-screen
// size in split-screen / multi-window, so the compact branch never
// triggered there. maxHeight/maxWidth are the real window bounds.
val isVertical = maxWidth < 600.dp
// In split-screen / small windows the vertical space is scarce: let the
// radar card fill ALL remaining space so the square plot is as large as
// possible, and give the pager a compact fixed strip — its pages are all
// scrollable, so nothing gets clipped. Full-screen layouts keep the 1:1 split.
val compact = isVertical && maxHeight < 480.dp
Column(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.spacedBy(6.dp)
) {
if (isVertical) {
TopBar {
IconCard(action = navigateUp, resId = R.drawable.ic_back)
TimerRow(timeString = uiState.currentTime, isTimeAos = uiState.isTimeAos)
IconCard(action = addToCalendar, resId = R.drawable.ic_calendar)
}
TopBar { NextPassRow(pass = upcomingPass, isUtc = uiState.isUtc) }
} else {
TopBar {
IconCard(action = navigateUp, resId = R.drawable.ic_back)
TimerRow(timeString = uiState.currentTime, isTimeAos = uiState.isTimeAos)
NextPassRow(pass = upcomingPass, modifier = Modifier.weight(1f), isUtc = uiState.isUtc)
IconCard(action = addToCalendar, resId = R.drawable.ic_calendar)
}
}
TopBar { NextPassRow(pass = upcomingPass, isUtc = uiState.isUtc) }
} else {
TopBar {
IconCard(action = navigateUp, resId = R.drawable.ic_back)
TimerRow(timeString = uiState.currentTime, isTimeAos = uiState.isTimeAos)
NextPassRow(pass = upcomingPass, modifier = Modifier.weight(1f), isUtc = uiState.isUtc)
IconCard(action = addToCalendar, resId = R.drawable.ic_calendar)
}
}
if (isVertical) {
RadarCard(uiState, trackB, trackBPosition, Modifier.weight(1f))
PagerCard(uiState, onAction, logViewModel, requestMicPermission, Modifier.weight(1f))
} else {
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
RadarCard(uiState, trackB, trackBPosition, Modifier.weight(1f))
PagerCard(uiState, onAction, logViewModel, requestMicPermission, Modifier.weight(1f))
if (isVertical) {
if (compact) {
RadarCard(uiState, trackB, trackBPosition, Modifier.weight(1f))
PagerCard(uiState, onAction, logViewModel, requestMicPermission, Modifier.height(COMPACT_PAGER_HEIGHT))
} else {
RadarCard(uiState, trackB, trackBPosition, Modifier.weight(1f))
PagerCard(uiState, onAction, logViewModel, requestMicPermission, Modifier.weight(1f))
}
} else {
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
RadarCard(uiState, trackB, trackBPosition, Modifier.weight(1f))
PagerCard(uiState, onAction, logViewModel, requestMicPermission, Modifier.weight(1f))
}
}
}
}
@@ -92,6 +92,7 @@ fun LoTWUploadConfigDialog(
stationMeta: LoTWStationMeta?,
busy: Boolean,
error: LoTWUploadError?,
errorDetail: String = "",
onDismiss: () -> Unit,
onImport: (ByteArray, CharArray) -> Unit,
onRemove: () -> Unit,
@@ -162,15 +163,20 @@ fun LoTWUploadConfigDialog(
)
error?.let {
Text(
text = stringResource(
when (it) {
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
}
),
text = when {
it == LoTWUploadError.FORMAT && errorDetail.isNotBlank() ->
stringResource(R.string.prefs_lotw_upload_error_format_alg, errorDetail)
it == LoTWUploadError.FORMAT -> stringResource(R.string.prefs_lotw_upload_error_format)
else -> stringResource(
when (it) {
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.UNKNOWN -> R.string.prefs_lotw_upload_error_unknown
LoTWUploadError.FORMAT -> R.string.prefs_lotw_upload_error_format
}
)
},
color = MaterialTheme.colorScheme.error,
fontSize = 12.sp
)
@@ -145,6 +145,14 @@ private fun LogbookUploadPreviewDialog(
"${preview.count} QSO(s) · ${preview.firstUtc} – ${preview.lastUtc}",
fontSize = 13.sp
)
if (preview.skipped > 0 || preview.unknownSkipped > 0 || preview.unavailableSkipped > 0) {
val parts = buildList {
if (preview.skipped > 0) add("${preview.skipped} already uploaded/duplicate")
if (preview.unknownSkipped > 0) add("${preview.unknownSkipped} unknown result")
if (preview.unavailableSkipped > 0) add("${preview.unavailableSkipped} un-uploadable")
}
Text(parts.joinToString(" · "), fontSize = 12.sp, color = MaterialTheme.colorScheme.error)
}
Text(preview.contacts.joinToString("\n") { it }, fontSize = 12.sp, maxLines = 8)
}
},
@@ -282,6 +282,7 @@ private fun SettingsScreen(uiState: SettingsState, onAction: (SettingsAction) ->
stationMeta = uiState.lotwStationMeta,
busy = uiState.lotwUploadBusy,
error = uiState.lotwUploadError,
errorDetail = uiState.lotwUploadErrorDetail,
onDismiss = { dialogs.lotwUpload = false },
onImport = { bytes, password -> onAction(SettingsAction.ImportLoTWCertificate(bytes, password)) },
onRemove = { onAction(SettingsAction.RemoveLoTWCertificate) },
@@ -79,6 +79,8 @@ data class SettingsState(
val lotwUploadBusy: Boolean = false,
/** Last certificate import outcome; shown inside the upload config dialog. */
val lotwUploadError: LoTWUploadError? = null,
/** Parser detail for FORMAT errors (e.g. the unsupported algorithm name). */
val lotwUploadErrorDetail: String = "",
/** One-click logbook upload: prepared preview awaiting confirmation. */
val logbookPreview: com.rtbishop.look4sat.core.domain.repository.LoTWUploadPreview? = null,
val logbookUploadBusy: Boolean = false,
@@ -163,6 +163,10 @@ class SettingsViewModel(
_uiState.update { it.copy(logbookRecords = records) }
}
}
// Load the LoTW upload certificate + station once at startup so the
// settings card reflects the real state on first frame (previously it
// stayed "not imported" until the config dialog was opened).
loadLoTWUploadStatus()
}
@@ -363,11 +367,18 @@ class SettingsViewModel(
val pending = all.filter { !it.lotwConfirmed && it.status == com.rtbishop.look4sat.core.domain.logbook.QsoStatus.COMPLETE }
val audit = lotwUploadRepository.audit(pending)
if (audit.pending == 0) {
_uiState.update { it.copy(logbookUploadBusy = false, logbookUploadMessage = "No pending QSOs to upload") }
val msg = when {
audit.unavailable > 0 -> "${audit.unavailable} QSO(s) can't be uploaded (invalid call/date — check the logbook)"
audit.unknown > 0 -> "${audit.unknown} QSO(s) had an unknown upload result — not retried automatically"
else -> "No pending QSOs to upload"
}
_uiState.update { it.copy(logbookUploadBusy = false, logbookUploadMessage = msg) }
return@launch
}
val preview = lotwUploadRepository.prepare(pending, false)
lastLogbookUploadIds = pending.map { it.id }
// Only the records that actually made it into the TQ8 may be
// marked uploaded later — never the whole candidate list.
lastLogbookUploadIds = preview.submittedIds
_uiState.update { it.copy(logbookUploadBusy = false, logbookPreview = preview) }
} catch (e: com.rtbishop.look4sat.core.domain.repository.LoTWOperationException) {
_uiState.update { it.copy(logbookUploadBusy = false, logbookUploadMessage = "Upload unavailable: ${e.reason}") }
@@ -423,9 +434,9 @@ class SettingsViewModel(
com.rtbishop.look4sat.core.domain.repository.LoTWProblem.CERTIFICATE_FORMAT -> LoTWUploadError.FORMAT
else -> LoTWUploadError.INVALID_FILE
}
_uiState.update { it.copy(lotwUploadBusy = false, lotwUploadError = error) }
_uiState.update { it.copy(lotwUploadBusy = false, lotwUploadError = error, lotwUploadErrorDetail = e.detail) }
} catch (_: Exception) {
_uiState.update { it.copy(lotwUploadBusy = false, lotwUploadError = LoTWUploadError.UNKNOWN) }
_uiState.update { it.copy(lotwUploadBusy = false, lotwUploadError = LoTWUploadError.UNKNOWN, lotwUploadErrorDetail = "") }
}
}
}
+2 -2
View File
@@ -1,8 +1,8 @@
[versions]
#noinspection UnusedVersionCatalogEntry
appVersionCode = "523"
appVersionCode = "529"
#noinspection UnusedVersionCatalogEntry
appVersionName = "4.4.7-ba7opf.17"
appVersionName = "4.4.7-ba7opf.17.6"
#noinspection UnusedVersionCatalogEntry
compileSdk = "37"
#noinspection UnusedVersionCatalogEntry