Compare commits

...
Author SHA1 Message Date
atsunatsu 1871c05002 build: release version 4.4.7-ba7opf.17.8 (531) 2026-09-27 21:26:16 +08:00
atsunatsu e9f58f76f6 feat(radar): keep the full-screen circle width share in small windows
The radar circle is a square, so it is limited by the shorter side of
the card it is drawn in: the small-window layout gave the radar card a
height of ~156dp in a 411x450 window, which shrank the circle to ~36% of
the page width while the full-screen layout shows ~84%.

- measure the real window with BoxWithConstraints instead of trusting the
  window size class, which can still report the full screen while the app
  runs in a split/floating window
- when the classic 1:1 split would give the radar less than 75% of the
  page width, use a fill layout: the radar takes the largest square that
  fits, a usable pager block is reserved under it while that keeps the
  circle at its full-screen share, otherwise the pager collapses into a
  48dp translucent tab strip with a tappable panel
- keep the normal two-row top area in the vertical layout: TimerRow and
  NextPassRow both carry weight(1f) internally, so putting them in a
  single row splits that row into two 50% blocks
- extract the geometry into pure Dp functions (useFillRadarLayout,
  radarFillSizes) covered by unit tests
2026-09-27 21:26:12 +08:00
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
21 changed files with 639 additions and 94 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>
+3 -1
View File
@@ -10,4 +10,6 @@ dependencies {
implementation(project(":feature:mutual"))
implementation(project(":feature:cw"))
implementation("androidx.constraintlayout:constraintlayout:2.2.1")
}
testImplementation(libs.test.junit4)
testImplementation(libs.test.coroutines)
}
@@ -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}") }
@@ -28,14 +28,18 @@ import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
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.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.fillMaxHeight
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
@@ -43,19 +47,24 @@ import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.PrimaryTabRow
import androidx.compose.material3.Surface
import androidx.compose.material3.Tab
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.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.keepScreenOn
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.content.ContextCompat
@@ -74,7 +83,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 +95,85 @@ private enum class RadarPage(val title: String) {
Sstv("SSTV")
}
/**
* Always-visible translucent pager tab strip drawn over the radar in small windows.
*/
private val COMPACT_TAB_STRIP_HEIGHT = 48.dp
/**
* Smallest pager block worth reserving below the radar before falling back to the
* overlaid tab strip + panel. Below this the pager would be unusable anyway.
*/
private val COMPACT_MIN_PAGER_HEIGHT = 96.dp
/**
* Vertical chrome the classic layout spends outside the radar: two 48dp top bar rows
* plus three 6dp row gaps (bar/bar/radar/pager).
*/
private val CLASSIC_VERTICAL_CHROME = 114.dp
/**
* The radar circle is 0.95 x min(cardWidth, cardHeight), so a card that is wider than it
* is tall always shows a small circle with empty bands on the sides. Full screen the radar
* card ends up ~0.88 x cardWidth tall (circle ~84% of the page width). Once the classic
* 1:1 split on a vertical window would give the radar less than this share of its own
* width, the fill layout takes over so the circle keeps the full-screen width proportion
* instead of shrinking with the window height.
*/
private const val FILL_RADAR_SPLIT_RATIO = 0.75f
/**
* Smallest radar side (as a share of the page width) worth keeping a reserved pager block for.
* 0.88 x 0.95 = ~0.84 = the circle's share of the page width full screen, so the pager is only
* preserved while reserving it costs the circle nothing against the full-screen proportion.
*/
private const val MIN_RADAR_SHARE_WITH_PAGER = 0.88f
/** Vertical gap kept between the stacked cards. */
private val ROW_GAP = 6.dp
/**
* True when a vertical window is too short for the classic 1:1 split to keep the radar circle
* at its full-screen share of the page width. The split gives the radar
* `(height - chrome) / 2`, so a short window caps the circle by that height instead of by the
* page width.
*/
internal fun useFillRadarLayout(isVertical: Boolean, maxWidth: Dp, maxHeight: Dp): Boolean =
isVertical && (maxHeight - CLASSIC_VERTICAL_CHROME) / 2 < maxWidth * FILL_RADAR_SPLIT_RATIO
/**
* Geometry of the fill layout: the square the radar keeps for itself (largest square that fits,
* so the circle spans the page width like it does full screen) and the space left for the pager.
* When that space is too small to be usable the pager is overlaid on the plot instead.
*/
internal data class RadarFillSizes(
val radarSide: Dp,
val pagerSpace: Dp,
val pagerOverlaid: Boolean
)
internal fun radarFillSizes(maxWidth: Dp, maxHeight: Dp): RadarFillSizes {
// Preferred: keep a usable pager block under the radar square. That is only worth it while
// the circle still reaches its full-screen share of the page width.
val radarWithPager = minOf(maxWidth, maxHeight - COMPACT_MIN_PAGER_HEIGHT - ROW_GAP)
if (radarWithPager >= maxWidth * MIN_RADAR_SHARE_WITH_PAGER) {
return RadarFillSizes(
radarSide = radarWithPager,
pagerSpace = maxHeight - radarWithPager - ROW_GAP,
pagerOverlaid = false
)
}
// Window too short for both: the radar takes the largest square it can and the pager is
// folded into the overlaid tab strip.
val radarSide = minOf(maxWidth, maxHeight)
val pagerSpace = maxHeight - radarSide - ROW_GAP
return RadarFillSizes(
radarSide = radarSide,
pagerSpace = pagerSpace,
pagerOverlaid = pagerSpace < COMPACT_MIN_PAGER_HEIGHT
)
}
@Composable
fun RadarDestination(navigateUp: () -> Unit) {
val context = LocalContext.current
@@ -172,37 +259,188 @@ 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
// The classic vertical layout gives the radar a 1:1 share of the height while also
// paying for two top bar rows. In split-screen / small windows that leaves a card that
// is wider than it is tall, so the circle is capped by the height and stops filling the
// page width. Below the full-screen proportion the fill layout takes over: one top bar
// row, the radar keeps the largest square it can, the pager yields (reserved block when
// the window is tall enough, overlaid tab strip + panel when it is not).
val fillRadar = useFillRadarLayout(isVertical, maxWidth, maxHeight)
Column(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.spacedBy(6.dp)
) {
if (isVertical) {
// Normal vertical top area: the timer card keeps its own row and the pass card
// gets a full-width row of its own. Never merge them into one row — both cards
// carry weight(1f), so side by side they split the width into two halves.
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) {
if (fillRadar) {
RadarFillArea(
uiState = uiState,
trackB = trackB,
trackBPosition = trackBPosition,
onAction = onAction,
logViewModel = logViewModel,
requestMicPermission = requestMicPermission,
modifier = Modifier.weight(1f)
)
} 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))
}
}
}
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))
}
}
/**
* Radar area for split-screen / small windows. The radar takes the largest square the area
* can hold so the circle spans the same share of the width it does full screen, and the
* pager yields: a real block below the square when there is room, otherwise a translucent
* tab strip overlaid on the plot that opens the pager panel on top of it.
*/
@Composable
private fun RadarFillArea(
uiState: RadarState,
trackB: List<OrbitalPos>,
trackBPosition: OrbitalPos?,
onAction: (RadarAction) -> Unit,
logViewModel: LogViewModel,
requestMicPermission: () -> Unit,
modifier: Modifier = Modifier
) {
BoxWithConstraints(modifier = modifier.fillMaxSize()) {
val sizes = radarFillSizes(maxWidth, maxHeight)
// Hoisted out of the nested Box scope: the BoxWithConstraints receiver is not
// implicitly reachable inside it.
val pagerPanelHeight = maxHeight * 0.72f
if (!sizes.pagerOverlaid) {
Column(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.spacedBy(ROW_GAP)
) {
RadarCard(uiState, trackB, trackBPosition, Modifier.height(sizes.radarSide).fillMaxWidth())
PagerCard(uiState, onAction, logViewModel, requestMicPermission, Modifier.height(sizes.pagerSpace))
}
} else {
Box(modifier = Modifier.fillMaxSize()) {
RadarCard(uiState, trackB, trackBPosition, Modifier.fillMaxSize(), overlayStrip = true)
CompactPagerOverlay(
uiState = uiState,
onAction = onAction,
logViewModel = logViewModel,
requestMicPermission = requestMicPermission,
panelHeight = pagerPanelHeight,
modifier = Modifier.align(Alignment.BottomCenter)
)
}
}
}
}
/**
* Collapsed pager for short windows: a translucent row of tab names over the bottom of the
* radar plot; picking one opens the full pager panel on top of the plot, collapsible again
* with the chevron in its tab row.
*/
@Composable
private fun CompactPagerOverlay(
uiState: RadarState,
onAction: (RadarAction) -> Unit,
logViewModel: LogViewModel,
requestMicPermission: () -> Unit,
panelHeight: Dp,
modifier: Modifier = Modifier
) {
val pages = rememberRadarPages(uiState)
var openPage by remember { mutableStateOf<RadarPage?>(null) }
val currentPage = openPage
if (currentPage == null) {
Surface(
color = MaterialTheme.colorScheme.surface.copy(alpha = 0.88f),
shape = MaterialTheme.shapes.small,
modifier = modifier
.fillMaxWidth()
.height(COMPACT_TAB_STRIP_HEIGHT)
) {
Row(modifier = Modifier.fillMaxSize(), verticalAlignment = Alignment.CenterVertically) {
pages.forEach { page ->
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.weight(1f)
.fillMaxHeight()
.clickable { openPage = page }
) {
Text(
text = page.title,
fontSize = 13.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
textAlign = TextAlign.Center
)
}
}
}
}
} else {
PagerCard(
uiState = uiState,
onAction = onAction,
logViewModel = logViewModel,
requestMicPermission = requestMicPermission,
modifier = modifier
.fillMaxWidth()
.height(panelHeight),
startPage = currentPage,
onCollapse = { openPage = null }
)
}
}
/** Page list of the pager card, shared by the inline pager and the compact overlay panel. */
@Composable
private fun rememberRadarPages(uiState: RadarState): List<RadarPage> {
val hasCalculatorPage = remember(uiState.transceivers.transmitters) {
uiState.transceivers.transmitters.any(DopplerFrequencyCalculator::isNamedLinearTransponder)
}
return remember(hasCalculatorPage) {
buildList {
add(RadarPage.Transceivers)
if (hasCalculatorPage) add(RadarPage.Calculator)
add(RadarPage.Log)
add(RadarPage.Sstv)
}
}
}
@@ -213,20 +451,15 @@ private fun PagerCard(
onAction: (RadarAction) -> Unit,
logViewModel: LogViewModel,
requestMicPermission: () -> Unit,
modifier: Modifier = Modifier
modifier: Modifier = Modifier,
startPage: RadarPage? = null,
onCollapse: (() -> Unit)? = null
) {
val hasCalculatorPage = remember(uiState.transceivers.transmitters) {
uiState.transceivers.transmitters.any(DopplerFrequencyCalculator::isNamedLinearTransponder)
}
val pages = remember(hasCalculatorPage) {
buildList {
add(RadarPage.Transceivers)
if (hasCalculatorPage) add(RadarPage.Calculator)
add(RadarPage.Log)
add(RadarPage.Sstv)
}
}
val pagerState = rememberPagerState(pageCount = { pages.size })
val pages = rememberRadarPages(uiState)
val pagerState = rememberPagerState(
initialPage = startPage?.let { pages.indexOf(it) }?.coerceAtLeast(0) ?: 0,
pageCount = { pages.size }
)
val coroutineScope = rememberCoroutineScope()
LaunchedEffect(pages.size) {
@@ -237,12 +470,26 @@ private fun PagerCard(
ElevatedCard(modifier = modifier) {
Column(modifier = Modifier.fillMaxSize()) {
val selectedTabIndex = pagerState.currentPage.coerceIn(0, pages.lastIndex)
PrimaryTabRow(selectedTabIndex = selectedTabIndex) {
pages.forEachIndexed { index, page ->
Tab(
selected = selectedTabIndex == index,
onClick = { coroutineScope.launch { pagerState.animateScrollToPage(index) } },
text = { Text(text = page.title, maxLines = 1, overflow = TextOverflow.Ellipsis) }
Row(verticalAlignment = Alignment.CenterVertically) {
PrimaryTabRow(
selectedTabIndex = selectedTabIndex,
modifier = if (onCollapse != null) Modifier.weight(1f) else Modifier
) {
pages.forEachIndexed { index, page ->
Tab(
selected = selectedTabIndex == index,
onClick = { coroutineScope.launch { pagerState.animateScrollToPage(index) } },
text = { Text(text = page.title, maxLines = 1, overflow = TextOverflow.Ellipsis) }
)
}
}
onCollapse?.let { collapse ->
Text(
text = "▼",
fontSize = 14.sp,
modifier = Modifier
.padding(horizontal = 12.dp)
.clickable { collapse() }
)
}
}
@@ -287,7 +534,8 @@ private fun RadarCard(
uiState: RadarState,
trackB: List<OrbitalPos> = emptyList(),
trackBPosition: OrbitalPos? = null,
modifier: Modifier = Modifier
modifier: Modifier = Modifier,
overlayStrip: Boolean = false
) {
val satellitePos = uiState.orbitalPos
val shouldAnimateBorder = satellitePos?.aboveHorizon == true && satellitePos.eclipsed
@@ -330,19 +578,25 @@ private fun RadarCard(
sunPosition = uiState.sunPosition,
moonPosition = uiState.moonPosition,
)
PositionOverlay(position)
PositionOverlay(position, overlayStrip)
}
}
}
}
@Composable
private fun PositionOverlay(position: OrbitalPos) {
private fun PositionOverlay(position: OrbitalPos, overlayStrip: Boolean = false) {
Column(
verticalArrangement = Arrangement.SpaceBetween,
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 6.dp, vertical = 4.dp)
// Keep the bottom labels clear of the overlaid pager tab strip in small windows.
.padding(
start = 6.dp,
end = 6.dp,
top = 4.dp,
bottom = if (overlayStrip) COMPACT_TAB_STRIP_HEIGHT + 4.dp else 4.dp
)
) {
Row(
horizontalArrangement = Arrangement.SpaceBetween,
@@ -0,0 +1,114 @@
/*
* 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.feature.radar
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
import kotlin.math.min
/**
* Small-window radar sizing.
*
* The plotted circle is `0.95 x min(cardWidth, cardHeight)`, so a radar card that is wider than
* it is tall caps the circle by its height and shows empty bands on the sides. Full screen the
* classic 1:1 split leaves the radar card ~0.88 x cardWidth tall, i.e. the circle spans ~84% of
* the page width. These tests pin the split-screen / small-window layout to the same width share
* instead of letting the circle shrink with the window height.
*/
class RadarFillLayoutTest {
/** Circle diameter as a share of the page width (radar canvas rule: 0.95 * min(width, height)). */
private fun circleShare(width: Dp, height: Dp, sizes: RadarFillSizes): Float {
val availableHeight = if (sizes.pagerOverlaid) height else sizes.radarSide
return 0.95f * min(width.value, availableHeight.value) / width.value
}
@Test
fun fullScreenVerticalKeepsClassicSplit() {
// 411 x 867dp full screen: the 1:1 split already gives the circle ~87% of the width,
// so the full-screen layout must not change.
assertFalse(useFillRadarLayout(isVertical = true, maxWidth = 411.dp, maxHeight = 867.dp))
assertFalse(useFillRadarLayout(isVertical = true, maxWidth = 393.dp, maxHeight = 804.dp))
}
@Test
fun splitScreenAndSmallWindowsUseFillLayout() {
assertTrue(useFillRadarLayout(true, 411.dp, 426.dp)) // portrait split-screen half
assertTrue(useFillRadarLayout(true, 411.dp, 500.dp)) // floating small window
assertTrue(useFillRadarLayout(true, 411.dp, 676.dp)) // 70/30 split
assertTrue(useFillRadarLayout(true, 393.dp, 560.dp))
}
@Test
fun wideWindowsKeepSideBySideLayout() {
assertFalse(useFillRadarLayout(isVertical = false, maxWidth = 700.dp, maxHeight = 400.dp))
}
@Test
fun fillLayoutKeepsCircleAtFullScreenWidthShare() {
// Previously these rendered the circle at ~36% of the page width.
val cases = listOf(
411.dp to 426.dp,
411.dp to 500.dp,
393.dp to 560.dp,
411.dp to 676.dp
)
cases.forEach { (width, height) ->
val share = circleShare(width, height, radarFillSizes(width, height))
assertTrue("circle is only ${share * 100}% of the page width at ${width.value}x${height.value}",
share >= 0.84f)
}
}
@Test
fun classicSplitWouldHaveSqueezedTheCircle() {
// Regression guard documenting why the fill layout exists: the old compact branch left
// a 411 x 156dp card, i.e. a circle at 36% of the page width, in the same split window.
val classicCompactCardHeight = 426.dp - 96.dp - 132.dp - 18.dp
val classicCompactShare = 0.95f * classicCompactCardHeight.value / 411f
assertTrue(classicCompactShare < 0.45f)
}
@Test
fun pagerIsOverlaidOnlyWhenAReservedBlockWouldBeUseless() {
val shortWindow = radarFillSizes(411.dp, 426.dp)
assertTrue(shortWindow.pagerOverlaid)
assertTrue(shortWindow.radarSide == 411.dp)
val tallWindow = radarFillSizes(411.dp, 676.dp)
assertFalse(tallWindow.pagerOverlaid)
assertEquals(411f, tallWindow.radarSide.value, 0.01f)
assertEquals(259f, tallWindow.pagerSpace.value, 0.01f)
}
@Test
fun pagerBlockIsKeptWhileTheCircleStaysAtTheFullScreenShare() {
// 411 x 468dp: reserving 96dp for the pager still leaves the circle at ~85% of the page
// width, so the pager stays as a real block instead of folding into the overlay strip.
val sizes = radarFillSizes(411.dp, 468.dp)
assertFalse(sizes.pagerOverlaid)
assertEquals(366f, sizes.radarSide.value, 0.01f)
assertEquals(96f, sizes.pagerSpace.value, 0.01f)
val share = circleShare(411.dp, 468.dp, sizes)
assertTrue("circle is only ${share * 100}% of the page width", share >= 0.84f)
}
}
@@ -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 = "531"
#noinspection UnusedVersionCatalogEntry
appVersionName = "4.4.7-ba7opf.17"
appVersionName = "4.4.7-ba7opf.17.8"
#noinspection UnusedVersionCatalogEntry
compileSdk = "37"
#noinspection UnusedVersionCatalogEntry