mirror of
https://github.com/atsunatsu/Look4Sat.git
synced 2026-10-09 22:07:23 +00:00
Compare commits
22
Commits
v4.4.8-ba7opf.10
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8d9208b063 | ||
|
|
a0ebe14d07 | ||
|
|
e162de7c19 | ||
|
|
63e2ea88b7 | ||
|
|
ddca54e7e6 | ||
|
|
e19d22714b | ||
|
|
623d859c97 | ||
|
|
08803e0f55 | ||
|
|
6f1c0a2d23 | ||
|
|
74e7e19cf2 | ||
|
|
bb001b4d38 | ||
|
|
9ea83cb161 | ||
|
|
d7d5918f0a | ||
|
|
b2e9454ee4 | ||
|
|
ea3e04d5db | ||
|
|
68e1925784 | ||
|
|
1e563f9cb0 | ||
|
|
f8d3c5b185 | ||
|
|
917788041c | ||
|
|
58bc96c441 | ||
|
|
7a7d742b54 | ||
|
|
146d47455f |
No files matched your search
+7
-1
@@ -74,7 +74,13 @@ internal fun Project.setupAndroidApp() {
|
||||
}
|
||||
androidResources {
|
||||
generateLocaleConfig = true
|
||||
localeFilters.addAll(listOf("en", "es", "ru", "si", "tr", "uk", "zh"))
|
||||
// "zh" alone is stripped down to the Hans config: without the Traditional
|
||||
// region entries below, values-b+zh+Hant is filtered out of the APK and a
|
||||
// zh-TW / zh-HK / zh-MO device falls back to English. aapt2 only accepts
|
||||
// the legacy "zh-rTW" spelling here ("zh-Hant" fails the build).
|
||||
localeFilters.addAll(
|
||||
listOf("en", "es", "ru", "si", "tr", "uk", "zh", "zh-rTW", "zh-rHK", "zh-rMO")
|
||||
)
|
||||
}
|
||||
packaging { resources { excludes += listOf("META-INF/*") } }
|
||||
}
|
||||
|
||||
Binary file not shown.
@@ -36,7 +36,7 @@ import java.util.Locale
|
||||
import java.util.zip.GZIPInputStream
|
||||
import javax.xml.parsers.DocumentBuilderFactory
|
||||
|
||||
/** Bundled ARRL configuration 11.34 from https://lotw.arrl.org/lotw/config.tq6. */
|
||||
/** Bundled ARRL configuration 11.35 from https://lotw.arrl.org/lotw/config.tq6. */
|
||||
internal class LoTWConfig(input: InputStream) {
|
||||
private val root = input.use { source -> GZIPInputStream(source).use { gzip ->
|
||||
val xml = gzip.bufferedReader(Charsets.UTF_8).readText()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -87,6 +87,7 @@ class LoTWConfigTest {
|
||||
assertEquals("AO-91", config.resolveSatellite("FOX-1B"))
|
||||
assertEquals("IO-86", config.resolveSatellite("LAPAN-A2"))
|
||||
assertEquals("RS-44", config.resolveSatellite("DOSAAF-85"))
|
||||
assertEquals("RS95S", config.resolveSatellite("QMR-KWT 2"))
|
||||
assertEquals("BO-102", config.resolveSatellite("CAS-7B"))
|
||||
assertEquals("SO-50", config.resolveSatellite("SO-50"))
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
|
||||
+36
@@ -124,6 +124,24 @@ class SelectionRepoSearchTest {
|
||||
assertEquals(setOf(25544, 61781), results.map { it.catnum }.toSet())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `FM virtual type includes the BA7OPF RS95S UNNE-1B and IO-86 additions`() = runTest {
|
||||
// RS95S / QMR-KWT-2 (67291), UNNE-1B (98205) and IO-86 / LAPAN-A2 (40931)
|
||||
// joined the hardcoded AMSAT Live FM set on 2026-10-09; each carries a V/U
|
||||
// FM repeater in SatNOGS ("V/U FM Transponder CTCSS 67.0 Hz",
|
||||
// "Mode V/U - FM Repeater (Voice + (A)FSK)", "V/U FM Voice (PL 88.5Hz)").
|
||||
// sampleItems contributes ISS (25544) only.
|
||||
val items = sampleItems + listOf(
|
||||
SatItem(catnum = 67291, name = "QMR-KWT-2 (RS95S)"),
|
||||
SatItem(catnum = 98205, name = "UNNE-1B"),
|
||||
SatItem(catnum = 40931, name = "LAPAN-A2 (IO-86)")
|
||||
)
|
||||
val repo = createRepo(items)
|
||||
repo.setTypes(listOf("AMSAT Live FM"))
|
||||
val results = repo.getEntriesFlow().first()
|
||||
assertEquals(setOf(25544, 67291, 98205, 40931), results.map { it.catnum }.toSet())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Linear virtual type shows only hardcoded linear satellites`() = runTest {
|
||||
val repo = createRepo(sampleItems)
|
||||
@@ -284,6 +302,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
|
||||
|
||||
+5
-3
@@ -34,6 +34,8 @@ import java.util.Locale
|
||||
* - live SatNOGS 3LE names and Celestrak amateur names joined to the same NORAD id,
|
||||
* - designator tokens of the config.tq6 satellite descriptions (e.g. CAS-7B -> BO-102),
|
||||
* - the ISS family, which SatNOGS does not list under its ARRL name "ARISS".
|
||||
* Added 2026-10-08: config.tq6 v11.35 lists QMR-KWT 2 as "RS95S"; SatNOGS DB carries it as
|
||||
* "QMR-KWT 2" (alias "RS95S") and other sources spell it "RS-95S", hence the identity entry.
|
||||
* Placeholder names ("OBJECT AY" and friends) are deliberately NOT mapped: they are recycled
|
||||
* between objects and would sign the wrong satellite. LoTWConfigTest asserts that every target
|
||||
* here is a real config.tq6 name.
|
||||
@@ -79,11 +81,11 @@ object LoTWSatelliteAliases {
|
||||
"OSCAR16PACSAT" to "AO-16", "OSCAR19LUSAT" to "LO-19", "OSCAR64" to "DO-64",
|
||||
"OSCAR7" to "AO-7", "PACSAT" to "AO-16", "PARKINSONSAT" to "NO-84",
|
||||
"PCSAT" to "NO-44", "PHILLIPINESOSCAR101" to "PO-101", "PO101" to "PO-101",
|
||||
"PSAT" to "NO-84", "PSAT2" to "NO-104", "QB50P1" to "EO-79",
|
||||
"PSAT" to "NO-84", "PSAT2" to "NO-104", "QB50P1" to "EO-79", "QMRKWT2" to "RS95S",
|
||||
"QO100" to "QO-100", "RADFXSAT" to "AO-91", "RADIOROSTO" to "RS-15",
|
||||
"RS0ISS" to "ARISS", "RS14" to "AO-21", "RS15" to "RS-15",
|
||||
"RS44" to "RS-44", "RS64S" to "AO-123", "SAUDISAT1C" to "SO-50",
|
||||
"SO121" to "SO-121", "SO124" to "SO-124", "SO125" to "SO-125",
|
||||
"RS44" to "RS-44", "RS64S" to "AO-123", "RS95S" to "RS95S",
|
||||
"SAUDISAT1C" to "SO-50", "SO121" to "SO-121", "SO124" to "SO-124", "SO125" to "SO-125",
|
||||
"SO50" to "SO-50", "SOLUTUSNANOSATTELITE" to "SONATE", "SONATE2" to "SONATE",
|
||||
"TAURUS1" to "TAURUS", "TEVEL21" to "TEV2-1", "TEVEL22" to "TEV2-2",
|
||||
"TEVEL23" to "TEV2-3", "TEVEL24" to "TEV2-4", "TEVEL25" to "TEV2-5",
|
||||
|
||||
+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
|
||||
|
||||
@@ -50,11 +50,30 @@ object Sources {
|
||||
)
|
||||
/**
|
||||
* Hardcoded AMSAT Live FM satellites (NORAD catnums):
|
||||
* SO-50 (27607), ISS ZARYA (25544), AO-123 ASRTU-1 (61781).
|
||||
* SO-50 (27607), ISS ZARYA (25544), AO-123 ASRTU-1 (61781),
|
||||
* RS95S/QMR-KWT-2 (67291), UNNE-1B (98205).
|
||||
* Replaces the AMSAT live-page fetch: stable on any network, no
|
||||
* sync-time dependency, no stale-list or timeout failure modes.
|
||||
*
|
||||
* BA7OPF additions (2026-10-09):
|
||||
* - RS95S / QMR-KWT-2 (67291): V/U FM transponder 145.920/436.950 CTCSS 67.0
|
||||
* (SatNOGS "QMR-KWT-2 (RS95S)", R4UAB "QMR-KWT-2 (RS95S)", AMSAT "RS95S
|
||||
* (QMR-KWT-2)"; the LoTW alias table maps the tracker name to ARRL's "RS95S").
|
||||
* - UNNE-1B (98205): AMSAT-EA 1.5P PocketQube released from the T-18 OTV
|
||||
* "OSSIE" with a V/U FM repeater ("Voice + (A)FSK").
|
||||
* - IO-86 / LAPAN-A2 (40931): V/U FM voice repeater 145.880/435.880 PL 88.5
|
||||
* (SatNOGS "V/U FM Voice (PL 88.5Hz)"; R4UAB "LAPAN-A2 (IO-86)", SatNOGS
|
||||
* 3LE "LAPAN-A2", AMSAT "IO-86"; the LoTW alias table maps LAPAN-A2 to
|
||||
* ARRL's "IO-86"). This fork's first TLE source keeps its elements fresh.
|
||||
* ⚠ 98205 is SatNOGS' analysis number (designator 26227BL) for the freshly
|
||||
* released object — Space-Track assigns the permanent catalogue number later,
|
||||
* so this entry must be re-pointed then; the ephem-watch cron job tracks it.
|
||||
*
|
||||
* The set is shared: the satellite page / mutual page "AMSAT Live FM" filters
|
||||
* and the radar Log page (FM satellites are logged on the fixed nominal
|
||||
* voice-repeater pair).
|
||||
*/
|
||||
val amSatFmCatnums = setOf(27607, 25544, 61781)
|
||||
val amSatFmCatnums = setOf(27607, 25544, 61781, 67291, 98205, 40931)
|
||||
|
||||
/**
|
||||
* Hardcoded AMSAT Live linear (SSB/CW) satellites:
|
||||
|
||||
+4
-1
@@ -27,7 +27,7 @@ import org.junit.Test
|
||||
/** Alias table that maps tracker catalogue names onto the names ARRL's config.tq6 uses. */
|
||||
class LoTWSatelliteAliasesTest {
|
||||
|
||||
private val catalogue = listOf("SO-50", "ARISS", "PO-101", "BO-102", "AO-91", "RS-44", "IO-117")
|
||||
private val catalogue = listOf("SO-50", "ARISS", "PO-101", "BO-102", "AO-91", "RS-44", "IO-117", "RS95S")
|
||||
|
||||
@Test
|
||||
fun `lookup maps SatNOGS catalogue names to ARRL names`() {
|
||||
@@ -37,6 +37,7 @@ class LoTWSatelliteAliasesTest {
|
||||
assertEquals("AO-91", LoTWSatelliteAliases.lookup("FOX-1B"))
|
||||
assertEquals("RS-44", LoTWSatelliteAliases.lookup("DOSAAF-85"))
|
||||
assertEquals("IO-117", LoTWSatelliteAliases.lookup("GREENCUBE"))
|
||||
assertEquals("RS95S", LoTWSatelliteAliases.lookup("QMR-KWT 2"))
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -59,6 +60,8 @@ class LoTWSatelliteAliasesTest {
|
||||
assertEquals("SO-50", LoTWSatelliteAliases.resolve("SAUDISAT 1C (SO-50)", catalogue))
|
||||
assertEquals("ARISS", LoTWSatelliteAliases.resolve("ISS (ZARYA)", catalogue))
|
||||
assertEquals("AO-91", LoTWSatelliteAliases.resolve("AO-91 (RadFxSat)", catalogue))
|
||||
assertEquals("RS95S", LoTWSatelliteAliases.resolve("RS-95S", catalogue))
|
||||
assertEquals("RS95S", LoTWSatelliteAliases.resolve("QMR-KWT 2", catalogue))
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
+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)
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,529 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<!--
|
||||
Traditional-Chinese (zh-Hant) devices fall back to the Simplified Chinese
|
||||
strings in values-zh/ instead of the English default. Android 7+ resolves
|
||||
Chinese by script, so an unqualified values-zh is treated as zh-Hans and is
|
||||
never matched by a zh-TW / zh-HK / zh-MO (Hant) device.
|
||||
|
||||
This file is a verbatim copy of values-zh/strings.xml — do NOT translate it
|
||||
here. Edit values-zh/strings.xml and copy the change over; the unit test
|
||||
TraditionalChineseFallbackTest fails if the two files ever diverge.
|
||||
-->
|
||||
<!-- Commmon -->
|
||||
<string name="btn_accept">确定</string>
|
||||
<string name="btn_cancel">取消</string>
|
||||
<string name="empty_list_message">没有数据可显示</string>
|
||||
|
||||
<!-- Navigation -->
|
||||
<string name="nav_sat">卫星</string>
|
||||
<string name="nav_pass">过境</string>
|
||||
<string name="nav_radar">雷达</string>
|
||||
<string name="nav_mutual">匹配</string>
|
||||
<string name="nav_map">地图</string>
|
||||
<string name="nav_prefs">设置</string>
|
||||
|
||||
<!-- Satellites screen -->
|
||||
<string name="sat_type_hint">类型:%s</string>
|
||||
<string name="sat_type_title">选择卫星类型</string>
|
||||
<string name="sat_search_hint">Id - 名称</string>
|
||||
<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>
|
||||
|
||||
<!-- Passes screen -->
|
||||
<string name="pass_filter_title">筛选过境</string>
|
||||
<string name="pass_filter_elev">仰角值</string>
|
||||
<string name="pass_filter_hours">提前时间</string>
|
||||
<string name="pass_filter_history_hours">历史过境时间</string>
|
||||
<string name="pass_filter_aos_time">AOS 时间段</string>
|
||||
<string name="pass_filter_invert_time">反选 AOS 时间段</string>
|
||||
<string name="pass_filter_deep_space">DeepSpace(周期 >225分钟)</string>
|
||||
<string name="pass_modes_title">选择调制类型</string>
|
||||
<string name="pass_elevation">仰角:%.1f°</string>
|
||||
<string name="pass_altitude">高度:%d km</string>
|
||||
<string name="pass_empty_list_message">请确保您的过滤器设置正确,并且已选择卫星</string>
|
||||
<string name="pass_error_message">没有即将过境的卫星</string>
|
||||
<string name="pass_welcome_title">欢迎来到 Look4Sat\!</string>
|
||||
<string name="pass_welcome_message">请务必在设置中通过GPS、经纬度或QTH定位您的位置\n建议至少每周更新一次数据库,以确保预测结果的准确性</string>
|
||||
|
||||
<!-- AMSAT Status screen -->
|
||||
|
||||
<string name="nav_amsat">AMSAT</string>
|
||||
<string name="amsat_title">AMSAT 卫星状态</string>
|
||||
<string name="amsat_refresh">刷新</string>
|
||||
<string name="amsat_retry">重试</string>
|
||||
<string name="amsat_updated">更新于:</string>
|
||||
<string name="amsat_active">活跃</string>
|
||||
<string name="amsat_tlm">遥测/信标</string>
|
||||
<string name="amsat_not_heard">未听到</string>
|
||||
<string name="amsat_conflict">冲突</string>
|
||||
<string name="amsat_name">名称</string>
|
||||
<string name="amsat_load_failed">加载失败 - 检查网络后重试</string>
|
||||
<string name="amsat_no_reports">此时间段无报告</string>
|
||||
<string name="amsat_close">关闭</string>
|
||||
<string name="amsat_upload">上传</string>
|
||||
<string name="amsat_upload_back">返回</string>
|
||||
<string name="amsat_upload_report">上传报告到 AMSAT</string>
|
||||
<string name="amsat_upload_status">状态</string>
|
||||
<string name="amsat_upload_callsign">呼号</string>
|
||||
<string name="amsat_upload_grid">网格</string>
|
||||
<string name="amsat_upload_time_now">时间:当前 UTC %s</string>
|
||||
<string name="amsat_upload_submit">提交到 AMSAT</string>
|
||||
<string name="amsat_upload_confirm">确认上传</string>
|
||||
<string name="amsat_upload_confirm_title">确认上传到 AMSAT</string>
|
||||
<string name="amsat_upload_confirm_message">这条公开报告将上传到 AMSAT。\n卫星:%1$s\n状态:%2$s\n呼号:%3$s\n网格:%4$s</string>
|
||||
<string name="amsat_upload_grid_none">未填写</string>
|
||||
<string name="amsat_upload_success">报告已上传</string>
|
||||
<string name="amsat_upload_failed">上传失败:%s</string>
|
||||
<string name="amsat_upload_callsign_required">请填写呼号</string>
|
||||
<string name="amsat_upload_grid_invalid">网格应为 4 或 6 位 Maidenhead 定位符</string>
|
||||
<string name="amsat_upload_active">活跃</string>
|
||||
<string name="amsat_upload_tlm">遥测/信标</string>
|
||||
<string name="amsat_upload_not_heard">未听到</string>
|
||||
<string name="amsat_upload_crew_active">ISS 语音</string>
|
||||
|
||||
<!-- Radar screen -->
|
||||
<string name="radar_back">后退</string>
|
||||
<string name="radar_notify">通知</string>
|
||||
<string name="radar_az_text">方位角</string>
|
||||
<string name="radar_el_text">仰角</string>
|
||||
<string name="radar_alt_text">高度</string>
|
||||
<string name="radar_dist_text">距离</string>
|
||||
<string name="radar_eclipsed">地影中</string>
|
||||
<string name="radar_downlink">下行链路</string>
|
||||
<string name="radar_uplink">上行链路</string>
|
||||
<string name="radar_no_data">这颗卫星没有收发器</string>
|
||||
<string name="radar_add">添加</string>
|
||||
<string name="radar_data">卫星收发器:</string>
|
||||
<string name="radar_inverted">倒置:%s</string>
|
||||
<string name="radar_mode">调制:%s</string>
|
||||
<string name="radar_string_no">否</string>
|
||||
<string name="radar_string_yes">是</string>
|
||||
<string name="radar_visible">日照中</string>
|
||||
|
||||
<string name="radar_doppler_calc">多普勒频率计算器</string>
|
||||
<string name="radar_doppler_tx_hint">输入上行频率 (MHz)</string>
|
||||
<string name="radar_doppler_rx_hint">输入下行频率 (MHz)</string>
|
||||
<string name="radar_doppler_offset_hint">偏移 (kHz)</string>
|
||||
<string name="radar_doppler_info">线性转发器:输入一个频率即可算出另一个</string>
|
||||
|
||||
<string name="radar_cw_decoder">CW 解码器</string>
|
||||
<string name="radar_cw_start">开始</string>
|
||||
<string name="radar_cw_stop">停止</string>
|
||||
<string name="radar_cw_reset">清空</string>
|
||||
|
||||
<!-- Map screen -->
|
||||
<string name="map_prev">上一个</string>
|
||||
<string name="map_next">下一个</string>
|
||||
<string name="map_grid_mode">网格模式</string>
|
||||
<string name="map_satellite_mode">卫星模式</string>
|
||||
<string name="map_first_call_mode">首通呼号</string>
|
||||
<string name="map_azimuth">方位角:%.1f°</string>
|
||||
<string name="map_elevation">仰角:%.1f°</string>
|
||||
<string name="map_altitude">高度:%.0f km</string>
|
||||
<string name="map_distance">距离:%.0f km</string>
|
||||
<string name="map_latitude">纬度:%.1f°</string>
|
||||
<string name="map_longitude">经度:%.1f°</string>
|
||||
<string name="map_phase">相位:%.1f°</string>
|
||||
<string name="map_eclipsed">地影中</string>
|
||||
<string name="map_period">周期:% .0f min</string>
|
||||
<string name="map_velocity">速度:%.2f km/s</string>
|
||||
<string name="map_visibility">能见度:%s</string>
|
||||
<!-- Worked-grid QSO dialog -->
|
||||
<string name="grid_qso_calls_count" formatted="false">%d 个呼号 · %d 次通联</string>
|
||||
<string name="region_qso_empty">该区域暂无已确认通联</string>
|
||||
<string name="grid_qso_count_one">%d QSO</string>
|
||||
<string name="grid_qso_count_many">%d QSOs</string>
|
||||
<string name="grid_qso_first">首通</string>
|
||||
<string name="grid_qso_no_sat">卫星未知</string>
|
||||
<string name="grid_qso_match">匹配</string>
|
||||
<!-- 未通联网格中的“标记想通联的电台”提醒 -->
|
||||
<string name="grid_mark_station">标记你想通联的电台</string>
|
||||
<string name="grid_mark_callsign">呼号</string>
|
||||
<string name="grid_mark_confirm">标记</string>
|
||||
<string name="grid_mark_label">标记</string>
|
||||
<string name="grid_mark_delete">删除标记</string>
|
||||
<string name="grid_mark_pin">置顶</string>
|
||||
<string name="map_visible">日照中</string>
|
||||
|
||||
<!-- Settings screen -->
|
||||
<string name="prefs_privacy_title">隐私政策</string>
|
||||
|
||||
<string name="prefs_updated_title">更新:%s</string>
|
||||
<string name="prefs_updated_time">yyyy年M月d日 HH:mm:ss</string>
|
||||
|
||||
<string name="prefs_loc_title">站点位置</string>
|
||||
<string name="prefs_loc_gps_title">GPS 定位</string>
|
||||
<string name="prefs_loc_gps_error">检查您的位置权限</string>
|
||||
<string name="prefs_loc_input_title">输入位置</string>
|
||||
<string name="prefs_loc_input_error">输入的位置无效</string>
|
||||
<string name="prefs_loc_qth_title">QTH 网格</string>
|
||||
<string name="prefs_loc_qth_error">无效的 QTH 网格</string>
|
||||
<string name="prefs_loc_success">位置更新成功</string>
|
||||
<string name="prefs_station_title">站点位置设置</string>
|
||||
<string name="prefs_station_lat_text">输入站点的纬度</string>
|
||||
<string name="prefs_station_lon_text">输入站点的经度</string>
|
||||
<string name="prefs_locator_title">QTH 网格</string>
|
||||
<string name="prefs_wavelog_configure">配置</string>
|
||||
<string name="prefs_wavelog_url">Wavelog 地址</string>
|
||||
<string name="prefs_wavelog_syncing">同步中…</string>
|
||||
<string name="prefs_wavelog_upload_title">Wavelog 配置</string>
|
||||
<string name="prefs_wavelog_exclusive">与 LoTW 二选一配置,两者不兼容。</string>
|
||||
<string name="prefs_wavelog_sync_incremental">增量同步</string>
|
||||
<string name="prefs_wavelog_sync_full">全量同步</string>
|
||||
<string name="prefs_wavelog_clear">清除配置</string>
|
||||
<string name="prefs_wavelog_clear_title">清除 Wavelog 配置?</string>
|
||||
<string name="prefs_wavelog_clear_message">清除后将切回 LoTW 模式,LoTW 功能恢复可用。</string>
|
||||
<string name="prefs_wavelog_switch_title">配置提醒</string>
|
||||
<string name="prefs_wavelog_switch_message">已配置 LoTW。配置 Wavelog 后,App 内 LoTW 的上传与同步将不可用(两者二选一,不兼容)。继续?</string>
|
||||
<string name="prefs_wavelog_switch_continue">继续</string>
|
||||
<string name="prefs_lotw_disabled_wavelog">Wavelog 模式中不可用(二选一)</string>
|
||||
<string name="prefs_wavelog_upload_not_configured">未配置 — 把本机通联记录上传到 Wavelog</string>
|
||||
<string name="prefs_wavelog_upload_configured">站点 %1$s — 可以上传</string>
|
||||
<string name="prefs_wavelog_upload_apikey">API key(读写权限)</string>
|
||||
<string name="prefs_wavelog_upload_fetch">获取站点</string>
|
||||
<string name="prefs_wavelog_upload_probing">检测中…</string>
|
||||
<string name="prefs_wavelog_upload_station_none">未选择站点 — 点「获取站点」</string>
|
||||
<string name="prefs_wavelog_upload_rights_rw">密钥:读写 — 可上传</string>
|
||||
<string name="prefs_wavelog_upload_rights_r">密钥只读 — 请在 Wavelog → API 生成「读写」密钥</string>
|
||||
<string name="prefs_wavelog_upload_hint">点「获取站点」选择上传使用的台站档案;密钥需要「读写」权限。</string>
|
||||
<string name="prefs_lotw_title">LoTW 已确认网格</string>
|
||||
<string name="prefs_lotw_callsign">LoTW 呼号</string>
|
||||
<string name="prefs_lotw_password">LoTW 密码</string>
|
||||
<string name="prefs_lotw_hint">直接从 ARRL LoTW 拉取该呼号已确认 QSO 的网格并覆盖本地已存集合(当前 %1$d 个)。密码仅保存在手机本地。点"同步"会自动保存凭据并拉取。</string>
|
||||
<string name="prefs_lotw_sync">同步</string>
|
||||
<string name="prefs_lotw_syncing">同步中…</string>
|
||||
<string name="prefs_lotw_configured">已从 LoTW 同步 — 共 %1$d 个网格</string>
|
||||
<string name="prefs_lotw_not_configured">未配置 — 直接登录 ARRL LoTW 拉取已确认 QSO 的网格,无需自建服务器</string>
|
||||
<string name="lotw_sync_error_not_configured">LoTW 呼号/密码未配置</string>
|
||||
<string name="lotw_sync_error_credentials">LoTW 同步失败——呼号或密码错误</string>
|
||||
<string name="lotw_sync_error_rate_limited">LoTW 同步失败——服务器限流,请等几分钟后再试</string>
|
||||
<string name="lotw_sync_error_timeout">LoTW 同步失败——连接超时,请更换网络重试</string>
|
||||
<string name="lotw_sync_error_network">LoTW 同步失败——网络错误(%1$s)</string>
|
||||
<string name="lotw_sync_full">全量同步</string>
|
||||
<string name="lotw_sync_incremental">增量合并</string>
|
||||
<string name="lotw_sync_rate_hint">下载速度约每 100 条 QSO 需 8 秒(另有 5–40 秒服务器生成时间)</string>
|
||||
<string name="lotw_sync_progress_connecting">正在连接 LoTW 服务器…</string>
|
||||
<string name="lotw_sync_progress_downloading">正在下载报告…</string>
|
||||
<string name="lotw_sync_progress_saving">正在处理下载的数据…</string>
|
||||
<string name="lotw_sync_progress_count">本次同步 %1$d 条 QSO…</string>
|
||||
<string name="lotw_sync_progress_qso">本次同步 %1$d 条 QSO,预计还需 %2$d 秒</string>
|
||||
<string name="prefs_locator_text">使用梅登黑德网格设置站点位置</string>
|
||||
|
||||
<string name="btn_delete">删除</string>
|
||||
<string name="prefs_logbook_title">日志本</string>
|
||||
<string name="prefs_logbook_count">%1$d 条记录 — 本地通联与 LoTW 确认</string>
|
||||
<string name="prefs_logbook_empty">暂无记录 — 在雷达页 Log 标签记录通联</string>
|
||||
<string name="prefs_logbook_close">关闭</string>
|
||||
<string name="prefs_logbook_upload">上传</string>
|
||||
<string name="prefs_logbook_upload_title">上传到 LoTW</string>
|
||||
<string name="prefs_logbook_upload_confirm">确认上传</string>
|
||||
<string name="prefs_logbook_resubmit">重传选中 (%1$d)</string>
|
||||
<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>
|
||||
<string name="lotw_upload_grid_title">上传前核对</string>
|
||||
<string name="lotw_upload_grid_fix">修改台址</string>
|
||||
<string name="lotw_upload_grid_ignore">忽略</string>
|
||||
<string name="lotw_upload_grid_mismatch">当前定位 %1$s 不在证书台址网格 %2$s 内——如果正在此处操作,请先更新台址再上传。</string>
|
||||
<string name="lotw_upload_pos_hint">当前定位 %1$s 不在证书台址网格 %2$s 内 · 点此更新</string>
|
||||
<string name="prefs_lotw_upload_title">LoTW 上传证书</string>
|
||||
<string name="prefs_lotw_upload_configured">证书已导入 · 台址网格 %1$s</string>
|
||||
<string name="prefs_lotw_upload_not_configured">未配置 — 导入 TrustedQSL 证书后即可上传通联</string>
|
||||
<string name="prefs_lotw_upload_busy">处理中…</string>
|
||||
<string name="prefs_lotw_upload_cert_hint">导入你的 TrustedQSL .p12 证书(与 TQSL 同款)用于签名上传通联。密码仅加密保存在手机本地。</string>
|
||||
<string name="prefs_lotw_upload_password">证书密码</string>
|
||||
<string name="prefs_lotw_upload_password_hint">证书无密码时留空即可</string>
|
||||
<string name="prefs_lotw_upload_import">选择 .p12 文件</string>
|
||||
<string name="prefs_lotw_upload_import_confirm">导入</string>
|
||||
<string name="prefs_lotw_upload_error_password">证书密码错误,请检查 TQSL 导出 .p12 时设置的密码</string>
|
||||
<string name="prefs_lotw_upload_error_expired">证书已过期或尚未生效</string>
|
||||
<string name="prefs_lotw_upload_error_invalid">不是有效的 LoTW 证书文件</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>
|
||||
<string name="prefs_lotw_upload_station_title">台址</string>
|
||||
<string name="prefs_lotw_upload_region_hint">未选择</string>
|
||||
<string name="prefs_lotw_upload_region_label">省份</string>
|
||||
<string name="prefs_lotw_upload_region_pending">导入证书后可选</string>
|
||||
<string name="prefs_lotw_upload_grid">网格(逗号分隔,可多格)</string>
|
||||
<string name="prefs_lotw_upload_save">保存</string>
|
||||
<string name="prefs_lotw_upload_close">关闭</string>
|
||||
|
||||
<string name="prefs_data_title">卫星数据更新</string>
|
||||
<string name="prefs_data_entries">卫星:%s</string>
|
||||
<string name="prefs_data_radios">收发器:%s</string>
|
||||
<string name="prefs_data_update">在线更新</string>
|
||||
<string name="prefs_data_import">导入文件</string>
|
||||
<string name="prefs_data_clear">清空</string>
|
||||
<string name="prefs_data_clear_success">数据清空成功</string>
|
||||
<string name="prefs_data_update_success">更新成功</string>
|
||||
|
||||
<string name="prefs_data_import_satellites_error">未导入卫星。请选择有效的 TLE/3LE (.txt) 或 OMM (.csv) 文件。</string>
|
||||
<string name="prefs_data_import_transceivers_error">未导入收发器。请选择有效的 SatNOGS (.json) 文件。</string>
|
||||
|
||||
<string name="prefs_data_sources_title">自定义数据源</string>
|
||||
<string name="prefs_data_sources_sat_hint">轨道数据优先取列表最新星历,此顺序仅影响卫星名称</string>
|
||||
<string name="prefs_data_sources_transceivers_hint">靠前优先:收发器数据</string>
|
||||
<string name="prefs_data_sources_restore">恢复默认数据源</string>
|
||||
<string name="prefs_data_sources_tle_switch">自定义TLE URL</string>
|
||||
<string name="prefs_data_sources_transceivers_switch">自定义收发器URL</string>
|
||||
<string name="prefs_data_sources_url_title" translatable="false">URL</string>
|
||||
<string name="prefs_data_sources_satellites_label">卫星数据</string>
|
||||
<string name="prefs_data_sources_transceivers_label">收发器数据</string>
|
||||
|
||||
<string name="prefs_data_output_title">数据输出</string>
|
||||
<string name="prefs_net_output">网络</string>
|
||||
<string name="prefs_bt_output">蓝牙</string>
|
||||
<string name="prefs_cat_output">CAT</string>
|
||||
|
||||
<string name="prefs_net_title">网络数据输出</string>
|
||||
<string name="prefs_net_rotator_switch">启用方位俯仰角输出</string>
|
||||
<string name="prefs_net_rotator_address_hint">IP:端口</string>
|
||||
<string name="prefs_net_rotator_format_hint">数据格式</string>
|
||||
<string name="prefs_net_frequency_switch">启用多普勒频率输出</string>
|
||||
<string name="prefs_net_frequency_address_hint">IP:端口</string>
|
||||
<string name="prefs_net_frequency_format_hint">数据格式</string>
|
||||
<string name="prefs_net_frequency_offset_hint" translatable="false">频率偏移 (Hz)</string>
|
||||
<string name="prefs_net_frequency_offset_help" translatable="false">范围:-50000 到 50000 Hz。正值提高上报频率,负值降低上报频率。</string>
|
||||
|
||||
<string name="prefs_bt_title">蓝牙数据输出</string>
|
||||
<string name="prefs_bt_rotator_switch">启用方位俯仰角输出</string>
|
||||
<string name="prefs_bt_rotator_device_hint">蓝牙设备 ID</string>
|
||||
<string name="prefs_bt_rotator_output_hint">数据格式</string>
|
||||
<string name="prefs_bt_frequency_switch">启用多普勒频率输出</string>
|
||||
<string name="prefs_bt_frequency_device_hint">蓝牙设备 ID</string>
|
||||
<string name="prefs_bt_frequency_output_hint">数据格式</string>
|
||||
<string name="prefs_bt_perm_error">请检查蓝牙权限</string>
|
||||
<string name="prefs_net_perm_error">请检查网络权限</string>
|
||||
|
||||
<!-- Radio Control -->
|
||||
<string name="nav_radiocontrol">电台控制</string>
|
||||
<string name="rc_settings_title">CAT 电台控制</string>
|
||||
<string name="rc_radio_model">电台型号</string>
|
||||
<string name="rc_tx_device_hint">发射电台蓝牙地址</string>
|
||||
<string name="rc_rx_device_hint">接收电台蓝牙地址</string>
|
||||
<string name="rc_tx_name_hint">发射电台名称</string>
|
||||
<string name="rc_rx_name_hint">接收电台名称</string>
|
||||
<string name="rc_enable_switch">启用 CAT 控制</string>
|
||||
|
||||
<string name="prefs_other_title">其他设置</string>
|
||||
<string name="prefs_map_title">地图设置</string>
|
||||
<string name="prefs_map_configure">配置地图</string>
|
||||
<string name="prefs_map_source_title">地图来源</string>
|
||||
<string name="prefs_map_source_osm">离线 OSM 地图</string>
|
||||
<string name="prefs_map_source_tianditu_vector">天地图(矢量)</string>
|
||||
<string name="prefs_map_source_tianditu_image">天地图(影像)</string>
|
||||
<string name="prefs_map_tianditu_key">天地图访问密钥</string>
|
||||
<string name="prefs_map_tianditu_key_help">使用天地图前,需先在天地图开发者控制台申请服务端应用 Key。</string>
|
||||
<string name="prefs_map_tianditu_key_required">选择天地图需要填写访问密钥;保存密钥前将继续使用离线 OSM 地图。</string>
|
||||
<string name="prefs_other_switch_utc">以 UTC 时间显示</string>
|
||||
<string name="prefs_other_switch_update">启用卫星数据自动更新</string>
|
||||
<string name="prefs_other_switch_lotw_sync">自动同步 LoTW 已确认网格</string>
|
||||
<string name="prefs_other_switch_sweep">启用雷达扫描动画</string>
|
||||
<string name="prefs_other_switch_sensors">使用传感器旋转雷达视图</string>
|
||||
<string name="prefs_compass_calibration_button">校准指南针</string>
|
||||
<string name="prefs_compass_calibration_title">指南针校准</string>
|
||||
<string name="prefs_compass_calibration_instruction">缓慢移动手机画“8”字,直到精度提高。请远离磁铁和金属物体。</string>
|
||||
<string name="prefs_compass_accuracy">精度:%s</string>
|
||||
<string name="prefs_compass_accuracy_high">高——校准完成</string>
|
||||
<string name="prefs_compass_accuracy_medium">中——请继续移动</string>
|
||||
<string name="prefs_compass_accuracy_low">低——请继续移动</string>
|
||||
<string name="prefs_compass_accuracy_unreliable">不可靠——请开始“8”字动作</string>
|
||||
<string name="prefs_compass_accuracy_unavailable">设备没有可用的磁场传感器</string>
|
||||
<string name="prefs_compass_heading">校正后航向:%.1f°</string>
|
||||
<string name="prefs_compass_offset">手动航向偏置(°)</string>
|
||||
<string name="prefs_compass_offset_support">范围 −180° 至 +180°;正值为顺时针偏移。</string>
|
||||
<string name="prefs_compass_offset_reset">偏置归零</string>
|
||||
<string name="prefs_other_switch_light_theme">浅色主题</string>
|
||||
<string name="prefs_other_switch_night_mode">启用红色夜间模式</string>
|
||||
<string name="prefs_other_switch_map_grid">地图显示 Maidenhead 网格</string>
|
||||
|
||||
<string name="prefs_highlight_title">仰角高亮</string>
|
||||
<string name="prefs_highlight_low">低 < %1$d°</string>
|
||||
<string name="prefs_highlight_mid">中 %1$d-%2$d°</string>
|
||||
<string name="prefs_highlight_high">高 > %1$d°</string>
|
||||
|
||||
<string name="prefs_outro_title">致谢与声明</string>
|
||||
<string name="prefs_project_wiki">Project Wiki</string>
|
||||
<string name="project_wiki_intro">Look4Sat-BA7OPF 是基于 Look4Sat 的定制版,保留上游全部功能,并新增:</string>
|
||||
<string name="project_wiki_section_logbook">卫星日志本与 LoTW</string>
|
||||
<string name="project_wiki_logbook_1">卫星通联日志本——通联时一键记录呼号、时间、频率、模式、信号报告和网格。</string>
|
||||
<string name="project_wiki_logbook_2">LoTW 直传——导入 ARRL 证书后,直接在 App 内把日志上传到 LoTW,无需电脑。</string>
|
||||
<string name="project_wiki_logbook_3">确认同步——一键同步 LoTW 官方确认,日志自动标记“已上传 / 已确认”;修改已上传的记录会自动新建一条,原记录不受影响。</string>
|
||||
<string name="project_wiki_logbook_how">用法:通联后点“记录”→ 日志页点“上传”→ 设置里“同步”查看确认。</string>
|
||||
<string name="project_wiki_section_mutual">双站过境匹配</string>
|
||||
<string name="project_wiki_mutual_1">友台共视匹配——输入你和友台的位置,找出双方同时可见的卫星过境,点一下直接进雷达图。</string>
|
||||
<string name="project_wiki_mutual_how">用法:Match 页 → 设置两站位置 → 查询。</string>
|
||||
<string name="project_wiki_section_grids">通联网格地图</string>
|
||||
<string name="project_wiki_grids_1">已通联网格——LoTW/Wavelog 同步的网格在地图上标绿,支持奖状边界和首通呼号标签;点格子看该网格的通联记录,还能一键去匹配友台。</string>
|
||||
<string name="project_wiki_grids_how">用法:地图页开启网格模式 → 设置里同步 LoTW/Wavelog。</string>
|
||||
<string name="prefs_outro_thanks" translatable="false">
|
||||
• Look4Sat 所有用户及贡献者!
|
||||
\n• David A. B. Johnson (predict4java)
|
||||
\n• Dave Moten (predict4java)
|
||||
\n• Alexandru Csete (Gpredict)
|
||||
\n• Dr T.S. Kelso (Celestrak)
|
||||
\n• Libre Space Foundation (SatNOGS)
|
||||
\n• xdsopl 和 Robot36 贡献者!
|
||||
\n• BG7NTA BG5JSU(CW解码功能)
|
||||
\n• BH2VSQ BG5JSU(天地图渲染支持)
|
||||
\n• BG5JSU(LoTW上传支持)
|
||||
</string>
|
||||
<string name="prefs_outro_notice">Look4Sat-BA7OPF 是基于 Look4Sat(GPL-3.0)的非官方修改版,由 BA7OPF 独立维护,与原项目无隶属关系,亦未获其背书。
|
||||
上游项目:https://github.com/rt-bishop/Look4Sat</string>
|
||||
<string name="prefs_outro_license">基于 GNU GPL v3 许可 · 本程序不提供任何保修</string>
|
||||
|
||||
<!-- 匹配页 Mutual match page -->
|
||||
<string name="mutual_title">过境匹配</string>
|
||||
<string name="mutual_station_you">你的位置</string>
|
||||
<string name="mutual_station_opposite">友台位置</string>
|
||||
<string name="mutual_label_you">你</string>
|
||||
<string name="mutual_label_opposite">友台</string>
|
||||
<string name="mutual_status_calculating">正在计算双方共同可见窗口…</string>
|
||||
<string name="mutual_status_error">请修正提示后重新查询</string>
|
||||
<string name="mutual_status_matches">找到 %d 个匹配 · 点击卡片展开曲线</string>
|
||||
<string name="mutual_status_none">未找到共同可见窗口,调整时间或仰角后重试</string>
|
||||
<string name="mutual_status_idle">输入双方位置后点击查询</string>
|
||||
<string name="mutual_chip_calculating">计算中</string>
|
||||
<string name="mutual_chip_error">有错误</string>
|
||||
<string name="mutual_chip_matches">%d 个匹配</string>
|
||||
<string name="mutual_chip_none">无匹配</string>
|
||||
<string name="mutual_chip_idle">待查询</string>
|
||||
<string name="mutual_use_current">填入当前位置</string>
|
||||
<string name="mutual_lat">纬度</string>
|
||||
<string name="mutual_lon">经度</string>
|
||||
<string name="mutual_grid">网格</string>
|
||||
<string name="mutual_grid_hint">支持 4/6/8 位 Maidenhead 网格;也可直接输入经纬度</string>
|
||||
<string name="mutual_min_elevation">最小仰角:%d°</string>
|
||||
<string name="mutual_time_range">时间范围</string>
|
||||
<string name="mutual_filter_transponder">转发器</string>
|
||||
<string name="mutual_filter_fm">FM</string>
|
||||
<string name="mutual_filter_linear">线性</string>
|
||||
<string name="mutual_calculating">计算中…</string>
|
||||
<string name="mutual_query">查询匹配</string>
|
||||
<string name="mutual_elevation_you">你:%d°</string>
|
||||
<string name="mutual_elevation_opposite">友台:%d°</string>
|
||||
<string name="mutual_both_tracks">双方轨迹</string>
|
||||
<string name="mutual_open_radar">查看雷达</string>
|
||||
<string name="mutual_station_a">站点A</string>
|
||||
<string name="mutual_station_b">站点B</string>
|
||||
<string name="update_check_title">检查更新</string>
|
||||
<string name="update_check_button">检查更新</string>
|
||||
<string name="update_check_current">当前版本:%s</string>
|
||||
<string name="update_check_latest">最新版本:%s</string>
|
||||
<string name="update_check_up_to_date">已是最新版本</string>
|
||||
<string name="update_check_available">发现新版本</string>
|
||||
<string name="update_check_release_notes">更新内容</string>
|
||||
<string name="update_check_download">下载并安装</string>
|
||||
<string name="update_check_downloading">正在下载…</string>
|
||||
<string name="update_check_error">检查更新失败,请检查网络连接。</string>
|
||||
<string name="update_check_download_error">下载失败,请重试。</string>
|
||||
<string name="update_check_no_apk">该版本未附带 APK 安装包。</string>
|
||||
|
||||
<!-- QSO edit dialog -->
|
||||
<string name="qso_edit_title">编辑通联记录</string>
|
||||
<string name="qso_edit_uploaded_notice">该通联已上传或已确认。保存将保留原记录不变,并新建一条记录。</string>
|
||||
<string name="qso_edit_callsign">呼号</string>
|
||||
<string name="qso_edit_date">日期 (UTC)</string>
|
||||
<string name="qso_edit_time">时间 (UTC)</string>
|
||||
<string name="qso_edit_tx">上行频率 (MHz)</string>
|
||||
<string name="qso_edit_rx">下行频率 (MHz)</string>
|
||||
<string name="qso_edit_band">频段:%1$s</string>
|
||||
<string name="qso_edit_mode">模式</string>
|
||||
<string name="qso_edit_satellite">卫星</string>
|
||||
<string name="qso_edit_hint_satellite">LoTW 只接受它的官方卫星名</string>
|
||||
<string name="qso_edit_sent">发送报告</string>
|
||||
<string name="qso_edit_received">接收报告</string>
|
||||
<string name="qso_edit_grid">对方网格</string>
|
||||
<string name="qso_edit_comment">备注</string>
|
||||
<string name="qso_edit_save">保存</string>
|
||||
<string name="qso_edit_cancel">取消</string>
|
||||
<string name="log_out_window_title">不在过境窗口</string>
|
||||
<string name="log_out_window_message">当前时间不在该过境窗口内,将默认按过境中点 %1$s 录入;如需修改,请点击该条通联记录。</string>
|
||||
<string name="log_out_window_confirm">录入</string>
|
||||
<string name="qso_edit_err_callsign">呼号不能为空</string>
|
||||
<string name="qso_edit_err_datetime">日期或时间格式不对 —— 应为 yyyy-MM-dd 与 HH:mm:ss(UTC)</string>
|
||||
<string name="qso_edit_err_frequency">频率格式不对 —— 请输入 MHz,例如 145.850</string>
|
||||
<string name="qso_edit_err_satellite">不是 LoTW 官方卫星名 —— 请从列表中选一个</string>
|
||||
<string name="qso_edit_satellite_pick">选择</string>
|
||||
<string name="qso_edit_satellite_pick_title">LoTW 官方卫星名</string>
|
||||
<string name="qso_edit_satellite_search">搜索卫星名或数据源别名</string>
|
||||
<string name="qso_edit_satellite_official">保存并签名时使用 %1$s</string>
|
||||
<string name="qso_edit_warn_no_freq">没有上行频率:LoTW 要求 BAND,补上频率前这条无法上传</string>
|
||||
|
||||
<!-- 网格寻线(VUCC 网格线/网格角实地导航) -->
|
||||
<string name="gridfinder_title">网格寻线</string>
|
||||
<string name="gridfinder_status_corner">位于网格点上</string>
|
||||
<string name="gridfinder_status_line">位于网格线上</string>
|
||||
<string name="gridfinder_status_inside">在网格内部</string>
|
||||
<string name="gridfinder_status_unknown">位置不可用</string>
|
||||
<string name="gridfinder_status_waiting">正在获取定位…</string>
|
||||
<string name="gridfinder_status_blocked">需要定位权限</string>
|
||||
<string name="gridfinder_status_disabled">本机定位服务未开启</string>
|
||||
<string name="gridfinder_status_confirmed">已由 %1$d 次连续满足 20 英尺规则的定位确认</string>
|
||||
<string name="gridfinder_status_unconfirmed">尚未确认:精度达 6.1 m 的连续定位 %1$d/%2$d 次</string>
|
||||
<string name="gridfinder_grant_permission">授予定位权限</string>
|
||||
<string name="gridfinder_card_location">位置</string>
|
||||
<string name="gridfinder_card_corner">最近网格点引导</string>
|
||||
<string name="gridfinder_card_line">最近网格线引导</string>
|
||||
<string name="gridfinder_card_lines">两条边界线</string>
|
||||
<string name="gridfinder_card_map">近距离网格图</string>
|
||||
<string name="gridfinder_card_fix">定位质量</string>
|
||||
<string name="gridfinder_card_compass">罗盘</string>
|
||||
<string name="gridfinder_label_claimable">此处可计分的网格</string>
|
||||
<string name="gridfinder_label_grid4">网格 (4)</string>
|
||||
<string name="gridfinder_label_grid6">网格 (6)</string>
|
||||
<string name="gridfinder_label_grid8">网格 (8)</string>
|
||||
<string name="gridfinder_label_latitude">纬度</string>
|
||||
<string name="gridfinder_label_longitude">经度</string>
|
||||
<string name="gridfinder_label_altitude">海拔</string>
|
||||
<string name="gridfinder_label_accuracy">水平精度</string>
|
||||
<string name="gridfinder_label_fix_age">定位时刻</string>
|
||||
<string name="gridfinder_label_provider">定位来源</string>
|
||||
<string name="gridfinder_value_provider_on">已开启</string>
|
||||
<string name="gridfinder_value_provider_off">未开启</string>
|
||||
<string name="gridfinder_label_distance">距离</string>
|
||||
<string name="gridfinder_label_bearing">方位角</string>
|
||||
<string name="gridfinder_label_direction">方向</string>
|
||||
<string name="gridfinder_label_grids">网格</string>
|
||||
<string name="gridfinder_label_lat_line">纬线</string>
|
||||
<string name="gridfinder_label_lon_line">经线</string>
|
||||
<string name="gridfinder_line_value">位于%2$s方向 %1$s</string>
|
||||
<string name="gridfinder_line_latitude">这是纬度边界,东西走向</string>
|
||||
<string name="gridfinder_line_longitude">这是经度边界,南北走向</string>
|
||||
<string name="gridfinder_label_heading">朝向</string>
|
||||
<string name="gridfinder_label_compass_accuracy">罗盘精度</string>
|
||||
<string name="gridfinder_action_set_station">设为我的台址</string>
|
||||
<string name="gridfinder_action_station_saved">台址已更新 —— 过境预报将使用这个位置</string>
|
||||
<string name="gridfinder_action_calibrate">校准罗盘</string>
|
||||
<string name="gridfinder_map_legend">中心点是你的位置,虚线是正北与正东,直线是两条网格边界,绿点是最近的网格点,圆环是最近网格线的垂足,红色指针是你的朝向</string>
|
||||
<string name="gridfinder_compass_disabled_hint">设置里未开启罗盘,表盘按正北朝上显示。方位角数字仍可引导你</string>
|
||||
<string name="gridfinder_compass_unavailable_hint">本机没有磁力传感器 —— 请按方位角数字导航</string>
|
||||
<string name="gridfinder_compass_hint">请平持手机,箭头指向应走的方向</string>
|
||||
<string name="gridfinder_vucc_rule_note">VUCC 20 英尺规则:%1$.1f m</string>
|
||||
<string name="gridfinder_direction_north">北</string>
|
||||
<string name="gridfinder_direction_south">南</string>
|
||||
<string name="gridfinder_direction_east">东</string>
|
||||
<string name="gridfinder_direction_west">西</string>
|
||||
<string name="gridfinder_action_set_lotw_station">设为 LoTW 上传台址(%1$s)</string>
|
||||
<string name="gridfinder_lotw_station_hint">预填上传台站网格:上传时 LoTW 按这些格记录(线上 2 格、角上 4 格);证书呼号保持不变。</string>
|
||||
|
||||
</resources>
|
||||
@@ -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>
|
||||
|
||||
+91
@@ -0,0 +1,91 @@
|
||||
/*
|
||||
* 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.presentation
|
||||
|
||||
import java.io.File
|
||||
import javax.xml.parsers.DocumentBuilderFactory
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import org.w3c.dom.Element
|
||||
|
||||
/**
|
||||
* Android 7+ resolves Chinese by script, so an unqualified values-zh is treated as
|
||||
* zh-Hans and a Traditional device (zh-TW / zh-HK / zh-MO, i.e. zh-Hant) never matches
|
||||
* it — it used to fall back to the English default. values-b+zh+Hant carries the
|
||||
* Simplified text for those devices, and the locale filter in the convention plugin
|
||||
* keeps that config in the APK.
|
||||
*
|
||||
* Both halves fail silently at runtime (English strings, or a Hant config dropped from
|
||||
* the resource table), so they are pinned here: keep the Hant copy verbatim and keep
|
||||
* the Traditional locales in the locale filter.
|
||||
*/
|
||||
class TraditionalChineseFallbackTest {
|
||||
|
||||
private val simplified = stringEntries("src/main/res/values-zh/strings.xml")
|
||||
private val traditional = stringEntries("src/main/res/values-b+zh+Hant/strings.xml")
|
||||
private val conventionPlugin = generateSequence(File("").absoluteFile) { it.parentFile }
|
||||
.map { File(it, "build-logic/convention/src/main/java/com/rtbishop/look4sat/convention/PluginSetupUtils.kt") }
|
||||
.first { it.isFile }
|
||||
|
||||
@Test
|
||||
fun `hant fallback is a verbatim copy of the simplified strings`() {
|
||||
assertTrue("values-zh/strings.xml looks empty or unparsable", simplified.size > 400)
|
||||
val missing = (simplified.keys - traditional.keys).sorted()
|
||||
val extra = (traditional.keys - simplified.keys).sorted()
|
||||
val changed = simplified.keys.intersect(traditional.keys)
|
||||
.filter { simplified[it] != traditional[it] }
|
||||
.sorted()
|
||||
assertEquals("keys missing from values-b+zh+Hant", emptyList<String>(), missing)
|
||||
assertEquals("keys only present in values-b+zh+Hant", emptyList<String>(), extra)
|
||||
assertEquals("values-b+zh+Hant diverged from values-zh (copy the change over)", emptyList<String>(), changed)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `traditional locales survive the locale filter`() {
|
||||
assertTrue("convention plugin not found: ${conventionPlugin.absolutePath}", conventionPlugin.isFile)
|
||||
// Comments may mention either spelling, so only the code lines are inspected.
|
||||
val filters = conventionPlugin.readLines()
|
||||
.filterNot { it.trimStart().startsWith("//") }
|
||||
.joinToString("\n")
|
||||
val traditionalLocales = listOf("zh-rTW", "zh-rHK", "zh-rMO")
|
||||
val dropped = traditionalLocales.filterNot { filters.contains("\"$it\"") }
|
||||
assertEquals(
|
||||
"localeFilters must list the Traditional locales, otherwise aapt2 strips" +
|
||||
" values-b+zh+Hant from the APK and Traditional devices get English",
|
||||
emptyList<String>(),
|
||||
dropped
|
||||
)
|
||||
// aapt2 rejects the script spelling, so it must not creep back into the filter.
|
||||
assertTrue("localeFilters must use the aapt2 'zh-rTW' spelling, not 'zh-Hant'", !filters.contains("\"zh-Hant\""))
|
||||
}
|
||||
|
||||
private fun stringEntries(path: String): Map<String, String> {
|
||||
val file = File(path)
|
||||
assertTrue("resource file not found: ${file.absolutePath}", file.isFile)
|
||||
val document = DocumentBuilderFactory.newInstance()
|
||||
.apply { isNamespaceAware = false }
|
||||
.newDocumentBuilder()
|
||||
.parse(file)
|
||||
val nodes = document.getElementsByTagName("string")
|
||||
return (0 until nodes.length).associate {
|
||||
val element = nodes.item(it) as Element
|
||||
element.getAttribute("name") to element.textContent.trim()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1 +1 @@
|
||||
{"type":"dxcc","regions":[{"id":0,"code":176,"name":"Fiji","label_lon":177.9759,"label_lat":-17.9376,"rings":[[[180.0,-16.0671],[180.0,-16.5552],[179.3641,-16.8014],[178.7251,-17.012],[178.5968,-16.6392],[179.0966,-16.434],[179.4135,-16.3791],[180.0,-16.0671]],[[178.1256,-17.5048],[178.3736,-17.3399],[178.7181,-17.6285],[178.5527,-18.1506],[177.9327,-18.288],[177.3815,-18.1643],[177.285,-17.7247],[177.6709,-17.3811]],[[-179.7933,-16.0209],[-179.9174,-16.5018],[-180.0,-16.5552],[-180.0,-16.0671]]]},{"id":1,"code":470,"name":"Tanzania","label_lon":34.1421,"label_lat":-6.2078,"rings":[[[33.9037,-0.95],[34.0726,-1.0598],[37.6987,-3.097],[37.7669,-3.6771],[39.2022,-4.6768],[38.7405,-5.9089],[38.7998,-6.4757],[39.44,-6.84],[39.47,-7.1],[39.1947,-7.7039],[39.252,-8.0078],[39.1865,-8.4855],[39.5357,-9.1124],[39.9496,-10.0984],[40.3166,-10.3171],[40.3166,-10.3171],[39.521,-10.8969],[38.4276,-11.2852],[37.8276,-11.2688],[37.4713,-11.5688],[36.7752,-11.5945],[36.5141,-11.7209],[35.3124,-11.4391],[34.56,-11.52],[34.28,-10.16],[33.9408,-9.6937],[33.7397,-9.4171],[32.7594,-9.2306],[32.1919,-8.9304],[31.5563,-8.762],[31.1578,-8.5946],[30.74,-8.34],[30.74,-8.34],[30.2,-7.08],[29.62,-6.52],[29.42,-5.94],[29.52,-5.42],[29.34,-4.5],[29.7535,-4.4524],[30.1163,-4.0901],[30.5055,-3.5686],[30.7522,-3.3593],[30.743,-3.0343],[30.5277,-2.8076],[30.4697,-2.4139],[30.4697,-2.4138],[30.7583,-2.2872],[30.8161,-1.6989],[30.4191,-1.1347],[30.7699,-1.0146],[31.8662,-1.0274]]]},{"id":2,"code":302,"name":"W. Sahara","label_lon":-12.572,"label_lat":24.2306,"rings":[[[-8.6656,27.6564],[-8.6651,27.5895],[-8.6844,27.3957],[-8.6873,25.8811],[-11.9694,25.9334],[-11.9372,23.3746],[-12.8742,23.2848],[-13.1188,22.7712],[-12.9291,21.3271],[-16.8452,21.3333],[-17.0634,20.9998],[-17.0204,21.4223],[-17.003,21.4207],[-14.751,21.5006],[-14.6308,21.8609],[-14.2212,22.3102],[-13.8911,23.691],[-12.501,24.7701],[-12.0308,26.0309],[-11.7182,26.1041],[-11.3926,26.8834],[-10.5513,26.9908],[-10.1894,26.8609],[-9.7353,26.8609],[-9.413,27.0885],[-8.7949,27.1207],[-8.8178,27.6564]]]},{"id":3,"code":1,"name":"Canada","label_lon":-110.2438,"label_lat":56.7019,"rings":[[[-122.84,49.0],[-122.9742,49.0025],[-124.9102,49.9846],[-125.6246,50.4166],[-127.4356,50.8306],[-127.9928,51.7158],[-127.8503,52.3296],[-129.1298,52.7554],[-129.3052,53.5616],[-130.515,54.2876],[-130.5361,54.8028],[-130.5361,54.8028],[-129.98,55.285],[-130.0078,55.9158],[-131.7078,56.5521],[-132.7304,57.6929],[-133.3556,58.4103],[-134.2711,58.8611],[-134.945,59.2706],[-135.4758,59.7878],[-136.4797,59.4639],[-137.4525,58.905],[-138.3409,59.5621],[-139.039,60.0],[-140.013,60.2768],[-140.9978,60.3064],[-140.9925,66.0],[-140.986,69.712],[-140.986,69.712],[-139.1205,69.471],[-137.5464,68.99],[-136.5036,68.898],[-135.6258,69.3151],[-134.4146,69.6274],[-132.9292,69.5053],[-131.4314,69.9445],[-129.7947,70.1937],[-129.1077,69.7793],[-128.3616,70.0129],[-128.1382,70.4838],[-127.4471,70.3772],[-125.7563,69.4806],[-124.4248,70.1584],[-124.2897,69.3997],[-123.0611,69.5637],[-122.6835,69.8555],[-121.4723,69.7978],[-119.9429,69.3779],[-117.6027,69.0113],[-116.2264,68.8415],[-115.2469,68.9059],[-113.8979,68.3989],[-115.3049,67.9026],[-113.4973,67.6881],[-110.798,67.8061],[-109.9462,67.981],[-108.8802,67.3814],[-107.7924,67.8874],[-108.813,68.3116],[-108.1672,68.6539],[-106.95,68.7],[-106.15,68.8],[-105.3428,68.5612],[-104.3379,68.018],[-103.2211,68.0978],[-101.4543,67.6469],[-99.9019,67.8057],[-98.4432,67.7816],[-98.5586,68.4039],[-97.6695,68.5786],[-96.1199,68.2394],[-96.1259,67.2934],[-95.4894,68.0907],[-94.685,68.0638],[-94.2328,69.069],[-95.3041,69.6857],[-96.4713,70.0898],[-96.3911,71.1948],[-95.2088,71.9205],[-93.89,71.7601],[-92.8782,71.3187],[-91.5196,70.1913],[-92.4069,69.7],[-90.5471,69.4977],[-90.5515,68.475],[-89.2151,69.2587],[-88.0197,68.6151],[-88.3175,67.8734],[-87.3502,67.1987],[-86.3061,67.9215],[-85.5766,68.7846],[-85.522,69.8821],[-84.1008,69.8054],[-82.6226,69.6583],[-81.2804,69.162],[-81.2202,68.6657],[-81.9644,68.1325],[-81.2593,67.5972],[-81.3865,67.1108],[-83.3446,66.4115],[-84.7354,66.2573],[-85.7694,66.5583],[-86.0676,66.0563],[-87.0314,65.213],[-87.3232,64.7756],[-88.483,64.099],[-89.9144,64.0327],[-90.704,63.6102],[-90.77,62.9602],[-91.9334,62.8351],[-93.157,62.0247],[-94.2415,60.8987],[-94.6293,60.1102],[-94.6846,58.9488],[-93.215,58.7821],[-92.7646,57.8457],[-92.297,57.0871],[-90.8977,57.2847],[-89.0395,56.8517],[-88.0398,56.4716],[-87.3242,55.9991],[-86.0712,55.7238],[-85.0118,55.3026],[-83.3606,55.2449],[-82.2729,55.1483],[-82.4362,54.2823],[-82.125,53.277],[-81.4008,52.1579],[-79.9129,51.2084],[-79.143,51.5339],[-78.6019,52.5621],[-79.1242,54.1414],[-79.8296,54.6677],[-78.2287,55.1365],[-77.0956,55.8374],[-76.5414,56.5342],[-76.6232,57.2026],[-77.3023,58.0521],[-78.5169,58.8046],[-77.3368,59.8526],[-77.7727,60.7579],[-78.1069,62.3196],[-77.4107,62.5505],[-75.6962,62.2784],[-74.6682,62.1811],[-73.8399,62.4438],[-72.9085,62.1051],[-71.6771,61.5254],[-71.3737,61.1372],[-69.5904,61.0614],[Line truncated
|
||||
{"type":"dxcc","regions":[{"id":0,"code":176,"name":"Fiji","label_lon":177.9759,"label_lat":-17.9376,"pfx":"3D2","rings":[[[180.0,-16.0671],[180.0,-16.5552],[179.3641,-16.8014],[178.7251,-17.012],[178.5968,-16.6392],[179.0966,-16.434],[179.4135,-16.3791],[180.0,-16.0671]],[[178.1256,-17.5048],[178.3736,-17.3399],[178.7181,-17.6285],[178.5527,-18.1506],[177.9327,-18.288],[177.3815,-18.1643],[177.285,-17.7247],[177.6709,-17.3811]],[[-179.7933,-16.0209],[-179.9174,-16.5018],[-180.0,-16.5552],[-180.0,-16.0671]]]},{"id":1,"code":470,"name":"Tanzania","label_lon":34.1421,"label_lat":-6.2078,"pfx":"5H","rings":[[[33.9037,-0.95],[34.0726,-1.0598],[37.6987,-3.097],[37.7669,-3.6771],[39.2022,-4.6768],[38.7405,-5.9089],[38.7998,-6.4757],[39.44,-6.84],[39.47,-7.1],[39.1947,-7.7039],[39.252,-8.0078],[39.1865,-8.4855],[39.5357,-9.1124],[39.9496,-10.0984],[40.3166,-10.3171],[40.3166,-10.3171],[39.521,-10.8969],[38.4276,-11.2852],[37.8276,-11.2688],[37.4713,-11.5688],[36.7752,-11.5945],[36.5141,-11.7209],[35.3124,-11.4391],[34.56,-11.52],[34.28,-10.16],[33.9408,-9.6937],[33.7397,-9.4171],[32.7594,-9.2306],[32.1919,-8.9304],[31.5563,-8.762],[31.1578,-8.5946],[30.74,-8.34],[30.74,-8.34],[30.2,-7.08],[29.62,-6.52],[29.42,-5.94],[29.52,-5.42],[29.34,-4.5],[29.7535,-4.4524],[30.1163,-4.0901],[30.5055,-3.5686],[30.7522,-3.3593],[30.743,-3.0343],[30.5277,-2.8076],[30.4697,-2.4139],[30.4697,-2.4138],[30.7583,-2.2872],[30.8161,-1.6989],[30.4191,-1.1347],[30.7699,-1.0146],[31.8662,-1.0274]]]},{"id":2,"code":302,"name":"W. Sahara","label_lon":-12.572,"label_lat":24.2306,"pfx":"S0","rings":[[[-8.6656,27.6564],[-8.6651,27.5895],[-8.6844,27.3957],[-8.6873,25.8811],[-11.9694,25.9334],[-11.9372,23.3746],[-12.8742,23.2848],[-13.1188,22.7712],[-12.9291,21.3271],[-16.8452,21.3333],[-17.0634,20.9998],[-17.0204,21.4223],[-17.003,21.4207],[-14.751,21.5006],[-14.6308,21.8609],[-14.2212,22.3102],[-13.8911,23.691],[-12.501,24.7701],[-12.0308,26.0309],[-11.7182,26.1041],[-11.3926,26.8834],[-10.5513,26.9908],[-10.1894,26.8609],[-9.7353,26.8609],[-9.413,27.0885],[-8.7949,27.1207],[-8.8178,27.6564]]]},{"id":3,"code":1,"name":"Canada","label_lon":-110.2438,"label_lat":56.7019,"pfx":"VE","rings":[[[-122.84,49.0],[-122.9742,49.0025],[-124.9102,49.9846],[-125.6246,50.4166],[-127.4356,50.8306],[-127.9928,51.7158],[-127.8503,52.3296],[-129.1298,52.7554],[-129.3052,53.5616],[-130.515,54.2876],[-130.5361,54.8028],[-130.5361,54.8028],[-129.98,55.285],[-130.0078,55.9158],[-131.7078,56.5521],[-132.7304,57.6929],[-133.3556,58.4103],[-134.2711,58.8611],[-134.945,59.2706],[-135.4758,59.7878],[-136.4797,59.4639],[-137.4525,58.905],[-138.3409,59.5621],[-139.039,60.0],[-140.013,60.2768],[-140.9978,60.3064],[-140.9925,66.0],[-140.986,69.712],[-140.986,69.712],[-139.1205,69.471],[-137.5464,68.99],[-136.5036,68.898],[-135.6258,69.3151],[-134.4146,69.6274],[-132.9292,69.5053],[-131.4314,69.9445],[-129.7947,70.1937],[-129.1077,69.7793],[-128.3616,70.0129],[-128.1382,70.4838],[-127.4471,70.3772],[-125.7563,69.4806],[-124.4248,70.1584],[-124.2897,69.3997],[-123.0611,69.5637],[-122.6835,69.8555],[-121.4723,69.7978],[-119.9429,69.3779],[-117.6027,69.0113],[-116.2264,68.8415],[-115.2469,68.9059],[-113.8979,68.3989],[-115.3049,67.9026],[-113.4973,67.6881],[-110.798,67.8061],[-109.9462,67.981],[-108.8802,67.3814],[-107.7924,67.8874],[-108.813,68.3116],[-108.1672,68.6539],[-106.95,68.7],[-106.15,68.8],[-105.3428,68.5612],[-104.3379,68.018],[-103.2211,68.0978],[-101.4543,67.6469],[-99.9019,67.8057],[-98.4432,67.7816],[-98.5586,68.4039],[-97.6695,68.5786],[-96.1199,68.2394],[-96.1259,67.2934],[-95.4894,68.0907],[-94.685,68.0638],[-94.2328,69.069],[-95.3041,69.6857],[-96.4713,70.0898],[-96.3911,71.1948],[-95.2088,71.9205],[-93.89,71.7601],[-92.8782,71.3187],[-91.5196,70.1913],[-92.4069,69.7],[-90.5471,69.4977],[-90.5515,68.475],[-89.2151,69.2587],[-88.0197,68.6151],[-88.3175,67.8734],[-87.3502,67.1987],[-86.3061,67.9215],[-85.5766,68.7846],[-85.522,69.8821],[-84.1008,69.8054],[-82.6226,69.6583],[-81.2804,69.162],[-81.2202,68.6657],[-81.9644,68.1325],[-81.2593,67.5972],[-81.3865,67.1108],[-83.3446,66.4115],[-84.7354,66.2573],[-85.7694,66.5583],[-86.0676,66.0563],[-87.0314,65.213],[-87.3232,64.7756],[-88.483,64.099],[-89.9144,64.0327],[-90.704,63.6102],[-90.77,62.9602],[-91.9334,62.8351],[-93.157,62.0247],[-94.2415,60.8987],[-94.6293,60.1102],[-94.6846,58.9488],[-93.215,58.7821],[-92.7646,57.8457],[-92.297,57.0871],[-90.8977,57.2847],[-89.0395,56.8517],[-88.0398,56.4716],[-87.3242,55.9991],[-86.0712,55.7238],[-85.0118,55.3026],[-83.3606,55.2449],[-82.2729,55.1483],[-82.4362,54.2823],[-82.125,53.277],[-81.4008,52.1579],[-79.9129,51.2084],[-79.143,51.5339],[-78.6019,52.5621],[-79.1242,54.1414],[-79.8296,54.6677],[-78.2287,55.1365],[-77.0956,55.8374],[-76.5414,56.5342],[-76.6232,57.2026],[-77.3023,58.0521],[-78.5169,58.8046],[-77.3368,59.8526],[-77.7727,60.7579],[-78.1069,62.3196],[-77.4107,62.5505],[-75.6962,62.2784],[-74.6682,62.1811],[-73.8399,62.4438],[-72.9085,62.1051],[-71.6771,61.Line truncated
|
||||
+46
-11
@@ -22,6 +22,7 @@ import android.graphics.Canvas
|
||||
import android.graphics.Color
|
||||
import android.graphics.Paint
|
||||
import android.graphics.Path
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import org.osmdroid.views.MapView
|
||||
import org.osmdroid.views.Projection
|
||||
@@ -48,6 +49,15 @@ data class AwardRegion(
|
||||
val labelLon: Double,
|
||||
val labelLat: Double,
|
||||
val rings: List<List<DoubleArray>>,
|
||||
/** Entity call-sign prefix ("BY" for China, "K" for the USA) from the
|
||||
* Club Log cty.csv primary prefix; shown in the QSO detail dialog. */
|
||||
val pfx: String = "",
|
||||
/** Optional fill geometry with separate child entities knocked out as
|
||||
* even-odd holes (e.g. Lesotho inside South Africa, Hong Kong inside
|
||||
* China). Used only for the worked green fill so a worked parent never
|
||||
* greens a different DXCC entity's territory; [rings] stay the stroke /
|
||||
* hit-test source. Empty means "fill = rings". */
|
||||
val fillRings: List<List<DoubleArray>> = emptyList(),
|
||||
val forceLabel: Boolean = false
|
||||
)
|
||||
|
||||
@@ -61,6 +71,20 @@ object AwardBoundaryData {
|
||||
|
||||
private val cache = mutableMapOf<AwardAsset, List<AwardRegion>>()
|
||||
|
||||
private fun parseRings(array: JSONArray): List<List<DoubleArray>> {
|
||||
val rings = mutableListOf<List<DoubleArray>>()
|
||||
for (r in 0 until array.length()) {
|
||||
val ring = array.optJSONArray(r) ?: continue
|
||||
val points = mutableListOf<DoubleArray>()
|
||||
for (p in 0 until ring.length()) {
|
||||
val pt = ring.optJSONArray(p) ?: continue
|
||||
if (pt.length() >= 2) points.add(doubleArrayOf(pt.getDouble(0), pt.getDouble(1)))
|
||||
}
|
||||
if (points.size >= 3) rings.add(points)
|
||||
}
|
||||
return rings
|
||||
}
|
||||
|
||||
enum class AwardAsset(val fileName: String) {
|
||||
WAPC("wapc.json"),
|
||||
WAJA("waja.json"),
|
||||
@@ -80,16 +104,7 @@ object AwardBoundaryData {
|
||||
for (i in 0 until array.length()) {
|
||||
val o = array.optJSONObject(i) ?: continue
|
||||
val ringsArray = o.optJSONArray("rings") ?: continue
|
||||
val rings = mutableListOf<List<DoubleArray>>()
|
||||
for (r in 0 until ringsArray.length()) {
|
||||
val ring = ringsArray.optJSONArray(r) ?: continue
|
||||
val points = mutableListOf<DoubleArray>()
|
||||
for (p in 0 until ring.length()) {
|
||||
val pt = ring.optJSONArray(p) ?: continue
|
||||
if (pt.length() >= 2) points.add(doubleArrayOf(pt.getDouble(0), pt.getDouble(1)))
|
||||
}
|
||||
if (points.size >= 3) rings.add(points)
|
||||
}
|
||||
val rings = parseRings(ringsArray)
|
||||
if (rings.isEmpty()) continue
|
||||
list.add(
|
||||
AwardRegion(
|
||||
@@ -98,7 +113,9 @@ object AwardBoundaryData {
|
||||
nameEn = o.optString("name_en").takeIf { it.isNotEmpty() },
|
||||
labelLon = o.optDouble("label_lon", 0.0),
|
||||
labelLat = o.optDouble("label_lat", 0.0),
|
||||
pfx = o.optString("pfx"),
|
||||
rings = rings,
|
||||
fillRings = o.optJSONArray("fill_rings")?.let { parseRings(it) } ?: emptyList(),
|
||||
forceLabel = o.optBoolean("force_label", false)
|
||||
)
|
||||
)
|
||||
@@ -238,6 +255,14 @@ class AwardBoundaryOverlay : Overlay() {
|
||||
val tiny = max(regionW, regionH) <= AwardHitTest.MIN_SHAPE_PX
|
||||
|
||||
val path = Path()
|
||||
// The worked fill uses fillRings when present: the award asset stores
|
||||
// fill geometry with separate child entities (Lesotho in South Africa,
|
||||
// Hong Kong in China, ...) knocked out as even-odd holes, so working a
|
||||
// parent never greens a different DXCC entity's territory. The stroke
|
||||
// keeps using the plain rings — hole borders coincide with the child
|
||||
// entity's own outline and must not be drawn twice.
|
||||
val fillRings = region.fillRings.ifEmpty { region.rings }
|
||||
val fillPath = if (worked) Path().apply { fillType = Path.FillType.EVEN_ODD } else null
|
||||
var pathHasPoints = false
|
||||
for (ring in region.rings) {
|
||||
// Split each ring into consecutive segments at the antimeridian
|
||||
@@ -262,9 +287,19 @@ class AwardBoundaryOverlay : Overlay() {
|
||||
pathHasPoints = traceSegment(path, segment, projection, centerLon, worldWidthPx, closeRing) || pathHasPoints
|
||||
}
|
||||
}
|
||||
fillPath?.let { fp ->
|
||||
for (ring in fillRings) {
|
||||
val segments = splitRingAtAntimeridian(ring)
|
||||
val closeRing = segments.size == 1
|
||||
for (segment in segments) {
|
||||
if (segment.isEmpty()) continue
|
||||
traceSegment(fp, segment, projection, centerLon, worldWidthPx, closeRing)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!pathHasPoints && !tiny) continue
|
||||
|
||||
if (worked) canvas.drawPath(path, workedPaint)
|
||||
if (worked) fillPath?.let { canvas.drawPath(it, workedPaint) }
|
||||
linePaint.strokeWidth = if (selected) LINE_WIDTH_SELECTED else LINE_WIDTH
|
||||
canvas.drawPath(path, linePaint)
|
||||
linePaint.strokeWidth = LINE_WIDTH
|
||||
|
||||
@@ -28,7 +28,7 @@ import kotlin.math.pow
|
||||
* user's finger in DXCC/WAPC/WAJA/WAZ/WAS view. Pure geometry (no osmdroid
|
||||
* types) so it is unit-testable; the overlay delegates to [hitRegion].
|
||||
*
|
||||
* Two hit shapes, mirroring how the overlay draws:
|
||||
* Three hit shapes, mirroring how the overlay draws:
|
||||
* - a small region (projects to <= [MIN_TAP_TARGET_PX] at the current zoom —
|
||||
* including the dot-rendered ones) is hit by an inflated square target of
|
||||
* [MIN_TAP_TARGET_PX] around its bbox centre, so finger taps land even on
|
||||
@@ -36,6 +36,14 @@ import kotlin.math.pow
|
||||
* - a normal region is hit by an even-odd point-in-polygon test on its rings.
|
||||
* Small targets win over polygon hits (Macao sits inside Guangdong's outline),
|
||||
* and among polygon hits the smallest region wins (Hong Kong over China).
|
||||
*
|
||||
* - a scattered sea archipelago (>= 2 disjoint island rings, every island
|
||||
* small — see [ARCHIPELAGO_MAX_RING_DEG]) is additionally hit anywhere in
|
||||
* its overall envelope, so tapping open sea between the islands lands on
|
||||
* the entity (user request 2026-10-06: 海上的群岛按照整体包络来判断 hit).
|
||||
* Priority: small target > land polygon > archipelago envelope — tapping the
|
||||
* Spanish coast that happens to lie in the Balearic envelope still opens
|
||||
* Spain; only sea inside the envelope belongs to the archipelago.
|
||||
*/
|
||||
object AwardHitTest {
|
||||
|
||||
@@ -49,6 +57,12 @@ object AwardHitTest {
|
||||
* like Hong Kong are hard to hit with a finger at their own size). */
|
||||
const val MIN_TAP_TARGET_PX = 80f
|
||||
|
||||
/** Largest per-island span (deg) for a region to count as a scattered sea
|
||||
* archipelago: every ring must be a small island, never a mainland
|
||||
* (Japan/UK/Indonesia stay polygon-only). Archipelago islands are
|
||||
* finger-proof at low zoom, so their hit region is the overall envelope. */
|
||||
const val ARCHIPELAGO_MAX_RING_DEG = 2.5
|
||||
|
||||
/**
|
||||
* The region under a tap at [tapLat]/[tapLon] on the map at [zoom], or
|
||||
* null when the tap lands on empty space. [bounds] holds each region's
|
||||
@@ -69,6 +83,8 @@ object AwardHitTest {
|
||||
var bestSmallDist = Double.MAX_VALUE
|
||||
var bestPoly: AwardRegion? = null
|
||||
var bestPolyArea = Double.MAX_VALUE
|
||||
var bestArch: AwardRegion? = null
|
||||
var bestArchArea = Double.MAX_VALUE
|
||||
for (i in regions.indices) {
|
||||
val b = bounds.getOrNull(i) ?: continue
|
||||
// Cheap bbox prefilter with the tap-target slack (longitudes wrap).
|
||||
@@ -83,20 +99,93 @@ object AwardHitTest {
|
||||
val dx = dLon / lonDegPerPx
|
||||
val dy = (tapLat - cLat) / latDegPerPx
|
||||
val dist = hypot(dx, dy)
|
||||
val region = regions[i]
|
||||
if (max(w, h) <= MIN_TAP_TARGET_PX) {
|
||||
// Small region: hit by an inflated square target around the
|
||||
// bbox centre — at least MIN_TAP_TARGET_PX on each side — so
|
||||
// finger taps land even on dot-sized entities.
|
||||
if (abs(dx) <= slack && abs(dy) <= slack && dist < bestSmallDist) {
|
||||
bestSmall = regions[i]
|
||||
bestSmall = region
|
||||
bestSmallDist = dist
|
||||
}
|
||||
} else if (w * h < bestPolyArea && contains(regions[i], tapLat, tapLon)) {
|
||||
bestPoly = regions[i]
|
||||
bestPolyArea = w * h
|
||||
} else if (contains(region, tapLat, tapLon)) {
|
||||
// Land polygon hit: the smallest enclosing region wins.
|
||||
if (w * h < bestPolyArea) {
|
||||
bestPoly = region
|
||||
bestPolyArea = w * h
|
||||
}
|
||||
} else if (isArchipelago(region)) {
|
||||
// Scattered sea archipelago: the overall envelope is the hit
|
||||
// region (open sea between the islands belongs to the entity).
|
||||
// The envelope is measured in one unwrapped longitude frame so
|
||||
// antimeridian groups (Fiji-style) keep a contiguous box.
|
||||
val env = envelope(region)
|
||||
val halfW = (env[2] - env[1]) / 2.0
|
||||
val halfH = (env[4] - env[3]) / 2.0
|
||||
val envLon = env[0] + wrap180(tapLon - env[0])
|
||||
val inEnvelope =
|
||||
abs(envLon - (env[1] + env[2]) / 2.0) <= halfW + slack * lonDegPerPx &&
|
||||
abs(tapLat - (env[3] + env[4]) / 2.0) <= halfH + slack * latDegPerPx
|
||||
val area = 4.0 * halfW * halfH
|
||||
if (inEnvelope && area < bestArchArea) {
|
||||
bestArch = region
|
||||
bestArchArea = area
|
||||
}
|
||||
}
|
||||
}
|
||||
return bestSmall ?: bestPoly
|
||||
return bestSmall ?: bestPoly ?: bestArch
|
||||
}
|
||||
|
||||
/**
|
||||
* Scattered sea archipelago: at least two disjoint island rings and every
|
||||
* ring small (<= [ARCHIPELAGO_MAX_RING_DEG] in both lon and lat, measured
|
||||
* in an unwrapped longitude frame) — no dominant mainland. Mainland
|
||||
* countries with stray islands (Japan, UK, Indonesia) fail the size test
|
||||
* and keep plain polygon hits.
|
||||
*/
|
||||
fun isArchipelago(region: AwardRegion): Boolean {
|
||||
if (region.rings.size < 2) return false
|
||||
for (ring in region.rings) {
|
||||
if (ring.isEmpty()) continue
|
||||
val anchor = ring[0][0]
|
||||
var minX = 0.0
|
||||
var maxX = 0.0
|
||||
var minY = ring[0][1]
|
||||
var maxY = ring[0][1]
|
||||
for (p in ring) {
|
||||
val x = wrap180(p[0] - anchor)
|
||||
if (x < minX) minX = x
|
||||
if (x > maxX) maxX = x
|
||||
if (p[1] < minY) minY = p[1]
|
||||
if (p[1] > maxY) maxY = p[1]
|
||||
}
|
||||
if (max(maxX - minX, maxY - minY) > ARCHIPELAGO_MAX_RING_DEG) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Envelope of all rings in one longitude frame anchored at the first ring's
|
||||
* first point: `[anchorLon, minX, maxX, minY, maxY]`. Longitudes past ±180
|
||||
* are unwrapped into the anchor's frame, keeping antimeridian groups whole.
|
||||
*/
|
||||
private fun envelope(region: AwardRegion): DoubleArray {
|
||||
val first = region.rings.firstOrNull { it.isNotEmpty() } ?: return doubleArrayOf(0.0, 0.0, 0.0, 0.0, 0.0)
|
||||
val anchor = first[0][0]
|
||||
var minX = 0.0
|
||||
var maxX = 0.0
|
||||
var minY = first[0][1]
|
||||
var maxY = first[0][1]
|
||||
for (ring in region.rings) {
|
||||
for (p in ring) {
|
||||
val x = wrap180(p[0] - anchor)
|
||||
if (x < minX) minX = x
|
||||
if (x > maxX) maxX = x
|
||||
if (p[1] < minY) minY = p[1]
|
||||
if (p[1] > maxY) maxY = p[1]
|
||||
}
|
||||
}
|
||||
return doubleArrayOf(anchor, anchor + minX, anchor + maxX, minY, maxY)
|
||||
}
|
||||
|
||||
/** Even-odd point-in-polygon across all rings (rings are disjoint parts). */
|
||||
|
||||
@@ -778,13 +778,13 @@ private fun RegionQsoDialog(
|
||||
) {
|
||||
// WAZ regions are named by their bare zone number in the asset, so the
|
||||
// title gets the full "CQ ZONE 24" wording (user request 2026-10-05) and
|
||||
// the code is not repeated in the subtitle. DXCC carries its award prefix
|
||||
// in the subtitle ("DXCC 318", same request); other awards keep the bare
|
||||
// code.
|
||||
// the code is not repeated in the subtitle. DXCC shows the entity's call
|
||||
// prefix instead of the numeric code ("BY", user request 2026-10-06);
|
||||
// other awards keep the bare code.
|
||||
val isZone = type == AwardType.WAZ
|
||||
val codeLabel = when {
|
||||
isZone -> ""
|
||||
type == AwardType.DXCC -> "DXCC ${region.code} · "
|
||||
type == AwardType.DXCC -> "${region.pfx.ifBlank { region.code }} · "
|
||||
else -> "${region.code} · "
|
||||
}
|
||||
androidx.compose.ui.window.Dialog(onDismissRequest = onDismiss) {
|
||||
|
||||
@@ -123,4 +123,83 @@ class AwardHitTestTest {
|
||||
assertEquals("176", hit(regions, -18.0, -175.0, 4.0)?.code)
|
||||
assertNull(hit(regions, -18.0, 165.0, 4.0))
|
||||
}
|
||||
|
||||
/** Two 0.2 x 0.2 deg island boxes [lon] apart, as one archipelago entity. */
|
||||
private fun archipelago(code: String, lon1: Double, lon2: Double, lat: Double = -10.0) =
|
||||
AwardRegion(
|
||||
code = code,
|
||||
name = code,
|
||||
labelLon = (lon1 + lon2) / 2.0,
|
||||
labelLat = lat,
|
||||
rings = listOf(
|
||||
listOf(
|
||||
doubleArrayOf(lon1 - 0.1, lat - 0.1), doubleArrayOf(lon1 + 0.1, lat - 0.1),
|
||||
doubleArrayOf(lon1 + 0.1, lat + 0.1), doubleArrayOf(lon1 - 0.1, lat + 0.1)
|
||||
),
|
||||
listOf(
|
||||
doubleArrayOf(lon2 - 0.1, lat - 0.1), doubleArrayOf(lon2 + 0.1, lat - 0.1),
|
||||
doubleArrayOf(lon2 + 0.1, lat + 0.1), doubleArrayOf(lon2 - 0.1, lat + 0.1)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `sea archipelago is hit by its overall envelope between the islands`() {
|
||||
// Two tiny islands 10 deg apart: at zoom 5 the bbox is ~230 px, so the
|
||||
// per-region inflated target does not apply — the whole envelope does
|
||||
// (user request 2026-10-06: 海上的群岛按照整体包络来判断 hit).
|
||||
val regions = listOf(archipelago("191", -165.0, -155.0))
|
||||
assertEquals("191", hit(regions, -10.0, -160.0, 5.0)?.code) // open sea in the middle
|
||||
assertEquals("191", hit(regions, -10.0, -156.0, 5.0)?.code) // near the east island
|
||||
assertNull(hit(regions, -10.0, -152.0, 5.0)) // ~2.8 deg past the envelope
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `small entity inside the archipelago envelope keeps its own target`() {
|
||||
val regions = listOf(
|
||||
archipelago("191", -165.0, -155.0),
|
||||
box("KS", -161.1, -9.6, -160.9, -9.4) // tiny standalone island in the gap
|
||||
)
|
||||
// Near KS the small target (80 px) wins over the archipelago envelope.
|
||||
assertEquals("KS", hit(regions, -9.5, -160.4, 5.0)?.code)
|
||||
// Away from it the open sea belongs to the archipelago.
|
||||
assertEquals("191", hit(regions, -10.0, -156.0, 5.0)?.code)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `mainland country with a stray island is not envelope hit`() {
|
||||
// Big mainland ring + one small island ring: sea inside the bbox is NOT
|
||||
// a tap target (the group is not a scattered archipelago).
|
||||
val regions = listOf(
|
||||
AwardRegion(
|
||||
code = "JP",
|
||||
name = "JP",
|
||||
labelLon = 133.0,
|
||||
labelLat = 37.0,
|
||||
rings = listOf(
|
||||
listOf(
|
||||
doubleArrayOf(130.0, 31.0), doubleArrayOf(135.0, 31.0),
|
||||
doubleArrayOf(135.0, 42.0), doubleArrayOf(130.0, 42.0)
|
||||
),
|
||||
listOf(
|
||||
doubleArrayOf(138.0, 34.2), doubleArrayOf(138.3, 34.2),
|
||||
doubleArrayOf(138.3, 34.5), doubleArrayOf(138.0, 34.5)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
assertNull(hit(regions, 35.0, 137.0, 5.0)) // sea inside the bbox
|
||||
assertEquals("JP", hit(regions, 37.0, 132.0, 5.0)?.code) // on the mainland
|
||||
assertEquals("JP", hit(regions, 34.35, 138.15, 5.0)?.code) // on the island itself
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `archipelago envelope crossing the antimeridian stays contiguous`() {
|
||||
// Two islands straddling 180: the raw bbox is 357 deg wide, but the
|
||||
// unwrapped envelope is a tight 3 deg box around the date line.
|
||||
val regions = listOf(archipelago("FJ", 178.65, -178.65, lat = -18.0))
|
||||
assertEquals("FJ", hit(regions, -18.0, 180.0, 5.0)?.code) // sea gap at the date line
|
||||
assertEquals("FJ", hit(regions, -18.0, -180.0, 5.0)?.code) // same, other sign
|
||||
assertNull(hit(regions, -18.0, 176.0, 5.0)) // well west of the group
|
||||
}
|
||||
}
|
||||
@@ -18,8 +18,10 @@
|
||||
package com.rtbishop.look4sat.feature.map
|
||||
|
||||
import java.io.File
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
@@ -60,8 +62,10 @@ class DxccAssetTest {
|
||||
first { it.getInt("code") == code }
|
||||
|
||||
/** Even-odd ray casting across all rings of a region (rings are disjoint parts). */
|
||||
private fun regionContains(region: JSONObject, lon: Double, lat: Double): Boolean {
|
||||
val rings = region.getJSONArray("rings")
|
||||
private fun regionContains(region: JSONObject, lon: Double, lat: Double): Boolean =
|
||||
ringsContain(region.getJSONArray("rings"), lon, lat)
|
||||
|
||||
private fun ringsContain(rings: JSONArray, lon: Double, lat: Double): Boolean {
|
||||
var inside = false
|
||||
for (r in 0 until rings.length()) {
|
||||
val ring = rings.getJSONArray(r)
|
||||
@@ -220,4 +224,123 @@ class DxccAssetTest {
|
||||
!regionContains(regions.byCode(c.outside), c.lon, c.lat))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `every entity carries its primary call prefix`() {
|
||||
// pfx = the ARRL DXCC entity prefix (Club Log cty.csv primary prefix),
|
||||
// shown in the QSO detail dialog instead of the numeric code. When an
|
||||
// entity owns several prefixes (China: BA/BG/BY..., USA: K/AA/W...) the
|
||||
// entity prefix is the conventional one. cty rows may list sub-entities
|
||||
// first (Vienna Intl Ctr before Austria under code 206), so the plain
|
||||
// entity row wins.
|
||||
val regions = loadRegions()
|
||||
regions.forEach { region ->
|
||||
assertTrue("${region.getString("name")}: blank pfx",
|
||||
region.getString("pfx").isNotBlank())
|
||||
}
|
||||
val expected = mapOf(
|
||||
318 to "BY", 291 to "K", 339 to "JA", 230 to "DL", 266 to "LA",
|
||||
206 to "OE", 259 to "JW", 232 to "6O", 215 to "5B", 302 to "S0",
|
||||
54 to "UA", 15 to "UA9", 126 to "UA2", 223 to "G"
|
||||
)
|
||||
for ((code, pfx) in expected) {
|
||||
assertEquals("pfx for $code", pfx, regions.byCode(code).getString("pfx"))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `merged map units are framed by their DXCC parent`() {
|
||||
// Natural Earth draws Somaliland and N. Cyprus as separate map units.
|
||||
// Both belong to their DXCC entity (6O Somalia / 5B Cyprus) and are
|
||||
// merged into the parent rings — without the merge the northern
|
||||
// territories had no boundary at all (nothing drawn, never greened).
|
||||
val regions = loadRegions()
|
||||
val checks = listOf(
|
||||
Triple("Hargeisa", 44.06 to 9.56, 232),
|
||||
Triple("Berbera", 45.02 to 10.44, 232),
|
||||
Triple("Garowe", 48.48 to 8.40, 232),
|
||||
Triple("Kyrenia", 33.32 to 35.34, 215),
|
||||
Triple("Nicosia", 33.38 to 35.19, 215)
|
||||
)
|
||||
for ((city, pos, code) in checks) {
|
||||
assertTrue("$city should be inside $code",
|
||||
regionContains(regions.byCode(code), pos.first, pos.second))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Norway keeps only its mainland ring`() {
|
||||
// Norway's Natural Earth geometry carries coarse Svalbard rings (7-17 pts).
|
||||
// Svalbard (259) is a separate DXCC entity with its own detailed rings, so
|
||||
// the coarse copies were deleted — they drew every island twice (a simple
|
||||
// and a coast-fitting outline) and greening Norway also greened JW.
|
||||
val norway = loadRegions().byCode(266)
|
||||
val rings = norway.getJSONArray("rings")
|
||||
assertEquals("Norway ring count", 1, rings.length())
|
||||
for (r in 0 until rings.length()) {
|
||||
val ring = rings.getJSONArray(r)
|
||||
for (p in 0 until ring.length()) {
|
||||
val lat = ring.getJSONArray(p).getDouble(1)
|
||||
assertTrue("Norway vertex at $lat — Svalbard ring leftover", lat <= 73.0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `worked fill knockouts cover the vetted parent child pairs`() {
|
||||
// fill_rings = parent territory minus separate child DXCC entities, so a
|
||||
// worked parent (e.g. South Africa 462) never greens a child entity
|
||||
// (Lesotho 432) that has its own code. Pairs were vetted geometrically
|
||||
// (child area >=50% inside the parent); this test pins them so future
|
||||
// asset edits re-trigger the review.
|
||||
val expected = mapOf(
|
||||
462 to setOf(432), // South Africa - Lesotho
|
||||
318 to setOf(321), // China - Hong Kong
|
||||
299 to setOf(381), // Malaysia - Singapore
|
||||
227 to setOf(203, 260), // France - Andorra, Monaco
|
||||
248 to setOf(278, 295), // Italy - San Marino, Vatican
|
||||
206 to setOf(251), // Austria - Liechtenstein
|
||||
287 to setOf(251), // Switzerland - Liechtenstein
|
||||
281 to setOf(203, 233), // Spain - Andorra, Gibraltar
|
||||
70 to setOf(105) // Cuba - Guantanamo Bay
|
||||
)
|
||||
val regions = loadRegions()
|
||||
val withFill = regions.filter { it.has("fill_rings") }.map { it.getInt("code") }.toSet()
|
||||
assertEquals("regions carrying fill_rings", expected.keys, withFill)
|
||||
for ((parentCode, childCodes) in expected) {
|
||||
val parent = regions.byCode(parentCode)
|
||||
val fillRings = parent.getJSONArray("fill_rings")
|
||||
assertTrue("${parent.getString("name")}: empty fill_rings", fillRings.length() >= 1)
|
||||
// The parent's own anchor must survive the knockout (no over-cutting).
|
||||
val pLon = parent.getDouble("label_lon")
|
||||
val pLat = parent.getDouble("label_lat")
|
||||
assertTrue("${parent.getString("name")}: anchor lost in fill",
|
||||
ringsContain(fillRings, pLon, pLat))
|
||||
for (childCode in childCodes) {
|
||||
val child = regions.byCode(childCode)
|
||||
val childRings = child.getJSONArray("rings")
|
||||
// Probes: the label anchor plus each ring's centroid. Some anchors
|
||||
// drift outside their own ring (Guantanamo Bay), so a probe only
|
||||
// counts when it really sits inside the child's own geometry.
|
||||
val probes = mutableListOf(
|
||||
child.getDouble("label_lon") to child.getDouble("label_lat")
|
||||
)
|
||||
for (r in 0 until childRings.length()) {
|
||||
val ring = childRings.getJSONArray(r)
|
||||
var sx = 0.0; var sy = 0.0
|
||||
for (p in 0 until ring.length()) {
|
||||
sx += ring.getJSONArray(p).getDouble(0)
|
||||
sy += ring.getJSONArray(p).getDouble(1)
|
||||
}
|
||||
probes.add(sx / ring.length() to sy / ring.length())
|
||||
}
|
||||
for ((cLon, cLat) in probes) {
|
||||
if (regionContains(child, cLon, cLat) && regionContains(parent, cLon, cLat)) {
|
||||
assertFalse("${parent.getString("name")} fill still covers ${child.getString("name")}",
|
||||
ringsContain(fillRings, cLon, cLat))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+5
-2
@@ -82,7 +82,10 @@ class MutualViewModel(
|
||||
private val settingsRepo: ISettingsRepo,
|
||||
// Injected so unit tests can run the pass computation on a test dispatcher;
|
||||
// production callers keep the CPU-bound pool default.
|
||||
private val computeDispatcher: CoroutineDispatcher = Dispatchers.Default
|
||||
private val computeDispatcher: CoroutineDispatcher = Dispatchers.Default,
|
||||
// Injected so unit tests can pin the wall clock to the fixture TLE epoch and
|
||||
// stay deterministic; production callers keep the real clock default.
|
||||
private val nowMillis: () -> Long = System::currentTimeMillis
|
||||
) : ViewModel() {
|
||||
|
||||
private val _uiState = MutableStateFlow(MutualUiState())
|
||||
@@ -303,7 +306,7 @@ class MutualViewModel(
|
||||
queryGeneration += 1
|
||||
|
||||
viewModelScope.launch {
|
||||
val now = System.currentTimeMillis()
|
||||
val now = nowMillis()
|
||||
// 转发器类型筛选: 使用硬编码 AMSAT Live 清单 (见 Sources):
|
||||
// FM = {SO-50, ISS ZARYA, AO-123}, Linear = {RS-44, FO-29,
|
||||
// AO-7, AO-73, JO-97}. 不再依赖 AMSAT 网页同步: 任何网络下都
|
||||
|
||||
+4
-2
@@ -53,7 +53,9 @@ class MutualViewModelTest {
|
||||
): MutualViewModel = MutualViewModel(
|
||||
satelliteRepo = FakeSatelliteRepo(satellites, passes),
|
||||
settingsRepo = FakeSettingsRepo(position),
|
||||
computeDispatcher = StandardTestDispatcher(mainDispatcherRule.dispatcher.scheduler)
|
||||
computeDispatcher = StandardTestDispatcher(mainDispatcherRule.dispatcher.scheduler),
|
||||
// Pin the clock to the fixture base so results never depend on wall time.
|
||||
nowMillis = { TestOrbits.FIXTURE_BASE }
|
||||
)
|
||||
|
||||
private fun TestScope.queryAndSettle(vm: MutualViewModel) {
|
||||
@@ -219,7 +221,7 @@ class MutualViewModelTest {
|
||||
assertTrue(state.mutualPasses.all { it.catNum == 25544 })
|
||||
assertTrue(state.mutualPasses.all { it.name == "ISS (ZARYA)" })
|
||||
// AOS/LOS within the searched horizon
|
||||
val now = System.currentTimeMillis()
|
||||
val now = TestOrbits.FIXTURE_BASE
|
||||
assertTrue(state.mutualPasses.all { it.startTime >= now - 120_000L })
|
||||
assertTrue(state.mutualPasses.all { it.endTime <= now + 12 * 3600_000L + 120_000L })
|
||||
}
|
||||
|
||||
@@ -48,6 +48,15 @@ object TestOrbits {
|
||||
)
|
||||
)
|
||||
|
||||
/**
|
||||
* Fixed wall-clock base for every fixture computation: 2024-03-10T00:00Z,
|
||||
* ~18 h after [ISS]'s TLE epoch, so SGP4 propagates an accurate, healthy
|
||||
* orbit. Tests pin the ViewModel clock to this value as well
|
||||
* (MutualViewModel.nowMillis), which makes pass windows deterministic
|
||||
* instead of rotting as the fixture ages against System.currentTimeMillis().
|
||||
*/
|
||||
val FIXTURE_BASE: Long = 1710028800000L
|
||||
|
||||
/** Guangzhou (default BA7OPF grid OL62/OL63 area). */
|
||||
val GUANGZHOU = GeoPos(23.13, 113.26)
|
||||
|
||||
@@ -85,7 +94,7 @@ object TestOrbits {
|
||||
fun findPassWindows(
|
||||
sat: OrbitalObject = ISS,
|
||||
pos: GeoPos = GUANGZHOU,
|
||||
time: Long = System.currentTimeMillis(),
|
||||
time: Long = FIXTURE_BASE,
|
||||
hoursAhead: Int = 12
|
||||
): List<OrbitalPass> {
|
||||
if (!sat.willBeSeen(pos)) return emptyList()
|
||||
|
||||
@@ -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 = "573"
|
||||
appVersionCode = "583"
|
||||
#noinspection UnusedVersionCatalogEntry
|
||||
appVersionName = "4.4.8-ba7opf.10"
|
||||
appVersionName = "4.4.8-ba7opf.11.8"
|
||||
#noinspection UnusedVersionCatalogEntry
|
||||
compileSdk = "37"
|
||||
#noinspection UnusedVersionCatalogEntry
|
||||
|
||||
Reference in new issue
Block a user