mirror of
https://github.com/atsunatsu/Look4Sat.git
synced 2026-10-08 05:27:24 +00:00
Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
623d859c97 | ||
|
|
08803e0f55 | ||
|
|
6f1c0a2d23 | ||
|
|
74e7e19cf2 | ||
|
|
bb001b4d38 | ||
|
|
9ea83cb161 | ||
|
|
d7d5918f0a |
No files matched your search
Binary file not shown.
@@ -36,7 +36,7 @@ import java.util.Locale
|
|||||||
import java.util.zip.GZIPInputStream
|
import java.util.zip.GZIPInputStream
|
||||||
import javax.xml.parsers.DocumentBuilderFactory
|
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) {
|
internal class LoTWConfig(input: InputStream) {
|
||||||
private val root = input.use { source -> GZIPInputStream(source).use { gzip ->
|
private val root = input.use { source -> GZIPInputStream(source).use { gzip ->
|
||||||
val xml = gzip.bufferedReader(Charsets.UTF_8).readText()
|
val xml = gzip.bufferedReader(Charsets.UTF_8).readText()
|
||||||
|
|||||||
@@ -37,7 +37,12 @@ internal class LoTWSigner(private val config: LoTWConfig) {
|
|||||||
val call = record.theirCallsign.trim().uppercase(Locale.US)
|
val call = record.theirCallsign.trim().uppercase(Locale.US)
|
||||||
if (record.status != QsoStatus.COMPLETE || !call.matches(Regex("[A-Z0-9]+(/[A-Z0-9]+)*")) ||
|
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)
|
!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")
|
val date = utc(record.startUtcMillis, "yyyy-MM-dd")
|
||||||
// A record dated after "now" gets its own message: it happens when the operator
|
// 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
|
// logged against a pass that had not started yet (out-of-window flow), and "fix the
|
||||||
|
|||||||
+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 ->
|
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
|
override fun getCurrentTypes() = currentTypes.value
|
||||||
|
|||||||
@@ -87,6 +87,7 @@ class LoTWConfigTest {
|
|||||||
assertEquals("AO-91", config.resolveSatellite("FOX-1B"))
|
assertEquals("AO-91", config.resolveSatellite("FOX-1B"))
|
||||||
assertEquals("IO-86", config.resolveSatellite("LAPAN-A2"))
|
assertEquals("IO-86", config.resolveSatellite("LAPAN-A2"))
|
||||||
assertEquals("RS-44", config.resolveSatellite("DOSAAF-85"))
|
assertEquals("RS-44", config.resolveSatellite("DOSAAF-85"))
|
||||||
|
assertEquals("RS95S", config.resolveSatellite("QMR-KWT 2"))
|
||||||
assertEquals("BO-102", config.resolveSatellite("CAS-7B"))
|
assertEquals("BO-102", config.resolveSatellite("CAS-7B"))
|
||||||
assertEquals("SO-50", config.resolveSatellite("SO-50"))
|
assertEquals("SO-50", config.resolveSatellite("SO-50"))
|
||||||
}
|
}
|
||||||
|
|||||||
+50
@@ -22,6 +22,7 @@ import com.rtbishop.look4sat.core.domain.logbook.QsoStatus
|
|||||||
import com.rtbishop.look4sat.core.domain.repository.LoTWOperationException
|
import com.rtbishop.look4sat.core.domain.repository.LoTWOperationException
|
||||||
import com.rtbishop.look4sat.core.domain.repository.LoTWProblem
|
import com.rtbishop.look4sat.core.domain.repository.LoTWProblem
|
||||||
import com.rtbishop.look4sat.core.domain.repository.LoTWStation
|
import com.rtbishop.look4sat.core.domain.repository.LoTWStation
|
||||||
|
import com.rtbishop.look4sat.core.domain.repository.LoTWUploadResult
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import kotlinx.coroutines.runBlocking
|
import kotlinx.coroutines.runBlocking
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
@@ -120,6 +121,55 @@ class LoTWUploadRepositoryPreviewTest {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 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(
|
private fun sampleRecord(start: Long, call: String, myCallsign: String) = QsoRecord(
|
||||||
id = start,
|
id = start,
|
||||||
startUtcMillis = start,
|
startUtcMillis = start,
|
||||||
|
|||||||
+18
@@ -284,6 +284,24 @@ class SelectionRepoSearchTest {
|
|||||||
assertEquals(sampleItems.map { it.catnum }.toSet(), results.map { it.catnum }.toSet())
|
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(
|
private fun createRepo(
|
||||||
items: List<SatItem>,
|
items: List<SatItem>,
|
||||||
sstvIds: List<Int> = emptyList()
|
sstvIds: List<Int> = emptyList()
|
||||||
|
|||||||
@@ -14,9 +14,15 @@ import java.util.Date
|
|||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
import java.util.TimeZone
|
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 {
|
object AdifCodec {
|
||||||
private val utc = TimeZone.getTimeZone("UTC")
|
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 {
|
fun encode(records: List<QsoRecord>, includeStationCallsign: Boolean = true): String = buildString {
|
||||||
append(field("ADIF_VER", "3.1.7"))
|
append(field("ADIF_VER", "3.1.7"))
|
||||||
@@ -36,7 +42,17 @@ object AdifCodec {
|
|||||||
appendOptional("MODE", record.mode)
|
appendOptional("MODE", record.mode)
|
||||||
appendOptional("SUBMODE", record.submode)
|
appendOptional("SUBMODE", record.submode)
|
||||||
appendOptional("GRIDSQUARE", record.theirGrid)
|
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)
|
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_SENT", record.sentReport)
|
||||||
appendOptional("RST_RCVD", record.receivedReport)
|
appendOptional("RST_RCVD", record.receivedReport)
|
||||||
record.txFrequencyHz?.let { append(field("FREQ", hzToMhz(it))) }
|
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.lotwConfirmed) append(field("LOTW_QSL_RCVD", "Y"))
|
||||||
if (record.lotwReceived) append(field("LOTW_QSL_SENT", "Y"))
|
if (record.lotwReceived) append(field("LOTW_QSL_SENT", "Y"))
|
||||||
appendOptional("LOTW_QSLRDATE", record.lotwQslDate)
|
appendOptional("LOTW_QSLRDATE", record.lotwQslDate)
|
||||||
appendOptional("VUCC_GRIDS", record.vuccGrids.joinToString(","))
|
|
||||||
record.dxcc?.let { append(field("DXCC", it.toString())) }
|
record.dxcc?.let { append(field("DXCC", it.toString())) }
|
||||||
appendOptional("COUNTRY", record.country)
|
appendOptional("COUNTRY", record.country)
|
||||||
record.cqZone?.let { append(field("CQZ", it.toString())) }
|
record.cqZone?.let { append(field("CQZ", it.toString())) }
|
||||||
@@ -80,7 +95,9 @@ object AdifCodec {
|
|||||||
theirCallsign = call,
|
theirCallsign = call,
|
||||||
myCallsign = values["STATION_CALLSIGN"].orEmpty().uppercase(Locale.US),
|
myCallsign = values["STATION_CALLSIGN"].orEmpty().uppercase(Locale.US),
|
||||||
theirGrid = values["GRIDSQUARE"].orEmpty().uppercase(Locale.US),
|
theirGrid = values["GRIDSQUARE"].orEmpty().uppercase(Locale.US),
|
||||||
|
theirVuccGrids = decodeGrids(values["VUCC_GRIDS"]),
|
||||||
myGrid = values["MY_GRIDSQUARE"].orEmpty().uppercase(Locale.US),
|
myGrid = values["MY_GRIDSQUARE"].orEmpty().uppercase(Locale.US),
|
||||||
|
vuccGrids = decodeGrids(values["MY_VUCC_GRIDS"]),
|
||||||
sentReport = values["RST_SENT"].orEmpty(),
|
sentReport = values["RST_SENT"].orEmpty(),
|
||||||
receivedReport = values["RST_RCVD"].orEmpty(),
|
receivedReport = values["RST_RCVD"].orEmpty(),
|
||||||
txFrequencyHz = mhzToHz(values["FREQ"]),
|
txFrequencyHz = mhzToHz(values["FREQ"]),
|
||||||
@@ -100,8 +117,6 @@ object AdifCodec {
|
|||||||
lotwConfirmed = values["LOTW_QSL_RCVD"].equals("Y", true),
|
lotwConfirmed = values["LOTW_QSL_RCVD"].equals("Y", true),
|
||||||
lotwReceived = values["LOTW_QSL_SENT"].equals("Y", true),
|
lotwReceived = values["LOTW_QSL_SENT"].equals("Y", true),
|
||||||
lotwQslDate = values["LOTW_QSLRDATE"].orEmpty(),
|
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(),
|
dxcc = values["DXCC"]?.toIntOrNull(),
|
||||||
country = values["COUNTRY"].orEmpty(),
|
country = values["COUNTRY"].orEmpty(),
|
||||||
cqZone = values["CQZ"]?.toIntOrNull()?.takeIf { it in 1..40 },
|
cqZone = values["CQZ"]?.toIntOrNull()?.takeIf { it in 1..40 },
|
||||||
@@ -140,6 +155,12 @@ object AdifCodec {
|
|||||||
return records
|
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) {
|
private fun StringBuilder.appendOptional(name: String, value: String) {
|
||||||
if (value.isNotBlank()) append(field(name, value))
|
if (value.isNotBlank()) append(field(name, value))
|
||||||
}
|
}
|
||||||
|
|||||||
+5
-3
@@ -34,6 +34,8 @@ import java.util.Locale
|
|||||||
* - live SatNOGS 3LE names and Celestrak amateur names joined to the same NORAD id,
|
* - 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),
|
* - 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".
|
* - 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
|
* 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
|
* between objects and would sign the wrong satellite. LoTWConfigTest asserts that every target
|
||||||
* here is a real config.tq6 name.
|
* 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",
|
"OSCAR16PACSAT" to "AO-16", "OSCAR19LUSAT" to "LO-19", "OSCAR64" to "DO-64",
|
||||||
"OSCAR7" to "AO-7", "PACSAT" to "AO-16", "PARKINSONSAT" to "NO-84",
|
"OSCAR7" to "AO-7", "PACSAT" to "AO-16", "PARKINSONSAT" to "NO-84",
|
||||||
"PCSAT" to "NO-44", "PHILLIPINESOSCAR101" to "PO-101", "PO101" to "PO-101",
|
"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",
|
"QO100" to "QO-100", "RADFXSAT" to "AO-91", "RADIOROSTO" to "RS-15",
|
||||||
"RS0ISS" to "ARISS", "RS14" to "AO-21", "RS15" 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",
|
"RS44" to "RS-44", "RS64S" to "AO-123", "RS95S" to "RS95S",
|
||||||
"SO121" to "SO-121", "SO124" to "SO-124", "SO125" to "SO-125",
|
"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",
|
"SO50" to "SO-50", "SOLUTUSNANOSATTELITE" to "SONATE", "SONATE2" to "SONATE",
|
||||||
"TAURUS1" to "TAURUS", "TEVEL21" to "TEV2-1", "TEVEL22" to "TEV2-2",
|
"TAURUS1" to "TAURUS", "TEVEL21" to "TEV2-1", "TEVEL22" to "TEV2-2",
|
||||||
"TEVEL23" to "TEV2-3", "TEVEL24" to "TEV2-4", "TEVEL25" to "TEV2-5",
|
"TEVEL23" to "TEV2-3", "TEVEL24" to "TEV2-4", "TEVEL25" to "TEV2-5",
|
||||||
|
|||||||
+12
@@ -18,6 +18,7 @@
|
|||||||
package com.rtbishop.look4sat.core.domain.logbook
|
package com.rtbishop.look4sat.core.domain.logbook
|
||||||
|
|
||||||
import com.rtbishop.look4sat.core.domain.repository.LoTWProblem
|
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.
|
* 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" }
|
val detail = parts.joinToString(", ").ifBlank { "check the logbook" }
|
||||||
return "$subject — $detail"
|
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 wavelogStation: String = "",
|
||||||
val lotwReceived: Boolean = false,
|
val lotwReceived: Boolean = false,
|
||||||
val lotwQslDate: String = "",
|
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 vuccGrids: List<String> = emptyList(),
|
||||||
val dxcc: Int? = null,
|
val dxcc: Int? = null,
|
||||||
val country: String = "",
|
val country: String = "",
|
||||||
|
|||||||
+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. */
|
/** Alias table that maps tracker catalogue names onto the names ARRL's config.tq6 uses. */
|
||||||
class LoTWSatelliteAliasesTest {
|
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
|
@Test
|
||||||
fun `lookup maps SatNOGS catalogue names to ARRL names`() {
|
fun `lookup maps SatNOGS catalogue names to ARRL names`() {
|
||||||
@@ -37,6 +37,7 @@ class LoTWSatelliteAliasesTest {
|
|||||||
assertEquals("AO-91", LoTWSatelliteAliases.lookup("FOX-1B"))
|
assertEquals("AO-91", LoTWSatelliteAliases.lookup("FOX-1B"))
|
||||||
assertEquals("RS-44", LoTWSatelliteAliases.lookup("DOSAAF-85"))
|
assertEquals("RS-44", LoTWSatelliteAliases.lookup("DOSAAF-85"))
|
||||||
assertEquals("IO-117", LoTWSatelliteAliases.lookup("GREENCUBE"))
|
assertEquals("IO-117", LoTWSatelliteAliases.lookup("GREENCUBE"))
|
||||||
|
assertEquals("RS95S", LoTWSatelliteAliases.lookup("QMR-KWT 2"))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -59,6 +60,8 @@ class LoTWSatelliteAliasesTest {
|
|||||||
assertEquals("SO-50", LoTWSatelliteAliases.resolve("SAUDISAT 1C (SO-50)", catalogue))
|
assertEquals("SO-50", LoTWSatelliteAliases.resolve("SAUDISAT 1C (SO-50)", catalogue))
|
||||||
assertEquals("ARISS", LoTWSatelliteAliases.resolve("ISS (ZARYA)", catalogue))
|
assertEquals("ARISS", LoTWSatelliteAliases.resolve("ISS (ZARYA)", catalogue))
|
||||||
assertEquals("AO-91", LoTWSatelliteAliases.resolve("AO-91 (RadFxSat)", 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
|
@Test
|
||||||
|
|||||||
+72
-8
@@ -10,6 +10,7 @@
|
|||||||
package com.rtbishop.look4sat.core.domain.logbook
|
package com.rtbishop.look4sat.core.domain.logbook
|
||||||
|
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
import org.junit.Assert.assertTrue
|
import org.junit.Assert.assertTrue
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
|
||||||
@@ -22,8 +23,10 @@ class AdifCodecTest {
|
|||||||
endUtcMillis = 1_700_000_000_100L,
|
endUtcMillis = 1_700_000_000_100L,
|
||||||
theirCallsign = "BG5JVM",
|
theirCallsign = "BG5JVM",
|
||||||
myCallsign = "BA7OPF",
|
myCallsign = "BA7OPF",
|
||||||
theirGrid = "OM60",
|
theirGrid = "EN52",
|
||||||
|
theirVuccGrids = listOf("EN52", "EN53"),
|
||||||
myGrid = "OL62TI",
|
myGrid = "OL62TI",
|
||||||
|
vuccGrids = listOf("OL62", "OL61"),
|
||||||
sentReport = "59",
|
sentReport = "59",
|
||||||
receivedReport = "59",
|
receivedReport = "59",
|
||||||
txFrequencyHz = 145_850_000L,
|
txFrequencyHz = 145_850_000L,
|
||||||
@@ -36,8 +39,7 @@ class AdifCodecTest {
|
|||||||
propagationMode = "SAT",
|
propagationMode = "SAT",
|
||||||
status = QsoStatus.COMPLETE,
|
status = QsoStatus.COMPLETE,
|
||||||
lotwConfirmed = true,
|
lotwConfirmed = true,
|
||||||
lotwQslDate = "2026-09-20",
|
lotwQslDate = "2026-09-20"
|
||||||
vuccGrids = listOf("OM60", "OM50")
|
|
||||||
)
|
)
|
||||||
val decoded = AdifCodec.decode(AdifCodec.encode(listOf(record))).single()
|
val decoded = AdifCodec.decode(AdifCodec.encode(listOf(record))).single()
|
||||||
assertEquals(record.startUtcMillis, decoded.startUtcMillis)
|
assertEquals(record.startUtcMillis, decoded.startUtcMillis)
|
||||||
@@ -47,12 +49,18 @@ class AdifCodecTest {
|
|||||||
assertEquals("2M", decoded.band)
|
assertEquals("2M", decoded.band)
|
||||||
assertEquals("70CM", decoded.rxBand)
|
assertEquals("70CM", decoded.rxBand)
|
||||||
assertTrue(decoded.lotwConfirmed)
|
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
|
@Test
|
||||||
fun decode_lotwStyleReportFields() {
|
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 = """
|
val adi = """
|
||||||
<EOH>
|
<EOH>
|
||||||
<CALL:6>BH6RJD
|
<CALL:6>BH6RJD
|
||||||
@@ -64,8 +72,9 @@ class AdifCodecTest {
|
|||||||
<BAND:3>2M
|
<BAND:3>2M
|
||||||
<BAND_RX:4>70CM
|
<BAND_RX:4>70CM
|
||||||
<GRIDSQUARE:4>OM60
|
<GRIDSQUARE:4>OM60
|
||||||
<MY_GRIDSQUARE:8>OM60IL70
|
|
||||||
<VUCC_GRIDS:9>OM60,OM50
|
<VUCC_GRIDS:9>OM60,OM50
|
||||||
|
<MY_GRIDSQUARE:6>OL62TI
|
||||||
|
<MY_VUCC_GRIDS:9>OL62,OL61
|
||||||
<STATION_CALLSIGN:6>BH6RJD
|
<STATION_CALLSIGN:6>BH6RJD
|
||||||
<DXCC:3>318
|
<DXCC:3>318
|
||||||
<COUNTRY:5>China
|
<COUNTRY:5>China
|
||||||
@@ -78,12 +87,27 @@ class AdifCodecTest {
|
|||||||
assertEquals("BH6RJD", record.theirCallsign)
|
assertEquals("BH6RJD", record.theirCallsign)
|
||||||
assertEquals("SO-50", record.satelliteName)
|
assertEquals("SO-50", record.satelliteName)
|
||||||
assertEquals("FM", record.displayMode)
|
assertEquals("FM", record.displayMode)
|
||||||
assertEquals("OM60IL70", record.myGrid)
|
assertEquals("OM60", record.theirGrid)
|
||||||
assertEquals(listOf("OM60", "OM50"), record.vuccGrids)
|
assertEquals(listOf("OM60", "OM50"), record.theirVuccGrids)
|
||||||
|
assertEquals("OL62TI", record.myGrid)
|
||||||
|
assertEquals(listOf("OL62", "OL61"), record.vuccGrids)
|
||||||
assertTrue(record.lotwConfirmed)
|
assertTrue(record.lotwConfirmed)
|
||||||
assertTrue(record.isSatellite)
|
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
|
@Test
|
||||||
fun encodeOmitsStationCallsignForWavelog() {
|
fun encodeOmitsStationCallsignForWavelog() {
|
||||||
val record = QsoRecord(
|
val record = QsoRecord(
|
||||||
@@ -99,4 +123,44 @@ class AdifCodecTest {
|
|||||||
assertTrue(adif.contains("<CALL:6>N0CALL"))
|
assertTrue(adif.contains("<CALL:6>N0CALL"))
|
||||||
assertTrue(adif.contains("<SAT_NAME:5>SO-50"))
|
assertTrue(adif.contains("<SAT_NAME:5>SO-50"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun encodeKeepsOwnGridsOutOfTheOppositeVuccField() {
|
||||||
|
// The reported defect: an operator's stamped own grid set must never land in
|
||||||
|
// VUCC_GRIDS — Wavelog displays that field as the OPPOSITE station's grid.
|
||||||
|
val record = QsoRecord(
|
||||||
|
startUtcMillis = 1_700_000_000_000L,
|
||||||
|
theirCallsign = "N0CALL",
|
||||||
|
myCallsign = "BA7OPF",
|
||||||
|
myGrid = "OL62TI",
|
||||||
|
vuccGrids = listOf("OL62"),
|
||||||
|
satelliteName = "SO-50"
|
||||||
|
)
|
||||||
|
val adif = AdifCodec.encode(listOf(record), includeStationCallsign = false)
|
||||||
|
assertTrue(adif.contains("<MY_VUCC_GRIDS:4>OL62"))
|
||||||
|
assertFalse(adif.contains("<VUCC_GRIDS:"))
|
||||||
|
assertFalse(adif.contains("<GRIDSQUARE:"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun encodeWritesTheOppositeMultiGridSetToVuccGrids() {
|
||||||
|
val multi = QsoRecord(
|
||||||
|
startUtcMillis = 1_700_000_000_000L,
|
||||||
|
theirCallsign = "N0CALL",
|
||||||
|
myCallsign = "BA7OPF",
|
||||||
|
theirGrid = "EN52",
|
||||||
|
theirVuccGrids = listOf("EN52", "EN53"),
|
||||||
|
satelliteName = "SO-50"
|
||||||
|
)
|
||||||
|
val multiAdif = AdifCodec.encode(listOf(multi), false)
|
||||||
|
assertTrue(multiAdif.contains("<GRIDSQUARE:4>EN52"))
|
||||||
|
assertTrue(multiAdif.contains("<VUCC_GRIDS:9>EN52,EN53"))
|
||||||
|
|
||||||
|
// A single opposite grid rides in GRIDSQUARE only; one value inside the VUCC
|
||||||
|
// field would trigger Wavelog's single-grid warning in its QSO view.
|
||||||
|
val single = multi.copy(theirVuccGrids = listOf("EN52"))
|
||||||
|
val singleAdif = AdifCodec.encode(listOf(single), false)
|
||||||
|
assertTrue(singleAdif.contains("<GRIDSQUARE:4>EN52"))
|
||||||
|
assertFalse(singleAdif.contains("<VUCC_GRIDS:"))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+59
@@ -0,0 +1,59 @@
|
|||||||
|
/*
|
||||||
|
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||||
|
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
package com.rtbishop.look4sat.core.domain.logbook
|
||||||
|
|
||||||
|
import com.rtbishop.look4sat.core.domain.repository.LoTWProblem
|
||||||
|
import com.rtbishop.look4sat.core.domain.repository.LoTWUploadAudit
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The preview dialog is the only place carrying the callsign-conflict actions (rewrite / use another
|
||||||
|
* certificate), so a conflict-only batch must NOT be short-circuited into the plain
|
||||||
|
* "can't be uploaded" message: that dialog has no buttons to get out of.
|
||||||
|
*/
|
||||||
|
class LoTWUploadMessagesTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `conflict-only batch asks for the preview even with nothing pending`() {
|
||||||
|
assertTrue(audit(pending = 0, mismatch = 107, satellite = 1).needsPreviewForConflicts())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `blocked batch without conflicts keeps the plain message`() {
|
||||||
|
assertFalse(audit(pending = 0, mismatch = 0, satellite = 3).needsPreviewForConflicts())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `batch with something to upload never takes the conflict fallback`() {
|
||||||
|
assertFalse(audit(pending = 5, mismatch = 2, satellite = 1).needsPreviewForConflicts())
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun audit(pending: Int, mismatch: Int, satellite: Int) = LoTWUploadAudit(
|
||||||
|
total = 108,
|
||||||
|
pending = pending,
|
||||||
|
uploaded = 0,
|
||||||
|
unknown = 0,
|
||||||
|
unavailable = 108 - pending,
|
||||||
|
reasons = buildMap {
|
||||||
|
if (mismatch > 0) put(LoTWProblem.CALLSIGN_MISMATCH, mismatch)
|
||||||
|
if (satellite > 0) put(LoTWProblem.SATELLITE, satellite)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -19,6 +19,10 @@
|
|||||||
<string name="sat_search_clear">Vaciar</string>
|
<string name="sat_search_clear">Vaciar</string>
|
||||||
<string name="sat_clear_all">Vaciar todo</string>
|
<string name="sat_clear_all">Vaciar todo</string>
|
||||||
<string name="sat_select_all">Seleccionar 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">
|
<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>
|
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>
|
<string name="sat_warning_title">Alarma\!</string>
|
||||||
|
|||||||
@@ -19,6 +19,10 @@
|
|||||||
<string name="sat_search_clear">Очистить</string>
|
<string name="sat_search_clear">Очистить</string>
|
||||||
<string name="sat_clear_all">Очистить</string>
|
<string name="sat_clear_all">Очистить</string>
|
||||||
<string name="sat_select_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_empty_list_message">Убедитесь, что ваш поисковый запрос верен и база данных обновлена</string>
|
||||||
<string name="sat_warning_title">Внимание\!</string>
|
<string name="sat_warning_title">Внимание\!</string>
|
||||||
<string name="sat_warning_message">
|
<string name="sat_warning_message">
|
||||||
|
|||||||
@@ -19,6 +19,10 @@
|
|||||||
<string name="sat_search_clear">පිරිසිදු ක°</string>
|
<string name="sat_search_clear">පිරිසිදු ක°</string>
|
||||||
<string name="sat_clear_all">සියල්ල පිරිසිදු ක°</string>
|
<string name="sat_clear_all">සියල්ල පිරිසිදු ක°</string>
|
||||||
<string name="sat_select_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 name="sat_empty_list_message">
|
||||||
ඔබගේ සෙවුම් විමසුම නිවැරදි බවත් දත්ත සමුදාය යාවත්කාලීන කර ඇති බවත් සහතික කර ගන්න</string>
|
ඔබගේ සෙවුම් විමසුම නිවැරදි බවත් දත්ත සමුදාය යාවත්කාලීන කර ඇති බවත් සහතික කර ගන්න</string>
|
||||||
<string name="sat_warning_title">අවවාදයයි\!</string>
|
<string name="sat_warning_title">අවවාදයයි\!</string>
|
||||||
|
|||||||
@@ -20,6 +20,10 @@
|
|||||||
<string name="sat_search_clear">Temizle</string>
|
<string name="sat_search_clear">Temizle</string>
|
||||||
<string name="sat_clear_all">Tümünü 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_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_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_title">Uyarı\!</string>
|
||||||
<string name="sat_warning_message">
|
<string name="sat_warning_message">
|
||||||
|
|||||||
@@ -19,6 +19,10 @@
|
|||||||
<string name="sat_search_clear">Очистити</string>
|
<string name="sat_search_clear">Очистити</string>
|
||||||
<string name="sat_clear_all">Очистити всі</string>
|
<string name="sat_clear_all">Очистити всі</string>
|
||||||
<string name="sat_select_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 name="sat_empty_list_message">
|
||||||
Переконайтеся, що ваш пошуковий запит правильний, а база даних оновлена</string>
|
Переконайтеся, що ваш пошуковий запит правильний, а база даних оновлена</string>
|
||||||
<string name="sat_warning_title">Увага\!</string>
|
<string name="sat_warning_title">Увага\!</string>
|
||||||
|
|||||||
@@ -20,6 +20,10 @@
|
|||||||
<string name="sat_search_clear">清空</string>
|
<string name="sat_search_clear">清空</string>
|
||||||
<string name="sat_clear_all">全部清空</string>
|
<string name="sat_clear_all">全部清空</string>
|
||||||
<string name="sat_select_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_empty_list_message">请确保您的搜索查询正确且数据库已更新</string>
|
||||||
<string name="sat_warning_title">警告\!</string>
|
<string name="sat_warning_title">警告\!</string>
|
||||||
<string name="sat_warning_message">此应用收录了超过9000颗卫星信息\n同时追踪所有卫星并不现实\n建议始终通过搜索和分类筛选功能仅勾选您感兴趣的卫星</string>
|
<string name="sat_warning_message">此应用收录了超过9000颗卫星信息\n同时追踪所有卫星并不现实\n建议始终通过搜索和分类筛选功能仅勾选您感兴趣的卫星</string>
|
||||||
|
|||||||
@@ -21,6 +21,10 @@
|
|||||||
<string name="sat_search_clear">Clear</string>
|
<string name="sat_search_clear">Clear</string>
|
||||||
<string name="sat_clear_all">Clear all</string>
|
<string name="sat_clear_all">Clear all</string>
|
||||||
<string name="sat_select_all">Select 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_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_title">Warning\!</string>
|
||||||
<string name="sat_warning_message">
|
<string name="sat_warning_message">
|
||||||
|
|||||||
+5
-2
@@ -82,7 +82,10 @@ class MutualViewModel(
|
|||||||
private val settingsRepo: ISettingsRepo,
|
private val settingsRepo: ISettingsRepo,
|
||||||
// Injected so unit tests can run the pass computation on a test dispatcher;
|
// Injected so unit tests can run the pass computation on a test dispatcher;
|
||||||
// production callers keep the CPU-bound pool default.
|
// 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() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val _uiState = MutableStateFlow(MutualUiState())
|
private val _uiState = MutableStateFlow(MutualUiState())
|
||||||
@@ -303,7 +306,7 @@ class MutualViewModel(
|
|||||||
queryGeneration += 1
|
queryGeneration += 1
|
||||||
|
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
val now = System.currentTimeMillis()
|
val now = nowMillis()
|
||||||
// 转发器类型筛选: 使用硬编码 AMSAT Live 清单 (见 Sources):
|
// 转发器类型筛选: 使用硬编码 AMSAT Live 清单 (见 Sources):
|
||||||
// FM = {SO-50, ISS ZARYA, AO-123}, Linear = {RS-44, FO-29,
|
// FM = {SO-50, ISS ZARYA, AO-123}, Linear = {RS-44, FO-29,
|
||||||
// AO-7, AO-73, JO-97}. 不再依赖 AMSAT 网页同步: 任何网络下都
|
// AO-7, AO-73, JO-97}. 不再依赖 AMSAT 网页同步: 任何网络下都
|
||||||
|
|||||||
+4
-2
@@ -53,7 +53,9 @@ class MutualViewModelTest {
|
|||||||
): MutualViewModel = MutualViewModel(
|
): MutualViewModel = MutualViewModel(
|
||||||
satelliteRepo = FakeSatelliteRepo(satellites, passes),
|
satelliteRepo = FakeSatelliteRepo(satellites, passes),
|
||||||
settingsRepo = FakeSettingsRepo(position),
|
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) {
|
private fun TestScope.queryAndSettle(vm: MutualViewModel) {
|
||||||
@@ -219,7 +221,7 @@ class MutualViewModelTest {
|
|||||||
assertTrue(state.mutualPasses.all { it.catNum == 25544 })
|
assertTrue(state.mutualPasses.all { it.catNum == 25544 })
|
||||||
assertTrue(state.mutualPasses.all { it.name == "ISS (ZARYA)" })
|
assertTrue(state.mutualPasses.all { it.name == "ISS (ZARYA)" })
|
||||||
// AOS/LOS within the searched horizon
|
// 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.startTime >= now - 120_000L })
|
||||||
assertTrue(state.mutualPasses.all { it.endTime <= now + 12 * 3600_000L + 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). */
|
/** Guangzhou (default BA7OPF grid OL62/OL63 area). */
|
||||||
val GUANGZHOU = GeoPos(23.13, 113.26)
|
val GUANGZHOU = GeoPos(23.13, 113.26)
|
||||||
|
|
||||||
@@ -85,7 +94,7 @@ object TestOrbits {
|
|||||||
fun findPassWindows(
|
fun findPassWindows(
|
||||||
sat: OrbitalObject = ISS,
|
sat: OrbitalObject = ISS,
|
||||||
pos: GeoPos = GUANGZHOU,
|
pos: GeoPos = GUANGZHOU,
|
||||||
time: Long = System.currentTimeMillis(),
|
time: Long = FIXTURE_BASE,
|
||||||
hoursAhead: Int = 12
|
hoursAhead: Int = 12
|
||||||
): List<OrbitalPass> {
|
): List<OrbitalPass> {
|
||||||
if (!sat.willBeSeen(pos)) return emptyList()
|
if (!sat.willBeSeen(pos)) return emptyList()
|
||||||
|
|||||||
@@ -431,7 +431,10 @@ private fun UploadPreviewDialog(
|
|||||||
text = {
|
text = {
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||||
Text("${preview.callsign} DXCC ${preview.dxcc} Grid ${preview.grid}", fontSize = 13.sp)
|
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 {
|
java.text.SimpleDateFormat("MM-dd HH:mm", java.util.Locale.US).apply {
|
||||||
timeZone = java.util.TimeZone.getTimeZone("UTC")
|
timeZone = java.util.TimeZone.getTimeZone("UTC")
|
||||||
}.format(java.util.Date(preview.firstUtc.let {
|
}.format(java.util.Date(preview.firstUtc.let {
|
||||||
@@ -447,7 +450,8 @@ private fun UploadPreviewDialog(
|
|||||||
timeZone = java.util.TimeZone.getTimeZone("UTC")
|
timeZone = java.util.TimeZone.getTimeZone("UTC")
|
||||||
}.parse(it).time }.getOrDefault(System.currentTimeMillis())
|
}.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) {
|
if (preview.skipped > 0 || preview.unknownSkipped > 0 || preview.unavailableSkipped > 0) {
|
||||||
val parts = buildList {
|
val parts = buildList {
|
||||||
// skipped counts every record left out of this batch: previously-uploaded
|
// skipped counts every record left out of this batch: previously-uploaded
|
||||||
@@ -498,7 +502,8 @@ private fun UploadPreviewDialog(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
confirmButton = {
|
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 = {
|
dismissButton = {
|
||||||
TextButton(onClick = onDismiss) { Text("Cancel") }
|
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.QsoStatus
|
||||||
import com.rtbishop.look4sat.core.domain.logbook.displayMode
|
import com.rtbishop.look4sat.core.domain.logbook.displayMode
|
||||||
import com.rtbishop.look4sat.core.domain.logbook.frequencyBand
|
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.officialSatelliteName
|
||||||
import com.rtbishop.look4sat.core.domain.logbook.satelliteIdentity
|
import com.rtbishop.look4sat.core.domain.logbook.satelliteIdentity
|
||||||
import com.rtbishop.look4sat.core.domain.logbook.unavailableUploadSummary
|
import com.rtbishop.look4sat.core.domain.logbook.unavailableUploadSummary
|
||||||
@@ -286,7 +287,10 @@ class LogViewModel(
|
|||||||
// LoTW-imported confirmations are the feedback side.
|
// LoTW-imported confirmations are the feedback side.
|
||||||
val pending = all.filter { !it.lotwConfirmed && !it.lotwUploaded && it.status == QsoStatus.COMPLETE }
|
val pending = all.filter { !it.lotwConfirmed && !it.lotwUploaded && it.status == QsoStatus.COMPLETE }
|
||||||
val audit = lotwUploadRepository.audit(pending)
|
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 {
|
val msg = when {
|
||||||
audit.unavailable > 0 -> unavailableUploadSummary(
|
audit.unavailable > 0 -> unavailableUploadSummary(
|
||||||
audit.unavailable, audit.reasons, audit.details, audit.duplicates, audit.incomplete
|
audit.unavailable, audit.reasons, audit.details, audit.duplicates, audit.incomplete
|
||||||
|
|||||||
+45
-2
@@ -326,9 +326,52 @@ private fun SatellitesPreview() {
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun SatellitesCard(items: List<SatItem>, onSelected: (Int, Boolean) -> Unit) {
|
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)) {
|
LazyVerticalGrid(columns = GridCells.Adaptive(320.dp)) {
|
||||||
items(items = items, key = { item -> item.catnum }) { entry ->
|
stickyHeader(key = "sat_summary_header") {
|
||||||
Satellite(entry, onSelected, Modifier.animateItem())
|
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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
+6
-2
@@ -299,7 +299,9 @@ private fun LogbookUploadPreviewDialog(
|
|||||||
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||||
Text("${preview.callsign} DXCC ${preview.dxcc} Grid ${preview.grid}", fontSize = 13.sp)
|
Text("${preview.callsign} DXCC ${preview.dxcc} Grid ${preview.grid}", fontSize = 13.sp)
|
||||||
Text(
|
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
|
fontSize = 13.sp
|
||||||
)
|
)
|
||||||
if (preview.skipped > 0 || preview.unknownSkipped > 0 || preview.unavailableSkipped > 0) {
|
if (preview.skipped > 0 || preview.unknownSkipped > 0 || preview.unavailableSkipped > 0) {
|
||||||
@@ -357,7 +359,9 @@ private fun LogbookUploadPreviewDialog(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
confirmButton = {
|
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))
|
Text(stringResource(R.string.prefs_logbook_upload_confirm))
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
+5
-1
@@ -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.repository.WavelogUploadPreview
|
||||||
import com.rtbishop.look4sat.core.domain.usecase.IShowToast
|
import com.rtbishop.look4sat.core.domain.usecase.IShowToast
|
||||||
import com.rtbishop.look4sat.core.domain.utility.VersionComparator
|
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.resubmitCandidates
|
||||||
import com.rtbishop.look4sat.core.domain.logbook.toConfirmedRecord
|
import com.rtbishop.look4sat.core.domain.logbook.toConfirmedRecord
|
||||||
import com.rtbishop.look4sat.core.domain.logbook.unavailableUploadSummary
|
import com.rtbishop.look4sat.core.domain.logbook.unavailableUploadSummary
|
||||||
@@ -576,7 +577,10 @@ class SettingsViewModel(
|
|||||||
// LoTW-imported confirmations are the feedback side.
|
// 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 pending = all.filter { !it.lotwConfirmed && !it.lotwUploaded && it.status == com.rtbishop.look4sat.core.domain.logbook.QsoStatus.COMPLETE }
|
||||||
val audit = lotwUploadRepository.audit(pending)
|
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 {
|
val msg = when {
|
||||||
audit.unavailable > 0 -> com.rtbishop.look4sat.core.domain.logbook.unavailableUploadSummary(
|
audit.unavailable > 0 -> com.rtbishop.look4sat.core.domain.logbook.unavailableUploadSummary(
|
||||||
audit.unavailable, audit.reasons, audit.details, audit.duplicates, audit.incomplete
|
audit.unavailable, audit.reasons, audit.details, audit.duplicates, audit.incomplete
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
[versions]
|
[versions]
|
||||||
#noinspection UnusedVersionCatalogEntry
|
#noinspection UnusedVersionCatalogEntry
|
||||||
appVersionCode = "578"
|
appVersionCode = "580"
|
||||||
#noinspection UnusedVersionCatalogEntry
|
#noinspection UnusedVersionCatalogEntry
|
||||||
appVersionName = "4.4.8-ba7opf.11.3"
|
appVersionName = "4.4.8-ba7opf.11.5"
|
||||||
#noinspection UnusedVersionCatalogEntry
|
#noinspection UnusedVersionCatalogEntry
|
||||||
compileSdk = "37"
|
compileSdk = "37"
|
||||||
#noinspection UnusedVersionCatalogEntry
|
#noinspection UnusedVersionCatalogEntry
|
||||||
|
|||||||
Reference in new issue
Block a user