mirror of
https://github.com/atsunatsu/Look4Sat.git
synced 2026-10-08 13:37:25 +00:00
Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6f1c0a2d23 | ||
|
|
74e7e19cf2 | ||
|
|
bb001b4d38 | ||
|
|
9ea83cb161 | ||
|
|
d7d5918f0a | ||
|
|
b2e9454ee4 | ||
|
|
ea3e04d5db | ||
|
|
68e1925784 |
No files matched your search
@@ -33,53 +33,38 @@ import java.util.TimeZone
|
||||
|
||||
internal data class LoTWKeyMaterial(val key: PrivateKey, val certificate: X509Certificate, val info: LoTWCertificate) {
|
||||
companion object {
|
||||
fun read(bytes: ByteArray, password: CharArray, now: Long): LoTWKeyMaterial {
|
||||
/**
|
||||
* Reads the private key and certificate out of a TrustedQSL .p12 backup.
|
||||
*
|
||||
* The platform PKCS12 parser is tried first because it resolves the certificate that
|
||||
* belongs to the key entry. When it cannot deliver a usable pair — it may refuse the file
|
||||
* outright (older Android cannot read PBES2 at all) or accept the container yet expose no
|
||||
* key entry (Conscrypt/OpenSSL reads PBES2 but fails to recover the key of, for example,
|
||||
* an empty-password backup) — our own PBES2 reader is consulted before reporting a problem,
|
||||
* so a valid certificate is not rejected as "not a valid LoTW certificate file".
|
||||
*
|
||||
* [platformStore] is a seam that lets tests simulate a platform which accepts the file
|
||||
* without exposing any key entry.
|
||||
*/
|
||||
internal fun read(
|
||||
bytes: ByteArray,
|
||||
password: CharArray,
|
||||
now: Long,
|
||||
platformStore: (ByteArray, CharArray) -> KeyStore? = { data, pass -> loadPlatformStore(data, pass) },
|
||||
): LoTWKeyMaterial {
|
||||
if (bytes.isEmpty() || bytes.size > MAX_CERTIFICATE_BYTES) fail(LoTWProblem.CERTIFICATE_INVALID)
|
||||
val key: PrivateKey
|
||||
val cert: X509Certificate
|
||||
val store = try {
|
||||
KeyStore.getInstance("PKCS12").apply { bytes.inputStream().use { load(it, password) } }
|
||||
} catch (_: Exception) {
|
||||
// Modern TQSL / OpenSSL 3 exports use PBES2+AES-CBC which Android's legacy
|
||||
// Bouncy Castle parser cannot read. Fall back to our own PBES2 reader; if
|
||||
// that fails too, report the real reason (format vs password).
|
||||
if (isPbes2(bytes)) {
|
||||
try {
|
||||
val parsed = Pkcs12Reader.read(bytes, password)
|
||||
parsed.first to parsed.second
|
||||
} catch (e: Exception) {
|
||||
// Classify by exception type so the user sees the right message:
|
||||
// - BadPadding (BAD_DECRYPT) -> wrong password
|
||||
// - IllegalStateException (error()) -> unsupported algorithm,
|
||||
// message is the algorithm name
|
||||
// - IllegalArgumentException (require()) -> structurally invalid file
|
||||
fail(
|
||||
when {
|
||||
e is javax.crypto.BadPaddingException -> LoTWProblem.CERTIFICATE_PASSWORD
|
||||
e is IllegalStateException && password.isNotEmpty() -> LoTWProblem.CERTIFICATE_FORMAT
|
||||
else -> LoTWProblem.CERTIFICATE_INVALID
|
||||
},
|
||||
if (e is IllegalStateException) (e.message ?: "") else ""
|
||||
)
|
||||
}
|
||||
} else {
|
||||
fail(LoTWProblem.CERTIFICATE_PASSWORD)
|
||||
}
|
||||
}
|
||||
if (store is Pair<*, *>) {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
key = store.first as PrivateKey
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
cert = store.second as X509Certificate
|
||||
val outcome = platformStore(bytes, password)?.let { platformKeyAndCertificate(it, password) }
|
||||
if (outcome is PlatformOutcome.Ok) {
|
||||
key = outcome.key
|
||||
cert = outcome.certificate
|
||||
} else if (isPbes2(bytes)) {
|
||||
val parsed = readWithPbes2Reader(bytes, password)
|
||||
key = parsed.first
|
||||
cert = parsed.second
|
||||
} else {
|
||||
val ks = store as KeyStore
|
||||
val aliases = Collections.list(ks.aliases()).filter { ks.isKeyEntry(it) }
|
||||
if (aliases.size != 1) fail(LoTWProblem.CERTIFICATE_INVALID)
|
||||
val alias = aliases.single()
|
||||
key = try { ks.getKey(alias, password) as? PrivateKey }
|
||||
catch (_: Exception) { fail(LoTWProblem.CERTIFICATE_PASSWORD) }
|
||||
?: fail(LoTWProblem.CERTIFICATE_INVALID)
|
||||
cert = ks.getCertificate(alias) as? X509Certificate ?: fail(LoTWProblem.CERTIFICATE_INVALID)
|
||||
fail((outcome as? PlatformOutcome.Unusable)?.problem ?: LoTWProblem.CERTIFICATE_PASSWORD)
|
||||
}
|
||||
if (key.algorithm != "RSA" || cert.publicKey.algorithm != "RSA") fail(LoTWProblem.CERTIFICATE_INVALID)
|
||||
try { cert.checkValidity(Date(now)) } catch (_: Exception) { fail(LoTWProblem.CERTIFICATE_EXPIRED) }
|
||||
@@ -92,6 +77,54 @@ internal data class LoTWKeyMaterial(val key: PrivateKey, val certificate: X509Ce
|
||||
return LoTWKeyMaterial(key, cert, info)
|
||||
}
|
||||
|
||||
/** What the platform PKCS12 parser managed to deliver for the key entry. */
|
||||
private sealed interface PlatformOutcome {
|
||||
data class Ok(val key: PrivateKey, val certificate: X509Certificate) : PlatformOutcome
|
||||
|
||||
/** The problem the platform path would have reported on its own. */
|
||||
data class Unusable(val problem: LoTWProblem) : PlatformOutcome
|
||||
}
|
||||
|
||||
/** The platform PKCS12 keystore, or null when it refuses to read the file at all. */
|
||||
private fun loadPlatformStore(bytes: ByteArray, password: CharArray): KeyStore? = try {
|
||||
KeyStore.getInstance("PKCS12").apply { bytes.inputStream().use { load(it, password) } }
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
|
||||
/** Resolves the single key entry the platform offers, keeping its own failure reasons. */
|
||||
private fun platformKeyAndCertificate(store: KeyStore, password: CharArray): PlatformOutcome {
|
||||
val aliases = Collections.list(store.aliases()).filter { store.isKeyEntry(it) }
|
||||
val alias = aliases.singleOrNull() ?: return PlatformOutcome.Unusable(LoTWProblem.CERTIFICATE_INVALID)
|
||||
val key = try {
|
||||
store.getKey(alias, password) as? PrivateKey
|
||||
} catch (_: Exception) {
|
||||
return PlatformOutcome.Unusable(LoTWProblem.CERTIFICATE_PASSWORD)
|
||||
} ?: return PlatformOutcome.Unusable(LoTWProblem.CERTIFICATE_INVALID)
|
||||
val certificate = store.getCertificate(alias) as? X509Certificate
|
||||
?: return PlatformOutcome.Unusable(LoTWProblem.CERTIFICATE_INVALID)
|
||||
return PlatformOutcome.Ok(key, certificate)
|
||||
}
|
||||
|
||||
/** Our own PBES2 reader, mapping failures onto the message the operator should see. */
|
||||
private fun readWithPbes2Reader(bytes: ByteArray, password: CharArray): Pair<PrivateKey, X509Certificate> = try {
|
||||
Pkcs12Reader.read(bytes, password)
|
||||
} catch (e: Exception) {
|
||||
// Classify by exception type so the user sees the right message:
|
||||
// - BadPadding (BAD_DECRYPT) -> wrong password
|
||||
// - IllegalStateException (error()) -> unsupported algorithm,
|
||||
// message is the algorithm name
|
||||
// - IllegalArgumentException (require()) -> structurally invalid file
|
||||
fail(
|
||||
when {
|
||||
e is javax.crypto.BadPaddingException -> LoTWProblem.CERTIFICATE_PASSWORD
|
||||
e is IllegalStateException && password.isNotEmpty() -> LoTWProblem.CERTIFICATE_FORMAT
|
||||
else -> LoTWProblem.CERTIFICATE_INVALID
|
||||
},
|
||||
if (e is IllegalStateException) (e.message ?: "") else ""
|
||||
)
|
||||
}
|
||||
|
||||
/** True when the PKCS12 uses PBES2 (OID 1.2.840.113549.1.5.13), the default
|
||||
* algorithm of OpenSSL 3 / modern TQSL. Android's legacy BC parser can't read it. */
|
||||
private fun isPbes2(bytes: ByteArray): Boolean {
|
||||
|
||||
@@ -37,7 +37,12 @@ internal class LoTWSigner(private val config: LoTWConfig) {
|
||||
val call = record.theirCallsign.trim().uppercase(Locale.US)
|
||||
if (record.status != QsoStatus.COMPLETE || !call.matches(Regex("[A-Z0-9]+(/[A-Z0-9]+)*")) ||
|
||||
!call.any(Char::isLetter) || !call.any(Char::isDigit)) fail(LoTWProblem.INVALID_CONTACT, call)
|
||||
if (!record.myCallsign.trim().equals(key.info.callsign, true)) fail(LoTWProblem.CALLSIGN_MISMATCH, call)
|
||||
// Report the two callsigns the operator has to reconcile (record vs certificate). A generic
|
||||
// sample — the opposite station's call — says nothing about which side is wrong, which is
|
||||
// exactly what a "MY callsign does not match the certificate" message must tell.
|
||||
if (!record.myCallsign.trim().equals(key.info.callsign, true)) {
|
||||
fail(LoTWProblem.CALLSIGN_MISMATCH, "${record.myCallsign.trim()} ≠ ${key.info.callsign}")
|
||||
}
|
||||
val date = utc(record.startUtcMillis, "yyyy-MM-dd")
|
||||
// A record dated after "now" gets its own message: it happens when the operator
|
||||
// logged against a pass that had not started yet (out-of-window flow), and "fix the
|
||||
|
||||
+20
-3
@@ -267,16 +267,29 @@ class LoTWUploadRepository internal constructor(
|
||||
var duplicates = 0
|
||||
val unique = hashSetOf<String>()
|
||||
val reasons = mutableMapOf<LoTWProblem, Int>()
|
||||
val certificateCallsign = signing.key.info.callsign
|
||||
val substitutedIds = mutableSetOf<Long>()
|
||||
val callsignConflicts = mutableListOf<Long>()
|
||||
val contacts = records.sortedBy { it.startUtcMillis }.mapNotNull { record ->
|
||||
coroutineContext.ensureActive()
|
||||
if (record.status != QsoStatus.COMPLETE || ((record.lotwReceived || record.lotwUploaded) && !resubmit)) { skipped++; return@mapNotNull null }
|
||||
// A record logged while no certificate was installed carries no own callsign: sign
|
||||
// it with the certificate's callsign (the preview says so, and an accepted upload
|
||||
// writes the callsign into the record). A record naming a DIFFERENT callsign may be
|
||||
// someone else's contact, so it stays refused — the conflict list lets the operator
|
||||
// rewrite those records or import the other certificate.
|
||||
val signable = if (record.myCallsign.isBlank()) {
|
||||
substitutedIds += record.id
|
||||
record.copy(myCallsign = certificateCallsign)
|
||||
} else record
|
||||
val contact = try {
|
||||
signing.signer.contact(record, signing.key, signing.location, now())
|
||||
signing.signer.contact(signable, signing.key, signing.location, now())
|
||||
} catch (e: LoTWOperationException) {
|
||||
// One un-signable record must not abort the whole batch:
|
||||
// skip it, count the real reason, and let the rest upload.
|
||||
unavailable++
|
||||
reasons[e.reason] = (reasons[e.reason] ?: 0) + 1
|
||||
if (e.reason == LoTWProblem.CALLSIGN_MISMATCH) callsignConflicts += record.id
|
||||
return@mapNotNull null
|
||||
}
|
||||
val previous = ledger[contact.fingerprint]
|
||||
@@ -299,6 +312,7 @@ class LoTWUploadRepository internal constructor(
|
||||
signing.location["MY_VUCC_GRIDS"]?.split(',')
|
||||
?.map(String::trim)?.filter(String::isNotBlank)?.let(::addAll)
|
||||
}.distinct()
|
||||
val submittedIds = contacts.map { it.record.id }
|
||||
val preview = LoTWUploadPreview(
|
||||
UUID.randomUUID().toString(), signing.key.info.callsign, signing.key.info.dxcc, signing.location.getValue("GRIDSQUARE"),
|
||||
contacts.size, skipped,
|
||||
@@ -309,9 +323,12 @@ class LoTWUploadRepository internal constructor(
|
||||
unavailable,
|
||||
reasons.toMap(),
|
||||
duplicates,
|
||||
contacts.map { it.record.id },
|
||||
submittedIds,
|
||||
locationGrids,
|
||||
resubmit
|
||||
resubmit,
|
||||
// Only the callsign-filled records that actually made it into this batch are reported.
|
||||
submittedIds.count { it in substitutedIds },
|
||||
callsignConflicts
|
||||
)
|
||||
if (contacts.isNotEmpty()) pending = Pending(
|
||||
preview,
|
||||
|
||||
+26
-5
@@ -82,18 +82,39 @@ class QsoRepository(
|
||||
|
||||
override suspend fun delete(id: Long) = withContext(dispatcher) { importMutex.withLock { dao.delete(id) } }
|
||||
|
||||
override suspend fun markUploaded(ids: List<Long>, grids: List<String>) = withContext(dispatcher) {
|
||||
override suspend fun markUploaded(ids: List<Long>, grids: List<String>, certificateCallsign: String) = withContext(dispatcher) {
|
||||
if (ids.isEmpty()) return@withContext
|
||||
dao.markUploaded(ids)
|
||||
// Stamp the station grid set this batch went out under (a line/corner prefill from
|
||||
// the grid finder carries 2–4 grids). The logbook row shows it after the timestamp.
|
||||
val normalized = grids.map { it.trim().uppercase(Locale.US).take(4) }
|
||||
.filter { it.length >= 4 }.distinct().sorted()
|
||||
if (normalized.isEmpty()) return@withContext
|
||||
val stamped = ids.mapNotNull { id ->
|
||||
dao.find(id)?.toDomain()?.takeIf { it.vuccGrids != normalized }?.copy(vuccGrids = normalized)
|
||||
if (normalized.isNotEmpty()) {
|
||||
val stamped = ids.mapNotNull { id ->
|
||||
dao.find(id)?.toDomain()?.takeIf { it.vuccGrids != normalized }?.copy(vuccGrids = normalized)
|
||||
}
|
||||
if (stamped.isNotEmpty()) dao.saveBatch(stamped.map { it.toEntity() })
|
||||
}
|
||||
if (stamped.isNotEmpty()) dao.saveBatch(stamped.map { it.toEntity() })
|
||||
// Records logged while no certificate was installed carry no own callsign; they were just
|
||||
// signed with the certificate's, so write it into them. Blanks only, and through save() so
|
||||
// the stored dedupe key follows the new value.
|
||||
if (certificateCallsign.isNotBlank()) {
|
||||
ids.mapNotNull { id -> dao.find(id)?.toDomain()?.takeIf { it.myCallsign.isBlank() } }
|
||||
.forEach { save(it.copy(myCallsign = certificateCallsign.trim().uppercase(Locale.US))) }
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun rewriteMyCallsign(ids: List<Long>, callsign: String): Int = withContext(dispatcher) {
|
||||
val target = callsign.trim().uppercase(Locale.US)
|
||||
if (ids.isEmpty() || target.isBlank()) return@withContext 0
|
||||
var changed = 0
|
||||
ids.forEach { id ->
|
||||
val record = dao.find(id)?.toDomain() ?: return@forEach
|
||||
if (record.myCallsign.trim().equals(target, true)) return@forEach
|
||||
save(record.copy(myCallsign = target))
|
||||
changed++
|
||||
}
|
||||
changed
|
||||
}
|
||||
|
||||
override suspend fun markWavelogUploaded(ids: List<Long>, stationId: String) = withContext(dispatcher) {
|
||||
|
||||
+10
-1
@@ -66,8 +66,17 @@ class SelectionRepo(
|
||||
}
|
||||
}
|
||||
|
||||
// Selected satellites are pinned to the top (matches upstream); ordering
|
||||
// within each group stays name/catnum. Sits on the reactive chain, so
|
||||
// ticking an entry re-sorts the visible list immediately.
|
||||
private val itemsWithQuery = currentQuery.flatMapLatest { query ->
|
||||
itemsWithTypes.map { items -> filterByQuery(items, query) }
|
||||
itemsWithTypes.map { items ->
|
||||
filterByQuery(items, query).sortedWith(
|
||||
compareByDescending<SatItem> { it.isSelected }
|
||||
.thenBy { it.name }
|
||||
.thenBy { it.catnum }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override fun getCurrentTypes() = currentTypes.value
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
/*
|
||||
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.rtbishop.look4sat.core.data.lotw
|
||||
|
||||
import com.rtbishop.look4sat.core.domain.repository.LoTWOperationException
|
||||
import com.rtbishop.look4sat.core.domain.repository.LoTWProblem
|
||||
import java.io.InputStream
|
||||
import java.security.KeyStore
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertThrows
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Certificate import must not depend on the platform PKCS12 parser. Android's parser accepts a
|
||||
* modern TQSL PBES2 export but can expose no key entry for it (observed with an empty-password
|
||||
* backup, which was then rejected as "not a valid LoTW certificate file" although every field of
|
||||
* the certificate is readable). These tests pin the fallback to our own PBES2 reader.
|
||||
*/
|
||||
class LoTWKeyMaterialTest {
|
||||
|
||||
private fun fixture(name: String): ByteArray =
|
||||
javaClass.classLoader!!.getResourceAsStream(name)!!.use { it.readBytes() }
|
||||
|
||||
/** Stands in for a platform that parsed the container but offers no private-key entry. */
|
||||
private fun storeWithoutKeyEntry(): KeyStore = KeyStore.getInstance("PKCS12").apply {
|
||||
load(null as InputStream?, null as CharArray?)
|
||||
}
|
||||
|
||||
private val now = System.currentTimeMillis()
|
||||
|
||||
/** TQSL-style backup (empty password) imported although the platform delivers no key entry. */
|
||||
@Test
|
||||
fun importsCertificateWhenPlatformExposesNoKeyEntry() {
|
||||
val material = LoTWKeyMaterial.read(
|
||||
bytes = fixture("test_tqsl_empty.p12"),
|
||||
password = charArrayOf(),
|
||||
now = now,
|
||||
platformStore = { _, _ -> storeWithoutKeyEntry() },
|
||||
)
|
||||
assertEquals("BA7OPF", material.info.callsign)
|
||||
assertEquals(318, material.info.dxcc)
|
||||
assertEquals("2026-01-01", material.info.firstQsoDate)
|
||||
}
|
||||
|
||||
/** The same backup still imports when the platform is the one that resolves the key entry. */
|
||||
@Test
|
||||
fun importsCertificateThroughPlatformWhenAvailable() {
|
||||
val material = LoTWKeyMaterial.read(fixture("test_tqsl_empty.p12"), charArrayOf(), now)
|
||||
assertEquals("BA7OPF", material.info.callsign)
|
||||
}
|
||||
|
||||
/** A wrong password is still reported as a password problem, not as an invalid file. */
|
||||
@Test
|
||||
fun reportsWrongPasswordForPbes2Backup() {
|
||||
val error = assertThrows(LoTWOperationException::class.java) {
|
||||
LoTWKeyMaterial.read(fixture("test_tqsl_empty.p12"), "wrong-password".toCharArray(), now)
|
||||
}
|
||||
assertEquals(LoTWProblem.CERTIFICATE_PASSWORD, error.reason)
|
||||
}
|
||||
|
||||
/** Something that is not a PKCS12 at all keeps the message the platform path produced. */
|
||||
@Test
|
||||
fun refusesNonPkcs12File() {
|
||||
val error = assertThrows(LoTWOperationException::class.java) {
|
||||
LoTWKeyMaterial.read("not a p12 at all".toByteArray(), "x".toCharArray(), now) { _, _ -> null }
|
||||
}
|
||||
assertEquals(LoTWProblem.CERTIFICATE_PASSWORD, error.reason)
|
||||
}
|
||||
|
||||
/** Oversized input is rejected before any parser runs. */
|
||||
@Test
|
||||
fun refusesOversizedInput() {
|
||||
val bytes = ByteArray(MAX_CERTIFICATE_BYTES + 1)
|
||||
val error = assertThrows(LoTWOperationException::class.java) {
|
||||
LoTWKeyMaterial.read(bytes, charArrayOf(), now) { _, _ -> storeWithoutKeyEntry() }
|
||||
}
|
||||
assertEquals(LoTWProblem.CERTIFICATE_INVALID, error.reason)
|
||||
}
|
||||
}
|
||||
+108
-1
@@ -17,8 +17,13 @@
|
||||
*/
|
||||
package com.rtbishop.look4sat.core.data.lotw
|
||||
|
||||
import com.rtbishop.look4sat.core.domain.logbook.QsoRecord
|
||||
import com.rtbishop.look4sat.core.domain.logbook.QsoStatus
|
||||
import com.rtbishop.look4sat.core.domain.repository.LoTWOperationException
|
||||
import com.rtbishop.look4sat.core.domain.repository.LoTWProblem
|
||||
import com.rtbishop.look4sat.core.domain.repository.LoTWStation
|
||||
import com.rtbishop.look4sat.core.domain.repository.LoTWUploadResult
|
||||
import java.io.File
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
@@ -39,7 +44,9 @@ class LoTWUploadRepositoryPreviewTest {
|
||||
|
||||
private class MemStorage : LoTWStorage {
|
||||
val files = mutableMapOf<String, ByteArray>()
|
||||
override fun read(name: String): ByteArray? = files[name]
|
||||
// Reads hand out their own copy, like the file-backed storage does: several call sites
|
||||
// wipe what they read, and that must not corrupt the stored bytes.
|
||||
override fun read(name: String): ByteArray? = files[name]?.copyOf()
|
||||
override fun write(name: String, data: ByteArray) { files[name] = data.copyOf() }
|
||||
override fun delete(name: String) { files.remove(name) }
|
||||
}
|
||||
@@ -79,4 +86,104 @@ class LoTWUploadRepositoryPreviewTest {
|
||||
assertTrue("nothing written", storage.files.isEmpty())
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Records logged before a certificate was installed (blank own callsign) or under another
|
||||
* certificate are signed with the certificate's callsign instead of being refused, and the
|
||||
* preview reports how many they were.
|
||||
*/
|
||||
@Test
|
||||
fun `records without a matching callsign upload under the certificate and are reported`() {
|
||||
runBlocking {
|
||||
val storage = MemStorage()
|
||||
val repo = LoTWUploadRepository(
|
||||
storage,
|
||||
{ LoTWConfig(File("src/main/assets/lotw/config.tq6").inputStream()) },
|
||||
now = { System.currentTimeMillis() }
|
||||
)
|
||||
repo.importCertificate(fixture("test_tqsl_empty.p12"), charArrayOf())
|
||||
repo.saveStation(LoTWStation(grid = "OL62TI"))
|
||||
val start = 1_787_000_000_000L // 2026-08, inside the fixture certificate's QSO range
|
||||
val preview = repo.prepare(
|
||||
listOf(
|
||||
sampleRecord(start, "BH6RJD", ""),
|
||||
sampleRecord(start + 60_000L, "BG7QBL", "XX0YY"),
|
||||
sampleRecord(start + 120_000L, "BA8BLK", "BA7OPF")
|
||||
),
|
||||
resubmit = false
|
||||
)
|
||||
// The blank record is signed with the certificate's callsign; the one naming another
|
||||
// callsign is refused and offered up for a rewrite instead.
|
||||
assertEquals(2, preview.count)
|
||||
assertEquals(1, preview.missingCallsign)
|
||||
assertEquals(listOf(start + 60_000L), preview.callsignConflicts)
|
||||
assertEquals(1, preview.unavailableReasons[LoTWProblem.CALLSIGN_MISMATCH])
|
||||
}
|
||||
}
|
||||
|
||||
/** Nothing signable at all: the preview still comes back (count 0) with the conflicts listed,
|
||||
* so the UI can offer the rewrite / use-another-certificate actions instead of a dead end. */
|
||||
@Test
|
||||
fun `fully blocked batch still returns a preview with the conflicts`() {
|
||||
runBlocking {
|
||||
val storage = MemStorage()
|
||||
val repo = LoTWUploadRepository(
|
||||
storage,
|
||||
{ LoTWConfig(File("src/main/assets/lotw/config.tq6").inputStream()) },
|
||||
now = { System.currentTimeMillis() }
|
||||
)
|
||||
repo.importCertificate(fixture("test_tqsl_empty.p12"), charArrayOf())
|
||||
repo.saveStation(LoTWStation(grid = "OL62TI"))
|
||||
val start = 1_787_000_000_000L
|
||||
val preview = repo.prepare(
|
||||
listOf(
|
||||
sampleRecord(start, "BH6RJD", "XX0YY"),
|
||||
sampleRecord(start + 60_000L, "BG7QBL", "XX0ZZ")
|
||||
),
|
||||
resubmit = false
|
||||
)
|
||||
assertEquals(0, preview.count)
|
||||
assertEquals(2, preview.unavailableSkipped)
|
||||
assertEquals(listOf(start, start + 60_000L), preview.callsignConflicts)
|
||||
assertEquals(2, preview.unavailableReasons[LoTWProblem.CALLSIGN_MISMATCH])
|
||||
assertEquals(0, preview.missingCallsign)
|
||||
// No payload was prepared: confirming this preview must fail as expired, not crash.
|
||||
assertTrue(repo.upload(preview.id) is LoTWUploadResult.ExpiredPreview)
|
||||
}
|
||||
}
|
||||
|
||||
/** The mismatch reason names both callsigns (record vs certificate). The old detail showed the
|
||||
* opposite station's call, which told the operator nothing about which side to fix. */
|
||||
@Test
|
||||
fun `callsign mismatch detail names the record and the certificate callsign`() {
|
||||
runBlocking {
|
||||
val storage = MemStorage()
|
||||
val repo = LoTWUploadRepository(
|
||||
storage,
|
||||
{ LoTWConfig(File("src/main/assets/lotw/config.tq6").inputStream()) },
|
||||
now = { System.currentTimeMillis() }
|
||||
)
|
||||
repo.importCertificate(fixture("test_tqsl_empty.p12"), charArrayOf())
|
||||
repo.saveStation(LoTWStation(grid = "OL62TI"))
|
||||
val audit = repo.audit(listOf(sampleRecord(1_787_000_000_000L, "BH6RJD", "XX0YY")))
|
||||
assertEquals("XX0YY ≠ BA7OPF", audit.details[LoTWProblem.CALLSIGN_MISMATCH])
|
||||
}
|
||||
}
|
||||
|
||||
private fun sampleRecord(start: Long, call: String, myCallsign: String) = QsoRecord(
|
||||
id = start,
|
||||
startUtcMillis = start,
|
||||
endUtcMillis = start,
|
||||
theirCallsign = call,
|
||||
myCallsign = myCallsign,
|
||||
myGrid = "OL62TI",
|
||||
txFrequencyHz = 145_850_000L,
|
||||
rxFrequencyHz = 436_795_000L,
|
||||
band = "2M",
|
||||
rxBand = "70CM",
|
||||
mode = "FM",
|
||||
satelliteName = "SO-50",
|
||||
propagationMode = "SAT",
|
||||
status = QsoStatus.COMPLETE
|
||||
)
|
||||
}
|
||||
+30
@@ -295,6 +295,36 @@ class QsoRepositoryTest {
|
||||
assertEquals(1, dao.getAll().size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun markUploaded_fillsBlankCallsignsAndKeepsTheOthers() = runBlocking {
|
||||
val blank = dao.save(loggedInApp("SO-50", lotwUploaded = false).copy(myCallsign = "").toEntity())
|
||||
val named = dao.save(loggedInApp("SO-50", lotwUploaded = false).copy(myCallsign = "BH3HCF").toEntity())
|
||||
|
||||
repository.markUploaded(listOf(blank, named), emptyList(), "BA7OPF")
|
||||
|
||||
val rows = dao.getAll().associateBy { it.id }
|
||||
// Records logged before a certificate existed get the certificate's callsign; a record that
|
||||
// names another callsign is not touched (they are refused, not rewritten, by the uploader).
|
||||
assertEquals("BA7OPF", rows[blank]!!.myCallsign)
|
||||
assertEquals("BH3HCF", rows[named]!!.myCallsign)
|
||||
assertTrue(rows[blank]!!.lotwUploaded)
|
||||
assertTrue(rows[named]!!.lotwUploaded)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun rewriteMyCallsign_rewritesTheConflictRecordsOnly() = runBlocking {
|
||||
val conflict = dao.save(loggedInApp("SO-50", lotwUploaded = false).copy(myCallsign = "XX0YY").toEntity())
|
||||
val kept = dao.save(loggedInApp("SO-50", lotwUploaded = false).copy(myCallsign = "BA7OPF").toEntity())
|
||||
|
||||
val changed = repository.rewriteMyCallsign(listOf(conflict, kept), "ba7opf")
|
||||
|
||||
assertEquals(1, changed)
|
||||
val rows = dao.getAll().associateBy { it.id }
|
||||
assertEquals("BA7OPF", rows[conflict]!!.myCallsign)
|
||||
assertEquals("BA7OPF", rows[kept]!!.myCallsign)
|
||||
assertFalse(rows[conflict]!!.lotwUploaded)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun markWavelogUploaded_setsTheFlagAndTheStationStamp() = runBlocking {
|
||||
val id = dao.save(loggedInApp("SO-50", lotwUploaded = false).toEntity())
|
||||
|
||||
+18
@@ -284,6 +284,24 @@ class SelectionRepoSearchTest {
|
||||
assertEquals(sampleItems.map { it.catnum }.toSet(), results.map { it.catnum }.toSet())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `selected satellites are shown first`() = runTest {
|
||||
// Pinning ported from upstream: ticked satellites sort above the rest,
|
||||
// each group keeping the name order.
|
||||
val repo = createRepo(
|
||||
items = listOf(
|
||||
SatItem(catnum = 44444, name = "Zeta"),
|
||||
SatItem(catnum = 25544, name = "Alpha"),
|
||||
SatItem(catnum = 40967, name = "Beta")
|
||||
)
|
||||
)
|
||||
val flow = repo.getEntriesFlow()
|
||||
repo.setSelection(listOf(40967), true)
|
||||
val results = flow.first()
|
||||
assertEquals(listOf(40967, 25544, 44444), results.map { it.catnum })
|
||||
assertEquals(listOf(true, false, false), results.map { it.isSelected })
|
||||
}
|
||||
|
||||
private fun createRepo(
|
||||
items: List<SatItem>,
|
||||
sstvIds: List<Int> = emptyList()
|
||||
|
||||
@@ -14,9 +14,15 @@ import java.util.Date
|
||||
import java.util.Locale
|
||||
import java.util.TimeZone
|
||||
|
||||
/** Small ADI codec for the logbook's actual field set (ADIF 3.1.7). */
|
||||
/**
|
||||
* Small ADI codec for the logbook's actual field set (ADIF 3.1.7).
|
||||
*
|
||||
* Grid directions follow ADIF: the un-prefixed fields (GRIDSQUARE, VUCC_GRIDS) belong to
|
||||
* the OPPOSITE station, the MY_* fields (MY_GRIDSQUARE, MY_VUCC_GRIDS) to the operator.
|
||||
*/
|
||||
object AdifCodec {
|
||||
private val utc = TimeZone.getTimeZone("UTC")
|
||||
private val gridPattern = Regex("[A-R]{2}[0-9]{2}([A-X]{2}([0-9]{2})?)?")
|
||||
|
||||
fun encode(records: List<QsoRecord>, includeStationCallsign: Boolean = true): String = buildString {
|
||||
append(field("ADIF_VER", "3.1.7"))
|
||||
@@ -36,7 +42,17 @@ object AdifCodec {
|
||||
appendOptional("MODE", record.mode)
|
||||
appendOptional("SUBMODE", record.submode)
|
||||
appendOptional("GRIDSQUARE", record.theirGrid)
|
||||
// The OPPOSITE station's multi-grid set (2+ grids: a grid line or corner).
|
||||
// A single grid is carried by GRIDSQUARE; one value inside the VUCC field
|
||||
// makes Wavelog flag the record in its QSO view.
|
||||
if (record.theirVuccGrids.size > 1) {
|
||||
appendOptional("VUCC_GRIDS", record.theirVuccGrids.joinToString(","))
|
||||
}
|
||||
appendOptional("MY_GRIDSQUARE", record.myGrid)
|
||||
// The grid set this qso went out under (own operated grids, stamped at
|
||||
// upload). ADIF's MY_VUCC_GRIDS is its home — not VUCC_GRIDS, which belongs
|
||||
// to the opposite station and drives Wavelog's grid/distance/QRB display.
|
||||
appendOptional("MY_VUCC_GRIDS", record.vuccGrids.joinToString(","))
|
||||
appendOptional("RST_SENT", record.sentReport)
|
||||
appendOptional("RST_RCVD", record.receivedReport)
|
||||
record.txFrequencyHz?.let { append(field("FREQ", hzToMhz(it))) }
|
||||
@@ -49,7 +65,6 @@ object AdifCodec {
|
||||
if (record.lotwConfirmed) append(field("LOTW_QSL_RCVD", "Y"))
|
||||
if (record.lotwReceived) append(field("LOTW_QSL_SENT", "Y"))
|
||||
appendOptional("LOTW_QSLRDATE", record.lotwQslDate)
|
||||
appendOptional("VUCC_GRIDS", record.vuccGrids.joinToString(","))
|
||||
record.dxcc?.let { append(field("DXCC", it.toString())) }
|
||||
appendOptional("COUNTRY", record.country)
|
||||
record.cqZone?.let { append(field("CQZ", it.toString())) }
|
||||
@@ -80,7 +95,9 @@ object AdifCodec {
|
||||
theirCallsign = call,
|
||||
myCallsign = values["STATION_CALLSIGN"].orEmpty().uppercase(Locale.US),
|
||||
theirGrid = values["GRIDSQUARE"].orEmpty().uppercase(Locale.US),
|
||||
theirVuccGrids = decodeGrids(values["VUCC_GRIDS"]),
|
||||
myGrid = values["MY_GRIDSQUARE"].orEmpty().uppercase(Locale.US),
|
||||
vuccGrids = decodeGrids(values["MY_VUCC_GRIDS"]),
|
||||
sentReport = values["RST_SENT"].orEmpty(),
|
||||
receivedReport = values["RST_RCVD"].orEmpty(),
|
||||
txFrequencyHz = mhzToHz(values["FREQ"]),
|
||||
@@ -100,8 +117,6 @@ object AdifCodec {
|
||||
lotwConfirmed = values["LOTW_QSL_RCVD"].equals("Y", true),
|
||||
lotwReceived = values["LOTW_QSL_SENT"].equals("Y", true),
|
||||
lotwQslDate = values["LOTW_QSLRDATE"].orEmpty(),
|
||||
vuccGrids = values["VUCC_GRIDS"].orEmpty().split(',').map { it.trim().uppercase(Locale.US) }
|
||||
.filter { it.matches(Regex("[A-R]{2}[0-9]{2}([A-X]{2}([0-9]{2})?)?")) }.distinct(),
|
||||
dxcc = values["DXCC"]?.toIntOrNull(),
|
||||
country = values["COUNTRY"].orEmpty(),
|
||||
cqZone = values["CQZ"]?.toIntOrNull()?.takeIf { it in 1..40 },
|
||||
@@ -140,6 +155,12 @@ object AdifCodec {
|
||||
return records
|
||||
}
|
||||
|
||||
/** Splits an ADIF grid list on commas, keeping only valid 4/6/8-char locators. */
|
||||
private fun decodeGrids(value: String?): List<String> = value.orEmpty().split(',')
|
||||
.map { it.trim().uppercase(Locale.US) }
|
||||
.filter { it.matches(gridPattern) }
|
||||
.distinct()
|
||||
|
||||
private fun StringBuilder.appendOptional(name: String, value: String) {
|
||||
if (value.isNotBlank()) append(field(name, value))
|
||||
}
|
||||
|
||||
+8
-1
@@ -22,7 +22,14 @@ interface IQsoRepository {
|
||||
* signed with (1–4 gridsquares: inside a grid / on a line / on a corner), so the
|
||||
* logbook row can show which grids the QSO went out under.
|
||||
*/
|
||||
suspend fun markUploaded(ids: List<Long>, grids: List<String> = emptyList())
|
||||
suspend fun markUploaded(ids: List<Long>, grids: List<String> = emptyList(), certificateCallsign: String = "")
|
||||
|
||||
/**
|
||||
* Rewrites the operator's callsign on the given records. This is the answer to "these contacts
|
||||
* were logged under another certificate — upload them under the one installed now". Returns the
|
||||
* number of records that changed.
|
||||
*/
|
||||
suspend fun rewriteMyCallsign(ids: List<Long>, callsign: String): Int
|
||||
|
||||
/**
|
||||
* Marks the batch as uploaded to Wavelog (independent of the LoTW upload state) and
|
||||
|
||||
+12
@@ -18,6 +18,7 @@
|
||||
package com.rtbishop.look4sat.core.domain.logbook
|
||||
|
||||
import com.rtbishop.look4sat.core.domain.repository.LoTWProblem
|
||||
import com.rtbishop.look4sat.core.domain.repository.LoTWUploadAudit
|
||||
|
||||
/**
|
||||
* Wording for the reasons a QSO cannot be signed for LoTW.
|
||||
@@ -69,3 +70,14 @@ fun unavailableUploadSummary(
|
||||
val detail = parts.joinToString(", ").ifBlank { "check the logbook" }
|
||||
return "$subject — $detail"
|
||||
}
|
||||
|
||||
/**
|
||||
* True when a batch that has nothing uploadable still has to reach the preview dialog.
|
||||
*
|
||||
* The preview is the only place that carries the callsign-conflict actions (rewrite the records, or
|
||||
* upload them under another certificate). Short-circuiting such a batch into the plain "can't be
|
||||
* uploaded" message strands exactly the records the operator needs to act on — the message has no
|
||||
* buttons to get out of, so those QSOs could never be uploaded.
|
||||
*/
|
||||
fun LoTWUploadAudit.needsPreviewForConflicts(): Boolean =
|
||||
pending == 0 && (reasons[LoTWProblem.CALLSIGN_MISMATCH] ?: 0) > 0
|
||||
@@ -48,6 +48,8 @@ data class QsoRecord(
|
||||
val wavelogStation: String = "",
|
||||
val lotwReceived: Boolean = false,
|
||||
val lotwQslDate: String = "",
|
||||
/** Own operated grid set this QSO went out under — uploaded as ADIF MY_VUCC_GRIDS
|
||||
* (never VUCC_GRIDS, which belongs to the opposite station). Stamped at upload. */
|
||||
val vuccGrids: List<String> = emptyList(),
|
||||
val dxcc: Int? = null,
|
||||
val country: String = "",
|
||||
|
||||
+7
-1
@@ -107,7 +107,13 @@ data class LoTWUploadPreview(
|
||||
val grids: List<String> = emptyList(),
|
||||
/** True when this batch is a resubmit: already-uploaded/confirmed records were allowed
|
||||
* through so the corrected station location reaches LoTW as an update of the contact. */
|
||||
val resubmit: Boolean = false
|
||||
val resubmit: Boolean = false,
|
||||
/** Records in this batch whose own callsign is blank (logged before a certificate was
|
||||
* installed). They are signed with [callsign]; a successful upload writes it into them. */
|
||||
val missingCallsign: Int = 0,
|
||||
/** Records refused because they name a different own callsign than [callsign]. The upload
|
||||
* flow offers either rewriting them with [callsign] or using the other certificate. */
|
||||
val callsignConflicts: List<Long> = emptyList()
|
||||
)
|
||||
|
||||
/** Pre-upload location check: the freshest known position grid is not covered by the
|
||||
|
||||
+72
-8
@@ -10,6 +10,7 @@
|
||||
package com.rtbishop.look4sat.core.domain.logbook
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
@@ -22,8 +23,10 @@ class AdifCodecTest {
|
||||
endUtcMillis = 1_700_000_000_100L,
|
||||
theirCallsign = "BG5JVM",
|
||||
myCallsign = "BA7OPF",
|
||||
theirGrid = "OM60",
|
||||
theirGrid = "EN52",
|
||||
theirVuccGrids = listOf("EN52", "EN53"),
|
||||
myGrid = "OL62TI",
|
||||
vuccGrids = listOf("OL62", "OL61"),
|
||||
sentReport = "59",
|
||||
receivedReport = "59",
|
||||
txFrequencyHz = 145_850_000L,
|
||||
@@ -36,8 +39,7 @@ class AdifCodecTest {
|
||||
propagationMode = "SAT",
|
||||
status = QsoStatus.COMPLETE,
|
||||
lotwConfirmed = true,
|
||||
lotwQslDate = "2026-09-20",
|
||||
vuccGrids = listOf("OM60", "OM50")
|
||||
lotwQslDate = "2026-09-20"
|
||||
)
|
||||
val decoded = AdifCodec.decode(AdifCodec.encode(listOf(record))).single()
|
||||
assertEquals(record.startUtcMillis, decoded.startUtcMillis)
|
||||
@@ -47,12 +49,18 @@ class AdifCodecTest {
|
||||
assertEquals("2M", decoded.band)
|
||||
assertEquals("70CM", decoded.rxBand)
|
||||
assertTrue(decoded.lotwConfirmed)
|
||||
assertEquals(listOf("OM60", "OM50"), decoded.vuccGrids)
|
||||
// Grid directions survive a round trip: the opposite's set stays theirs,
|
||||
// the operator's own set stays theirs.
|
||||
assertEquals("EN52", decoded.theirGrid)
|
||||
assertEquals(listOf("EN52", "EN53"), decoded.theirVuccGrids)
|
||||
assertEquals("OL62TI", decoded.myGrid)
|
||||
assertEquals(listOf("OL62", "OL61"), decoded.vuccGrids)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun decode_lotwStyleReportFields() {
|
||||
// LoTW report subset: lowercase eor, alphabetical fields, VUCC_GRIDS list.
|
||||
// LoTW report subset: lowercase eor, alphabetical fields. The un-prefixed
|
||||
// grid fields belong to the OPPOSITE station, MY_* fields to the operator.
|
||||
val adi = """
|
||||
<EOH>
|
||||
<CALL:6>BH6RJD
|
||||
@@ -64,8 +72,9 @@ class AdifCodecTest {
|
||||
<BAND:3>2M
|
||||
<BAND_RX:4>70CM
|
||||
<GRIDSQUARE:4>OM60
|
||||
<MY_GRIDSQUARE:8>OM60IL70
|
||||
<VUCC_GRIDS:9>OM60,OM50
|
||||
<MY_GRIDSQUARE:6>OL62TI
|
||||
<MY_VUCC_GRIDS:9>OL62,OL61
|
||||
<STATION_CALLSIGN:6>BH6RJD
|
||||
<DXCC:3>318
|
||||
<COUNTRY:5>China
|
||||
@@ -78,12 +87,27 @@ class AdifCodecTest {
|
||||
assertEquals("BH6RJD", record.theirCallsign)
|
||||
assertEquals("SO-50", record.satelliteName)
|
||||
assertEquals("FM", record.displayMode)
|
||||
assertEquals("OM60IL70", record.myGrid)
|
||||
assertEquals(listOf("OM60", "OM50"), record.vuccGrids)
|
||||
assertEquals("OM60", record.theirGrid)
|
||||
assertEquals(listOf("OM60", "OM50"), record.theirVuccGrids)
|
||||
assertEquals("OL62TI", record.myGrid)
|
||||
assertEquals(listOf("OL62", "OL61"), record.vuccGrids)
|
||||
assertTrue(record.lotwConfirmed)
|
||||
assertTrue(record.isSatellite)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun decodeWavelogPullStyleRecordKeepsGridDirections() {
|
||||
// Wavelog's export: the OPPOSITE station's grids un-prefixed, the station
|
||||
// profile's grid under MY_*. A pull must not flip them into each other.
|
||||
val adi = "<EOH><CALL:6>BG5JVM<QSO_DATE:8>20260916<TIME_ON:4>0745" +
|
||||
"<VUCC_GRIDS:9>EN52,EN53<MY_GRIDSQUARE:6>OL62TI<MY_VUCC_GRIDS:9>OL62,OL61" +
|
||||
"<MODE:2>FM<PROP_MODE:3>SAT<SAT_NAME:5>SO-50<EOR>"
|
||||
val record = AdifCodec.decode(adi).single()
|
||||
assertEquals(listOf("EN52", "EN53"), record.theirVuccGrids)
|
||||
assertEquals("OL62TI", record.myGrid)
|
||||
assertEquals(listOf("OL62", "OL61"), record.vuccGrids)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun encodeOmitsStationCallsignForWavelog() {
|
||||
val record = QsoRecord(
|
||||
@@ -99,4 +123,44 @@ class AdifCodecTest {
|
||||
assertTrue(adif.contains("<CALL:6>N0CALL"))
|
||||
assertTrue(adif.contains("<SAT_NAME:5>SO-50"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun encodeKeepsOwnGridsOutOfTheOppositeVuccField() {
|
||||
// The reported defect: an operator's stamped own grid set must never land in
|
||||
// VUCC_GRIDS — Wavelog displays that field as the OPPOSITE station's grid.
|
||||
val record = QsoRecord(
|
||||
startUtcMillis = 1_700_000_000_000L,
|
||||
theirCallsign = "N0CALL",
|
||||
myCallsign = "BA7OPF",
|
||||
myGrid = "OL62TI",
|
||||
vuccGrids = listOf("OL62"),
|
||||
satelliteName = "SO-50"
|
||||
)
|
||||
val adif = AdifCodec.encode(listOf(record), includeStationCallsign = false)
|
||||
assertTrue(adif.contains("<MY_VUCC_GRIDS:4>OL62"))
|
||||
assertFalse(adif.contains("<VUCC_GRIDS:"))
|
||||
assertFalse(adif.contains("<GRIDSQUARE:"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun encodeWritesTheOppositeMultiGridSetToVuccGrids() {
|
||||
val multi = QsoRecord(
|
||||
startUtcMillis = 1_700_000_000_000L,
|
||||
theirCallsign = "N0CALL",
|
||||
myCallsign = "BA7OPF",
|
||||
theirGrid = "EN52",
|
||||
theirVuccGrids = listOf("EN52", "EN53"),
|
||||
satelliteName = "SO-50"
|
||||
)
|
||||
val multiAdif = AdifCodec.encode(listOf(multi), false)
|
||||
assertTrue(multiAdif.contains("<GRIDSQUARE:4>EN52"))
|
||||
assertTrue(multiAdif.contains("<VUCC_GRIDS:9>EN52,EN53"))
|
||||
|
||||
// A single opposite grid rides in GRIDSQUARE only; one value inside the VUCC
|
||||
// field would trigger Wavelog's single-grid warning in its QSO view.
|
||||
val single = multi.copy(theirVuccGrids = listOf("EN52"))
|
||||
val singleAdif = AdifCodec.encode(listOf(single), false)
|
||||
assertTrue(singleAdif.contains("<GRIDSQUARE:4>EN52"))
|
||||
assertFalse(singleAdif.contains("<VUCC_GRIDS:"))
|
||||
}
|
||||
}
|
||||
+59
@@ -0,0 +1,59 @@
|
||||
/*
|
||||
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.rtbishop.look4sat.core.domain.logbook
|
||||
|
||||
import com.rtbishop.look4sat.core.domain.repository.LoTWProblem
|
||||
import com.rtbishop.look4sat.core.domain.repository.LoTWUploadAudit
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* The preview dialog is the only place carrying the callsign-conflict actions (rewrite / use another
|
||||
* certificate), so a conflict-only batch must NOT be short-circuited into the plain
|
||||
* "can't be uploaded" message: that dialog has no buttons to get out of.
|
||||
*/
|
||||
class LoTWUploadMessagesTest {
|
||||
|
||||
@Test
|
||||
fun `conflict-only batch asks for the preview even with nothing pending`() {
|
||||
assertTrue(audit(pending = 0, mismatch = 107, satellite = 1).needsPreviewForConflicts())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `blocked batch without conflicts keeps the plain message`() {
|
||||
assertFalse(audit(pending = 0, mismatch = 0, satellite = 3).needsPreviewForConflicts())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `batch with something to upload never takes the conflict fallback`() {
|
||||
assertFalse(audit(pending = 5, mismatch = 2, satellite = 1).needsPreviewForConflicts())
|
||||
}
|
||||
|
||||
private fun audit(pending: Int, mismatch: Int, satellite: Int) = LoTWUploadAudit(
|
||||
total = 108,
|
||||
pending = pending,
|
||||
uploaded = 0,
|
||||
unknown = 0,
|
||||
unavailable = 108 - pending,
|
||||
reasons = buildMap {
|
||||
if (mismatch > 0) put(LoTWProblem.CALLSIGN_MISMATCH, mismatch)
|
||||
if (satellite > 0) put(LoTWProblem.SATELLITE, satellite)
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -19,6 +19,10 @@
|
||||
<string name="sat_search_clear">Vaciar</string>
|
||||
<string name="sat_clear_all">Vaciar todo</string>
|
||||
<string name="sat_select_all">Seleccionar todo</string>
|
||||
<string name="sat_group_selected">Seleccionados</string>
|
||||
<string name="sat_group_available">Disponibles</string>
|
||||
<string name="sat_group_selected_count">Seleccionados (%1$d)</string>
|
||||
<string name="sat_group_available_count">Disponibles (%1$d)</string>
|
||||
<string name="sat_empty_list_message">
|
||||
Asegúrese de que su consulta de búsqueda sea correcta y que la base de datos esté actualizada</string>
|
||||
<string name="sat_warning_title">Alarma\!</string>
|
||||
|
||||
@@ -19,6 +19,10 @@
|
||||
<string name="sat_search_clear">Очистить</string>
|
||||
<string name="sat_clear_all">Очистить</string>
|
||||
<string name="sat_select_all">Выбрать</string>
|
||||
<string name="sat_group_selected">Выбранные</string>
|
||||
<string name="sat_group_available">Доступные</string>
|
||||
<string name="sat_group_selected_count">Выбранные (%1$d)</string>
|
||||
<string name="sat_group_available_count">Доступные (%1$d)</string>
|
||||
<string name="sat_empty_list_message">Убедитесь, что ваш поисковый запрос верен и база данных обновлена</string>
|
||||
<string name="sat_warning_title">Внимание\!</string>
|
||||
<string name="sat_warning_message">
|
||||
|
||||
@@ -19,6 +19,10 @@
|
||||
<string name="sat_search_clear">පිරිසිදු ක°</string>
|
||||
<string name="sat_clear_all">සියල්ල පිරිසිදු ක°</string>
|
||||
<string name="sat_select_all">සියල්ල තෝරන්න</string>
|
||||
<string name="sat_group_selected">තෝරාගත්</string>
|
||||
<string name="sat_group_available">තිබෙන</string>
|
||||
<string name="sat_group_selected_count">තෝරාගත් (%1$d)</string>
|
||||
<string name="sat_group_available_count">තිබෙන (%1$d)</string>
|
||||
<string name="sat_empty_list_message">
|
||||
ඔබගේ සෙවුම් විමසුම නිවැරදි බවත් දත්ත සමුදාය යාවත්කාලීන කර ඇති බවත් සහතික කර ගන්න</string>
|
||||
<string name="sat_warning_title">අවවාදයයි\!</string>
|
||||
|
||||
@@ -20,6 +20,10 @@
|
||||
<string name="sat_search_clear">Temizle</string>
|
||||
<string name="sat_clear_all">Tümünü temizle</string>
|
||||
<string name="sat_select_all">Tümünü seç</string>
|
||||
<string name="sat_group_selected">Seçilenler</string>
|
||||
<string name="sat_group_available">Kullanılabilir</string>
|
||||
<string name="sat_group_selected_count">Seçilenler (%1$d)</string>
|
||||
<string name="sat_group_available_count">Kullanılabilir (%1$d)</string>
|
||||
<string name="sat_empty_list_message">"Arama sorgunuzun doğru olduğundan ve veritabanının güncellendiğinden emin olun"</string>
|
||||
<string name="sat_warning_title">Uyarı\!</string>
|
||||
<string name="sat_warning_message">
|
||||
|
||||
@@ -19,6 +19,10 @@
|
||||
<string name="sat_search_clear">Очистити</string>
|
||||
<string name="sat_clear_all">Очистити всі</string>
|
||||
<string name="sat_select_all">Вибрати всі</string>
|
||||
<string name="sat_group_selected">Обрані</string>
|
||||
<string name="sat_group_available">Доступні</string>
|
||||
<string name="sat_group_selected_count">Обрані (%1$d)</string>
|
||||
<string name="sat_group_available_count">Доступні (%1$d)</string>
|
||||
<string name="sat_empty_list_message">
|
||||
Переконайтеся, що ваш пошуковий запит правильний, а база даних оновлена</string>
|
||||
<string name="sat_warning_title">Увага\!</string>
|
||||
|
||||
@@ -20,6 +20,10 @@
|
||||
<string name="sat_search_clear">清空</string>
|
||||
<string name="sat_clear_all">全部清空</string>
|
||||
<string name="sat_select_all">全选</string>
|
||||
<string name="sat_group_selected">已选择</string>
|
||||
<string name="sat_group_available">可用</string>
|
||||
<string name="sat_group_selected_count">已选择(%1$d)</string>
|
||||
<string name="sat_group_available_count">可用(%1$d)</string>
|
||||
<string name="sat_empty_list_message">请确保您的搜索查询正确且数据库已更新</string>
|
||||
<string name="sat_warning_title">警告\!</string>
|
||||
<string name="sat_warning_message">此应用收录了超过9000颗卫星信息\n同时追踪所有卫星并不现实\n建议始终通过搜索和分类筛选功能仅勾选您感兴趣的卫星</string>
|
||||
@@ -216,6 +220,10 @@
|
||||
<string name="prefs_logbook_selected">已选 %1$d 条</string>
|
||||
<string name="prefs_logbook_select_hint">长按记录行可多选重传</string>
|
||||
<string name="prefs_logbook_resubmit_note">已上传记录将按当前台址就地更新;已被奖项使用的旧网格可能被拒绝。</string>
|
||||
<string name="prefs_logbook_callsign_missing">其中 %1$d 条记录没有呼号,将按证书 %2$s 上传。</string>
|
||||
<string name="prefs_logbook_callsign_different">%1$d 条记录登记的不是证书 %2$s 的呼号。</string>
|
||||
<string name="prefs_logbook_callsign_rewrite">改写为 %1$s</string>
|
||||
<string name="prefs_logbook_callsign_switch">换一张证书</string>
|
||||
<string name="prefs_logbook_station_label">台址</string>
|
||||
<string name="prefs_logbook_station_all">全部</string>
|
||||
<string name="prefs_logbook_upload_wavelog_title">上传到 Wavelog</string>
|
||||
|
||||
@@ -21,6 +21,10 @@
|
||||
<string name="sat_search_clear">Clear</string>
|
||||
<string name="sat_clear_all">Clear all</string>
|
||||
<string name="sat_select_all">Select all</string>
|
||||
<string name="sat_group_selected">Selected</string>
|
||||
<string name="sat_group_available">Available</string>
|
||||
<string name="sat_group_selected_count">Selected (%1$d)</string>
|
||||
<string name="sat_group_available_count">Available (%1$d)</string>
|
||||
<string name="sat_empty_list_message">"Make sure your search query is correct and the DB is updated"</string>
|
||||
<string name="sat_warning_title">Warning\!</string>
|
||||
<string name="sat_warning_message">
|
||||
@@ -245,6 +249,10 @@
|
||||
<string name="prefs_logbook_selected">%1$d selected</string>
|
||||
<string name="prefs_logbook_select_hint">Long-press a row to re-upload corrected records</string>
|
||||
<string name="prefs_logbook_resubmit_note">Existing contacts will be updated in place — a grid already used for an award may be rejected.</string>
|
||||
<string name="prefs_logbook_callsign_missing">%1$d record(s) have no callsign — signed with %2$s from the certificate.</string>
|
||||
<string name="prefs_logbook_callsign_different">%1$d record(s) name another callsign than the certificate %2$s.</string>
|
||||
<string name="prefs_logbook_callsign_rewrite">Rewrite with %1$s</string>
|
||||
<string name="prefs_logbook_callsign_switch">Use another certificate</string>
|
||||
<string name="prefs_logbook_station_label">Station</string>
|
||||
<string name="prefs_logbook_station_all">All</string>
|
||||
<string name="prefs_logbook_upload_wavelog_title">Upload to Wavelog</string>
|
||||
|
||||
@@ -262,7 +262,9 @@ fun LogPage(
|
||||
preview = preview,
|
||||
busy = logUiState.busy,
|
||||
onConfirm = logViewModel::confirmUpload,
|
||||
onDismiss = logViewModel::dismissPreview
|
||||
onDismiss = logViewModel::dismissPreview,
|
||||
onRewriteCallsign = logViewModel::rewriteCallsignConflicts,
|
||||
onSwitchCertificate = logViewModel::switchCertificateForConflicts
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -417,7 +419,9 @@ private fun UploadPreviewDialog(
|
||||
preview: com.rtbishop.look4sat.core.domain.repository.LoTWUploadPreview,
|
||||
busy: Boolean,
|
||||
onConfirm: () -> Unit,
|
||||
onDismiss: () -> Unit
|
||||
onDismiss: () -> Unit,
|
||||
onRewriteCallsign: () -> Unit,
|
||||
onSwitchCertificate: () -> Unit
|
||||
) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
@@ -427,7 +431,10 @@ private fun UploadPreviewDialog(
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
Text("${preview.callsign} DXCC ${preview.dxcc} Grid ${preview.grid}", fontSize = 13.sp)
|
||||
Text("${preview.count} QSO(s) · ${
|
||||
val range = if (preview.firstUtc.isBlank()) {
|
||||
// A blocked batch (shown for its conflict actions) has no date range to show.
|
||||
"${preview.count} QSO(s)"
|
||||
} else "${preview.count} QSO(s) · ${
|
||||
java.text.SimpleDateFormat("MM-dd HH:mm", java.util.Locale.US).apply {
|
||||
timeZone = java.util.TimeZone.getTimeZone("UTC")
|
||||
}.format(java.util.Date(preview.firstUtc.let {
|
||||
@@ -443,7 +450,8 @@ private fun UploadPreviewDialog(
|
||||
timeZone = java.util.TimeZone.getTimeZone("UTC")
|
||||
}.parse(it).time }.getOrDefault(System.currentTimeMillis())
|
||||
}))
|
||||
}Z", fontSize = 13.sp)
|
||||
}Z"
|
||||
Text(range, fontSize = 13.sp)
|
||||
if (preview.skipped > 0 || preview.unknownSkipped > 0 || preview.unavailableSkipped > 0) {
|
||||
val parts = buildList {
|
||||
// skipped counts every record left out of this batch: previously-uploaded
|
||||
@@ -464,11 +472,38 @@ private fun UploadPreviewDialog(
|
||||
}
|
||||
Text(parts.joinToString(" · "), fontSize = 12.sp, color = MaterialTheme.colorScheme.error)
|
||||
}
|
||||
if (preview.missingCallsign > 0) {
|
||||
// Logged while no certificate was installed; signed with the certificate's
|
||||
// callsign instead of being refused.
|
||||
Text(
|
||||
stringResource(R.string.prefs_logbook_callsign_missing, preview.missingCallsign, preview.callsign),
|
||||
fontSize = 12.sp,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
}
|
||||
if (preview.callsignConflicts.isNotEmpty()) {
|
||||
// These name another callsign than the certificate: rewrite them with this
|
||||
// certificate's callsign, or upload them under the other certificate.
|
||||
Text(
|
||||
stringResource(R.string.prefs_logbook_callsign_different, preview.callsignConflicts.size, preview.callsign),
|
||||
fontSize = 12.sp,
|
||||
color = MaterialTheme.colorScheme.error
|
||||
)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
TextButton(onClick = onRewriteCallsign, enabled = !busy) {
|
||||
Text(stringResource(R.string.prefs_logbook_callsign_rewrite, preview.callsign), fontSize = 12.sp)
|
||||
}
|
||||
TextButton(onClick = onSwitchCertificate, enabled = !busy) {
|
||||
Text(stringResource(R.string.prefs_logbook_callsign_switch), fontSize = 12.sp)
|
||||
}
|
||||
}
|
||||
}
|
||||
Text(preview.contacts.joinToString("\n") { it }, fontSize = 12.sp, maxLines = 8)
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = onConfirm, enabled = !busy) { Text("确认上传") }
|
||||
// Nothing uploadable (a conflict-only batch): the actions above are the way out.
|
||||
TextButton(onClick = onConfirm, enabled = !busy && preview.count > 0) { Text("确认上传") }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) { Text("Cancel") }
|
||||
|
||||
@@ -18,6 +18,7 @@ import com.rtbishop.look4sat.core.domain.logbook.QsoRecord
|
||||
import com.rtbishop.look4sat.core.domain.logbook.QsoStatus
|
||||
import com.rtbishop.look4sat.core.domain.logbook.displayMode
|
||||
import com.rtbishop.look4sat.core.domain.logbook.frequencyBand
|
||||
import com.rtbishop.look4sat.core.domain.logbook.needsPreviewForConflicts
|
||||
import com.rtbishop.look4sat.core.domain.logbook.officialSatelliteName
|
||||
import com.rtbishop.look4sat.core.domain.logbook.satelliteIdentity
|
||||
import com.rtbishop.look4sat.core.domain.logbook.unavailableUploadSummary
|
||||
@@ -286,7 +287,10 @@ class LogViewModel(
|
||||
// LoTW-imported confirmations are the feedback side.
|
||||
val pending = all.filter { !it.lotwConfirmed && !it.lotwUploaded && it.status == QsoStatus.COMPLETE }
|
||||
val audit = lotwUploadRepository.audit(pending)
|
||||
if (audit.pending == 0) {
|
||||
// A batch held back only by callsign conflicts still gets the preview: that dialog
|
||||
// carries the rewrite / use-another-certificate actions. Without this the operator
|
||||
// gets a dead-end message and those records can never be uploaded.
|
||||
if (audit.pending == 0 && !audit.needsPreviewForConflicts()) {
|
||||
val msg = when {
|
||||
audit.unavailable > 0 -> unavailableUploadSummary(
|
||||
audit.unavailable, audit.reasons, audit.details, audit.duplicates, audit.incomplete
|
||||
@@ -343,7 +347,7 @@ class LogViewModel(
|
||||
if (result is LoTWUploadResult.Accepted && lastUploadedIds.isNotEmpty()) {
|
||||
// Mark the submitted QSOs as uploaded (distinct from confirmed) and stamp
|
||||
// the station grids this batch went out under.
|
||||
qsoRepository.markUploaded(lastUploadedIds, preview.grids)
|
||||
qsoRepository.markUploaded(lastUploadedIds, preview.grids, preview.callsign)
|
||||
lastUploadedIds = emptyList()
|
||||
}
|
||||
val msg = when (result) {
|
||||
@@ -368,6 +372,34 @@ class LogViewModel(
|
||||
|
||||
fun dismissPreview() = _uiState.update { it.copy(preview = null) }
|
||||
|
||||
/**
|
||||
* Operator chose "rewrite" on the callsign conflict: the refused records get this certificate's
|
||||
* callsign so the next upload can sign them. The prepared batch is dropped — nothing was sent.
|
||||
*/
|
||||
fun rewriteCallsignConflicts() {
|
||||
val preview = _uiState.value.preview ?: return
|
||||
val ids = preview.callsignConflicts
|
||||
if (ids.isEmpty()) return
|
||||
viewModelScope.launch {
|
||||
_uiState.update { it.copy(busy = true) }
|
||||
val changed = qsoRepository.rewriteMyCallsign(ids, preview.callsign)
|
||||
lotwUploadRepository.discardPreview()
|
||||
lastUploadedIds = emptyList()
|
||||
_uiState.update {
|
||||
it.copy(busy = false, preview = null, message = "$changed record(s) rewritten to ${preview.callsign} — upload again")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Operator chose "use another certificate": that certificate is imported on the settings page. */
|
||||
fun switchCertificateForConflicts() {
|
||||
lotwUploadRepository.discardPreview()
|
||||
lastUploadedIds = emptyList()
|
||||
_uiState.update {
|
||||
it.copy(preview = null, message = "Import the other callsign's certificate in Settings → LoTW upload, then upload again")
|
||||
}
|
||||
}
|
||||
|
||||
fun dismissWavelogPreview() = _uiState.update { it.copy(wavelogPreview = null) }
|
||||
|
||||
/**
|
||||
|
||||
+45
-2
@@ -326,9 +326,52 @@ private fun SatellitesPreview() {
|
||||
|
||||
@Composable
|
||||
private fun SatellitesCard(items: List<SatItem>, onSelected: (Int, Boolean) -> Unit) {
|
||||
val selectedItems = items.filter { it.isSelected }
|
||||
val availableItems = items.filterNot { it.isSelected }
|
||||
|
||||
LazyVerticalGrid(columns = GridCells.Adaptive(320.dp)) {
|
||||
items(items = items, key = { item -> item.catnum }) { entry ->
|
||||
Satellite(entry, onSelected, Modifier.animateItem())
|
||||
stickyHeader(key = "sat_summary_header") {
|
||||
SummaryHeader(
|
||||
selectedText = stringResource(R.string.sat_group_selected_count, selectedItems.size),
|
||||
availableText = stringResource(R.string.sat_group_available_count, availableItems.size)
|
||||
)
|
||||
}
|
||||
|
||||
if (selectedItems.isNotEmpty()) {
|
||||
items(items = selectedItems, key = { item -> item.catnum }) { entry ->
|
||||
Satellite(entry, onSelected, Modifier.animateItem())
|
||||
}
|
||||
}
|
||||
|
||||
if (availableItems.isNotEmpty()) {
|
||||
items(items = availableItems, key = { item -> item.catnum }) { entry ->
|
||||
Satellite(entry, onSelected, Modifier.animateItem())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SummaryHeader(selectedText: String, availableText: String) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(MaterialTheme.colorScheme.surfaceContainerHighest)
|
||||
.padding(horizontal = 12.dp, vertical = 4.dp)
|
||||
) {
|
||||
Text(
|
||||
text = selectedText,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Text(
|
||||
text = availableText,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
+42
-4
@@ -103,6 +103,10 @@ fun LogbookDialog(
|
||||
onDismissMessage: () -> Unit,
|
||||
onIgnorePositionWarning: () -> Unit = {},
|
||||
onFixGrid: (List<String>) -> Unit = {},
|
||||
/** Rewrite the records whose own callsign differs from the certificate's. */
|
||||
onRewriteCallsign: () -> Unit = {},
|
||||
/** Leave the upload to import a different certificate instead. */
|
||||
onSwitchCertificate: () -> Unit = {},
|
||||
selectionMode: Boolean = false,
|
||||
selectedIds: Set<Long> = emptySet(),
|
||||
onStartSelection: (Long) -> Unit = {},
|
||||
@@ -246,7 +250,9 @@ fun LogbookDialog(
|
||||
preview = preview,
|
||||
busy = uploadBusy,
|
||||
onConfirm = onConfirmUpload,
|
||||
onDismiss = onDismissPreview
|
||||
onDismiss = onDismissPreview,
|
||||
onRewriteCallsign = onRewriteCallsign,
|
||||
onSwitchCertificate = onSwitchCertificate
|
||||
)
|
||||
}
|
||||
if (uploadMessage.isNotBlank()) {
|
||||
@@ -280,7 +286,9 @@ private fun LogbookUploadPreviewDialog(
|
||||
preview: com.rtbishop.look4sat.core.domain.repository.LoTWUploadPreview,
|
||||
busy: Boolean,
|
||||
onConfirm: () -> Unit,
|
||||
onDismiss: () -> Unit
|
||||
onDismiss: () -> Unit,
|
||||
onRewriteCallsign: () -> Unit,
|
||||
onSwitchCertificate: () -> Unit
|
||||
) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
@@ -291,7 +299,9 @@ private fun LogbookUploadPreviewDialog(
|
||||
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
Text("${preview.callsign} DXCC ${preview.dxcc} Grid ${preview.grid}", fontSize = 13.sp)
|
||||
Text(
|
||||
"${preview.count} QSO(s) · ${preview.firstUtc} – ${preview.lastUtc}",
|
||||
// A blocked batch (shown for its conflict actions) has no date range to show.
|
||||
if (preview.firstUtc.isBlank()) "${preview.count} QSO(s)"
|
||||
else "${preview.count} QSO(s) · ${preview.firstUtc} – ${preview.lastUtc}",
|
||||
fontSize = 13.sp
|
||||
)
|
||||
if (preview.skipped > 0 || preview.unknownSkipped > 0 || preview.unavailableSkipped > 0) {
|
||||
@@ -319,11 +329,39 @@ private fun LogbookUploadPreviewDialog(
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
}
|
||||
if (preview.missingCallsign > 0) {
|
||||
// Logged while no certificate was installed; signed with the certificate's
|
||||
// callsign instead of being refused.
|
||||
Text(
|
||||
stringResource(R.string.prefs_logbook_callsign_missing, preview.missingCallsign, preview.callsign),
|
||||
fontSize = 12.sp,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
}
|
||||
if (preview.callsignConflicts.isNotEmpty()) {
|
||||
// These name another callsign than the certificate: the operator either rewrites
|
||||
// them with the certificate's callsign or uploads them under that other one.
|
||||
Text(
|
||||
stringResource(R.string.prefs_logbook_callsign_different, preview.callsignConflicts.size, preview.callsign),
|
||||
fontSize = 12.sp,
|
||||
color = MaterialTheme.colorScheme.error
|
||||
)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
TextButton(onClick = onRewriteCallsign, enabled = !busy) {
|
||||
Text(stringResource(R.string.prefs_logbook_callsign_rewrite, preview.callsign), fontSize = 12.sp)
|
||||
}
|
||||
TextButton(onClick = onSwitchCertificate, enabled = !busy) {
|
||||
Text(stringResource(R.string.prefs_logbook_callsign_switch), fontSize = 12.sp)
|
||||
}
|
||||
}
|
||||
}
|
||||
Text(preview.contacts.joinToString("\n") { it }, fontSize = 12.sp, maxLines = 8)
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = onConfirm, enabled = !busy) {
|
||||
// Nothing uploadable (a conflict-only batch): the actions above are the way out, so the
|
||||
// upload button stays disabled instead of failing with an expired preview.
|
||||
TextButton(onClick = onConfirm, enabled = !busy && preview.count > 0) {
|
||||
Text(stringResource(R.string.prefs_logbook_upload_confirm))
|
||||
}
|
||||
},
|
||||
|
||||
+9
@@ -308,6 +308,15 @@ private fun SettingsScreen(
|
||||
onConfirmUpload = { onAction(SettingsAction.ConfirmLogbookUpload) },
|
||||
onDismissPreview = { onAction(SettingsAction.DismissLogbookPreview) },
|
||||
onDismissMessage = { onAction(SettingsAction.ClearLogbookMessage) },
|
||||
onRewriteCallsign = { onAction(SettingsAction.RewriteLogbookCallsigns) },
|
||||
onSwitchCertificate = {
|
||||
// The other callsign's certificate lives in the LoTW upload settings: leave the
|
||||
// logbook and open that dialog.
|
||||
onAction(SettingsAction.DismissLogbookPreview)
|
||||
onAction(SettingsAction.ExitLogbookSelection)
|
||||
dialogs.logbook = false
|
||||
dialogs.lotwUpload = true
|
||||
},
|
||||
onIgnorePositionWarning = { onAction(SettingsAction.IgnoreLogbookPositionWarning) },
|
||||
onFixGrid = { grids ->
|
||||
onAction(SettingsAction.AbandonLogbookForGridFix)
|
||||
|
||||
@@ -193,6 +193,7 @@ sealed interface SettingsAction {
|
||||
data object PrepareLogbookUpload : SettingsAction
|
||||
data object ConfirmLogbookUpload : SettingsAction
|
||||
data object DismissLogbookPreview : SettingsAction
|
||||
data object RewriteLogbookCallsigns : SettingsAction
|
||||
/** The operator acknowledged the position mismatch and wants to upload anyway. */
|
||||
data object IgnoreLogbookPositionWarning : SettingsAction
|
||||
/** The operator chose to fix the station location first; the preview is discarded. */
|
||||
|
||||
+30
-2
@@ -46,6 +46,7 @@ import com.rtbishop.look4sat.core.domain.repository.WavelogUploadOutcome
|
||||
import com.rtbishop.look4sat.core.domain.repository.WavelogUploadPreview
|
||||
import com.rtbishop.look4sat.core.domain.usecase.IShowToast
|
||||
import com.rtbishop.look4sat.core.domain.utility.VersionComparator
|
||||
import com.rtbishop.look4sat.core.domain.logbook.needsPreviewForConflicts
|
||||
import com.rtbishop.look4sat.core.domain.logbook.resubmitCandidates
|
||||
import com.rtbishop.look4sat.core.domain.logbook.toConfirmedRecord
|
||||
import com.rtbishop.look4sat.core.domain.logbook.unavailableUploadSummary
|
||||
@@ -257,6 +258,7 @@ class SettingsViewModel(
|
||||
SettingsAction.PrepareLogbookUpload -> prepareLogbookUpload()
|
||||
SettingsAction.ConfirmLogbookUpload -> confirmLogbookUpload()
|
||||
SettingsAction.DismissLogbookPreview -> dismissLogbookPreview()
|
||||
SettingsAction.RewriteLogbookCallsigns -> rewriteLogbookCallsigns()
|
||||
SettingsAction.IgnoreLogbookPositionWarning -> ignoreLogbookPositionWarning()
|
||||
SettingsAction.AbandonLogbookForGridFix -> abandonLogbookForGridFix()
|
||||
is SettingsAction.StartLogbookSelection -> startLogbookSelection(action.id)
|
||||
@@ -575,7 +577,10 @@ class SettingsViewModel(
|
||||
// LoTW-imported confirmations are the feedback side.
|
||||
val pending = all.filter { !it.lotwConfirmed && !it.lotwUploaded && it.status == com.rtbishop.look4sat.core.domain.logbook.QsoStatus.COMPLETE }
|
||||
val audit = lotwUploadRepository.audit(pending)
|
||||
if (audit.pending == 0) {
|
||||
// A batch held back only by callsign conflicts still gets the preview: that dialog
|
||||
// carries the rewrite / use-another-certificate actions. Without this the operator
|
||||
// gets a dead-end message and those records can never be uploaded.
|
||||
if (audit.pending == 0 && !audit.needsPreviewForConflicts()) {
|
||||
val msg = when {
|
||||
audit.unavailable > 0 -> com.rtbishop.look4sat.core.domain.logbook.unavailableUploadSummary(
|
||||
audit.unavailable, audit.reasons, audit.details, audit.duplicates, audit.incomplete
|
||||
@@ -648,7 +653,7 @@ class SettingsViewModel(
|
||||
val result = lotwUploadRepository.upload(preview.id)
|
||||
val accepted = result is com.rtbishop.look4sat.core.domain.repository.LoTWUploadResult.Accepted
|
||||
if (accepted && lastLogbookUploadIds.isNotEmpty()) {
|
||||
qsoRepository.markUploaded(lastLogbookUploadIds, preview.grids)
|
||||
qsoRepository.markUploaded(lastLogbookUploadIds, preview.grids, preview.callsign)
|
||||
lastLogbookUploadIds = emptyList()
|
||||
}
|
||||
val msg = when (result) {
|
||||
@@ -673,6 +678,29 @@ class SettingsViewModel(
|
||||
|
||||
private fun dismissLogbookPreview() = _uiState.update { it.copy(logbookPreview = null, logbookWavelogPreview = null) }
|
||||
|
||||
/**
|
||||
* Operator chose "rewrite" on the callsign conflict: the refused records get this certificate's
|
||||
* callsign so the next upload can sign them. The batch is discarded — nothing was uploaded.
|
||||
*/
|
||||
private fun rewriteLogbookCallsigns() {
|
||||
val preview = _uiState.value.logbookPreview ?: return
|
||||
val ids = preview.callsignConflicts
|
||||
if (ids.isEmpty()) return
|
||||
viewModelScope.launch {
|
||||
_uiState.update { it.copy(logbookUploadBusy = true) }
|
||||
val changed = qsoRepository.rewriteMyCallsign(ids, preview.callsign)
|
||||
lotwUploadRepository.discardPreview()
|
||||
lastLogbookUploadIds = emptyList()
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
logbookUploadBusy = false,
|
||||
logbookPreview = null,
|
||||
logbookUploadMessage = "$changed record(s) rewritten to ${preview.callsign} — upload again"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Operator chose "ignore" on the position check: keep the prepared preview. */
|
||||
private fun ignoreLogbookPositionWarning() = _uiState.update { it.copy(logbookPositionWarning = null) }
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
[versions]
|
||||
#noinspection UnusedVersionCatalogEntry
|
||||
appVersionCode = "576"
|
||||
appVersionCode = "580"
|
||||
#noinspection UnusedVersionCatalogEntry
|
||||
appVersionName = "4.4.8-ba7opf.11.1"
|
||||
appVersionName = "4.4.8-ba7opf.11.5"
|
||||
#noinspection UnusedVersionCatalogEntry
|
||||
compileSdk = "37"
|
||||
#noinspection UnusedVersionCatalogEntry
|
||||
|
||||
Reference in new issue
Block a user