mirror of
https://github.com/atsunatsu/Look4Sat.git
synced 2026-10-05 03:57:24 +00:00
feat(logbook): 窗口外录入 + 网格显示链 + 双倍修复 + 上传前核对 + 重传选择 + 报错拆分
.2.5–.3 调试系列收口: - 雷达窗口外录入:不在过境窗口内按 Log 时按当前过境中点落点(弹窗确认,可事后编辑改时间) - 网格显示链:上传盖章(markUploaded 记录台址网格集)+ 日志本行尾 / 雷达页「自己的格子」+ QSL 槽对方网格(绿 #4CD964)+ 缩写规则统一(gridsLabel 上移 core:presentation); QsoDatabase v2→v3 增列 theirVuccGrids - 全量同步「双倍」三层修复:键无关身份±60s 镜像折叠、matchableMode 归一(MFSK→FT4)、 双确认对按 contentScore 折叠 - 上传前核对:批记录网格与台址完全不交集 → 弹窗(修改台址带预填跳转 / 忽略),预览被闸住 - 重传选中:日志本长按多选(含已上传/已确认)→ prepare(resubmit=true) → 成功后网格盖章更新; 退出日志本重置选择(需重新长按) - 报错拆分:QSO_FUTURE(记录时间在未来)与 QSO_DATE(超出证书范围)分开提示 - 全 App 弹窗样式统一(深色圆角 sheet) 测试:domain 172 / data 116 全绿。
This commit is contained in:
1 parent
d13af978af
commit
5b7c7d6df5
36 files changed
+1145
-99
No files matched your search
@@ -20,7 +20,7 @@ import com.rtbishop.look4sat.core.data.database.entity.QsoEntity
|
||||
* upload receipts). Kept apart from [Look4SatDb] so existing installations
|
||||
* never need a migration of the satellite database.
|
||||
*/
|
||||
@Database(entities = [QsoEntity::class], version = 2, exportSchema = false)
|
||||
@Database(entities = [QsoEntity::class], version = 3, exportSchema = false)
|
||||
abstract class QsoDatabase : RoomDatabase() {
|
||||
abstract fun qsoDao(): QsoDao
|
||||
}
|
||||
@@ -31,3 +31,10 @@ val MIGRATION_QSO_1_2 = object : Migration(1, 2) {
|
||||
db.execSQL("ALTER TABLE qso_records ADD COLUMN lotwUploaded INTEGER NOT NULL DEFAULT 0")
|
||||
}
|
||||
}
|
||||
|
||||
/** v2 → v3: keep the OPPOSITE station's grid set (multi-grid confirmations as LoTW reports them). */
|
||||
val MIGRATION_QSO_2_3 = object : Migration(2, 3) {
|
||||
override fun migrate(db: SupportSQLiteDatabase) {
|
||||
db.execSQL("ALTER TABLE qso_records ADD COLUMN theirVuccGrids TEXT NOT NULL DEFAULT ''")
|
||||
}
|
||||
}
|
||||
@@ -47,6 +47,7 @@ data class QsoEntity(
|
||||
@ColumnInfo(defaultValue = "0") val lotwReceived: Boolean = false,
|
||||
@ColumnInfo(defaultValue = "''") val lotwQslDate: String = "",
|
||||
@ColumnInfo(defaultValue = "''") val vuccGrids: String = "",
|
||||
@ColumnInfo(defaultValue = "''") val theirVuccGrids: String = "",
|
||||
val dxcc: Int? = null,
|
||||
@ColumnInfo(defaultValue = "''") val country: String = "",
|
||||
val cqZone: Int? = null,
|
||||
|
||||
@@ -30,6 +30,7 @@ import com.rtbishop.look4sat.core.data.database.Look4SatDb
|
||||
import com.rtbishop.look4sat.core.data.database.MIGRATION_1_2
|
||||
import com.rtbishop.look4sat.core.data.database.MIGRATION_2_3
|
||||
import com.rtbishop.look4sat.core.data.database.MIGRATION_QSO_1_2
|
||||
import com.rtbishop.look4sat.core.data.database.MIGRATION_QSO_2_3
|
||||
import com.rtbishop.look4sat.core.data.database.QsoDatabase
|
||||
import com.rtbishop.look4sat.core.data.framework.BluetoothReporter
|
||||
import com.rtbishop.look4sat.core.data.framework.Ft817Controller
|
||||
@@ -103,7 +104,7 @@ class MainContainer(private val context: Context) : IMainContainer {
|
||||
override val lotwRepo: ILoTWRepository by lazy { LoTWRepository() }
|
||||
override val qsoRepository: IQsoRepository by lazy {
|
||||
val database = Room.databaseBuilder(context, QsoDatabase::class.java, "Look4SatQsoDB")
|
||||
.addMigrations(MIGRATION_QSO_1_2)
|
||||
.addMigrations(MIGRATION_QSO_1_2, MIGRATION_QSO_2_3)
|
||||
.build()
|
||||
QsoRepository(database.qsoDao(), Dispatchers.IO)
|
||||
}
|
||||
|
||||
@@ -39,7 +39,14 @@ internal class LoTWSigner(private val config: LoTWConfig) {
|
||||
!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)
|
||||
val date = utc(record.startUtcMillis, "yyyy-MM-dd")
|
||||
if (date < key.info.firstQsoDate || (key.info.lastQsoDate.isNotBlank() && date > key.info.lastQsoDate) || record.startUtcMillis > now) {
|
||||
// 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
|
||||
// time" is the actionable advice. Reporting it as "outside the certificate" sent one
|
||||
// operator hunting the certificate while the culprit was the record's future time.
|
||||
if (record.startUtcMillis > now) {
|
||||
fail(LoTWProblem.QSO_FUTURE, "$call @ ${utc(record.startUtcMillis, "MM-dd HH:mm'Z'")}")
|
||||
}
|
||||
if (date < key.info.firstQsoDate || (key.info.lastQsoDate.isNotBlank() && date > key.info.lastQsoDate)) {
|
||||
fail(LoTWProblem.QSO_DATE, call)
|
||||
}
|
||||
fun mhz(hz: Long?): String = hz?.let { BigDecimal.valueOf(it, 6).stripTrailingZeros().toPlainString() }.orEmpty()
|
||||
|
||||
+15
-1
@@ -30,6 +30,7 @@ import com.rtbishop.look4sat.core.domain.repository.LoTWStation
|
||||
import com.rtbishop.look4sat.core.domain.repository.LoTWStationMeta
|
||||
import com.rtbishop.look4sat.core.domain.repository.LoTWUploadAudit
|
||||
import com.rtbishop.look4sat.core.domain.repository.LoTWUploadPreview
|
||||
import com.rtbishop.look4sat.core.domain.repository.uploadGridWarning
|
||||
import com.rtbishop.look4sat.core.domain.repository.LoTWUploadResult
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ensureActive
|
||||
@@ -279,6 +280,16 @@ class LoTWUploadRepository internal constructor(
|
||||
else -> contact
|
||||
}
|
||||
}
|
||||
// The station-location grids this batch is signed with: the primary grid plus the
|
||||
// MY_VUCC_GRIDS set (1 inside a grid, 2 on a line, 4 on a corner — see [LoTWConfig]).
|
||||
val locationGrids = buildList {
|
||||
signing.location["GRIDSQUARE"]?.takeIf(String::isNotBlank)?.let(::add)
|
||||
signing.location["MY_VUCC_GRIDS"]?.split(',')
|
||||
?.map(String::trim)?.filter(String::isNotBlank)?.let(::addAll)
|
||||
}.distinct()
|
||||
// Roaming guard: flag records whose own grids fall outside the station grids this
|
||||
// batch would be signed with (a station location never updated while roaming).
|
||||
val gridWarning = uploadGridWarning(contacts.map { it.record }, locationGrids)
|
||||
val preview = LoTWUploadPreview(
|
||||
UUID.randomUUID().toString(), signing.key.info.callsign, signing.key.info.dxcc, signing.location.getValue("GRIDSQUARE"),
|
||||
contacts.size, skipped,
|
||||
@@ -289,7 +300,10 @@ class LoTWUploadRepository internal constructor(
|
||||
unavailable,
|
||||
reasons.toMap(),
|
||||
duplicates,
|
||||
contacts.map { it.record.id }
|
||||
contacts.map { it.record.id },
|
||||
locationGrids,
|
||||
gridWarning,
|
||||
resubmit
|
||||
)
|
||||
if (contacts.isNotEmpty()) pending = Pending(
|
||||
preview,
|
||||
|
||||
+3
-1
@@ -285,7 +285,9 @@ class LoTWRepository : ILoTWRepository {
|
||||
myGrid = myGrids.firstOrNull(),
|
||||
myGrids = myGrids,
|
||||
myCallsign = myCallsign,
|
||||
stationKey = stationKey
|
||||
stationKey = stationKey,
|
||||
// Copy, never the live buffer — resetRecord() clears it.
|
||||
theirGrids = gridsInRecord.distinct().toList()
|
||||
)
|
||||
for (grid in gridsInRecord) {
|
||||
result.getOrPut(grid) { mutableListOf() }.add(qso)
|
||||
|
||||
+44
-21
@@ -16,8 +16,11 @@ import com.rtbishop.look4sat.core.domain.logbook.AdifImportResult
|
||||
import com.rtbishop.look4sat.core.domain.logbook.IQsoRepository
|
||||
import com.rtbishop.look4sat.core.domain.logbook.QsoRecord
|
||||
import com.rtbishop.look4sat.core.domain.logbook.QsoStatus
|
||||
import com.rtbishop.look4sat.core.domain.logbook.bandsMatchOrMirror
|
||||
import com.rtbishop.look4sat.core.domain.logbook.contentScore
|
||||
import com.rtbishop.look4sat.core.domain.logbook.displayMode
|
||||
import com.rtbishop.look4sat.core.domain.logbook.sameConfirmedContact
|
||||
import com.rtbishop.look4sat.core.domain.logbook.sameContactIdentity
|
||||
import com.rtbishop.look4sat.core.domain.logbook.satelliteIdentity
|
||||
import com.rtbishop.look4sat.core.domain.logbook.splitConfirmationPairs
|
||||
import com.rtbishop.look4sat.core.domain.logbook.withConfirmation
|
||||
@@ -54,14 +57,14 @@ class QsoRepository(
|
||||
dao.save(record.copy(
|
||||
id = 0L,
|
||||
lotwUploaded = false, lotwConfirmed = false, lotwReceived = false,
|
||||
lotwQslDate = "", vuccGrids = emptyList(),
|
||||
lotwQslDate = "", vuccGrids = emptyList(), theirVuccGrids = emptyList(),
|
||||
dxcc = null, country = "", cqZone = null, region = ""
|
||||
).toEntity())
|
||||
} else {
|
||||
val saved = if (previous?.lotwConfirmed == true && sameConfirmedContact(record, previous)) {
|
||||
record.withConfirmation(previous)
|
||||
} else if (previous?.lotwConfirmed == true) record.copy(
|
||||
lotwConfirmed = false, lotwQslDate = "", vuccGrids = emptyList(),
|
||||
lotwConfirmed = false, lotwQslDate = "", vuccGrids = emptyList(), theirVuccGrids = emptyList(),
|
||||
dxcc = null, country = "", cqZone = null, region = ""
|
||||
) else record
|
||||
val received = if (previous != null && (
|
||||
@@ -76,9 +79,18 @@ class QsoRepository(
|
||||
|
||||
override suspend fun delete(id: Long) = withContext(dispatcher) { importMutex.withLock { dao.delete(id) } }
|
||||
|
||||
override suspend fun markUploaded(ids: List<Long>) = withContext(dispatcher) {
|
||||
override suspend fun markUploaded(ids: List<Long>, grids: List<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 (stamped.isNotEmpty()) dao.saveBatch(stamped.map { it.toEntity() })
|
||||
}
|
||||
|
||||
override suspend fun exportAdi(ids: Set<Long>?, includeIncomplete: Boolean): String = withContext(dispatcher) {
|
||||
@@ -132,9 +144,7 @@ class QsoRepository(
|
||||
val renames = rewriteOfficialNames(working)
|
||||
val lookup = working.indices.groupBy { working[it].confirmationLookupKey() }
|
||||
.mapValues { it.value.toMutableList() }.toMutableMap()
|
||||
val keyIndex = working.indices.groupBy { stableQsoKey(working[it]) }
|
||||
.mapValues { it.value.toMutableList() }.toMutableMap()
|
||||
val knownKeys = keyIndex.keys.toMutableSet()
|
||||
val knownKeys = working.map { stableQsoKey(it) }.toMutableSet()
|
||||
val changes = linkedMapOf<Int, QsoRecord>().apply {
|
||||
putAll(consolidation.merged)
|
||||
putAll(renames)
|
||||
@@ -149,27 +159,38 @@ class QsoRepository(
|
||||
val exact = candidates.filter { working[it].startUtcMillis == remote.startUtcMillis }
|
||||
val match = exact.singleOrNull() ?: candidates.singleOrNull()
|
||||
if (match == null) {
|
||||
// A confirmation downloaded by the pre-fix parser sits as its own row with the
|
||||
// band direction read mirrored (the downlink was stored as the uplink). It has no
|
||||
// local partner (so nothing folds it) and its dedupe key equals the fresh report,
|
||||
// so it would otherwise be skipped forever, keeping the wrong band on display.
|
||||
// When the bands are exactly mirrored, adopt the report's direction instead.
|
||||
val stale = if (fromLoTW) keyIndex[stableQsoKey(remote)]?.firstOrNull()
|
||||
?.takeIf { index -> working[index].lotwConfirmed &&
|
||||
working[index].band.equals(remote.rxBand, true) &&
|
||||
working[index].rxBand.equals(remote.band, true) } else null
|
||||
if (stale != null) {
|
||||
val fixed = working[stale].copy(band = remote.band, rxBand = remote.rxBand)
|
||||
working[stale] = fixed
|
||||
changes[stale] = fixed
|
||||
updated++
|
||||
// A confirmation downloaded by an early release sits as its own row with the
|
||||
// band direction read mirrored (the downlink was stored as the uplink). The
|
||||
// strict lookup above cannot see it: its bands fail the comparison, and its
|
||||
// dedupe key can differ too (fields written by older releases — timestamp
|
||||
// precision, FT4 sub-mode spelling, missing callsign — so it is not treated
|
||||
// as known either). Fold the report into the richest mirrored row instead of
|
||||
// inserting a second row next to it; left as two rows, neither consolidation
|
||||
// nor a later sync could reliably heal them.
|
||||
val mirroredRows = if (fromLoTW) lookup[remote.confirmationLookupKey()].orEmpty().filter { index ->
|
||||
working[index].band.isNotBlank() && remote.band.isNotBlank() &&
|
||||
!working[index].band.equals(remote.band, true) &&
|
||||
sameContactIdentity(working[index], remote) &&
|
||||
bandsMatchOrMirror(working[index], remote)
|
||||
} else emptyList()
|
||||
if (mirroredRows.isNotEmpty()) {
|
||||
val target = mirroredRows.maxBy { contentScore(working[it]) }
|
||||
mirroredRows.forEach { index ->
|
||||
val healed = working[index].copy(
|
||||
band = remote.band.ifBlank { working[index].band },
|
||||
rxBand = remote.rxBand.ifBlank { working[index].rxBand }
|
||||
)
|
||||
val folded = if (index == target) healed.withConfirmation(remote) else healed
|
||||
working[index] = folded
|
||||
changes[index] = folded
|
||||
}
|
||||
updated += mirroredRows.size
|
||||
return@forEach
|
||||
}
|
||||
if (stableQsoKey(remote) in knownKeys) { skipped++; return@forEach }
|
||||
val added = remote.copy(id = 0L, satelliteName = officialSatelliteName(remote.satelliteName))
|
||||
changes[working.size] = added
|
||||
lookup.getOrPut(added.confirmationLookupKey()) { mutableListOf() }.add(working.size)
|
||||
keyIndex.getOrPut(stableQsoKey(added)) { mutableListOf() }.add(working.size)
|
||||
knownKeys += stableQsoKey(added)
|
||||
working += added
|
||||
imported++
|
||||
@@ -268,6 +289,7 @@ private fun QsoEntity.toDomain() = QsoRecord(
|
||||
lotwReceived = lotwReceived,
|
||||
lotwQslDate = lotwQslDate,
|
||||
vuccGrids = vuccGrids.split(',').filter(String::isNotBlank),
|
||||
theirVuccGrids = theirVuccGrids.split(',').filter(String::isNotBlank),
|
||||
dxcc = dxcc,
|
||||
country = country,
|
||||
cqZone = cqZone,
|
||||
@@ -304,6 +326,7 @@ private fun QsoRecord.toEntity() = QsoEntity(
|
||||
lotwReceived = lotwReceived,
|
||||
lotwQslDate = lotwQslDate,
|
||||
vuccGrids = vuccGrids.joinToString(","),
|
||||
theirVuccGrids = theirVuccGrids.joinToString(","),
|
||||
dxcc = dxcc,
|
||||
country = country,
|
||||
cqZone = cqZone,
|
||||
|
||||
+23
@@ -167,6 +167,29 @@ class LoTWRepositoryTest {
|
||||
result.values.forEach { assertEquals(1, it.size) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parseQsosCapturesOppositeGridSet() {
|
||||
// The opposite station's grids (GRIDSQUARE + VUCC_GRIDS) must ride on the
|
||||
// GridQso so the logbook can show multi-grid confirmations in its QSL slot.
|
||||
val single = "<PROP_MODE:3>SAT\n<SAT_NAME:5>FO-29\n<GRIDSQUARE:4>NL47\n<EOR>\n"
|
||||
assertEquals(
|
||||
listOf("NL47"),
|
||||
repo.parseConfirmedGridQsos(report(single))!!["NL47"]!!.first().theirGrids
|
||||
)
|
||||
val pair = "<PROP_MODE:3>SAT\n<SAT_NAME:5>SO-50\n<VUCC_GRIDS:11>EN52en,EN53fa\n<EOR>\n"
|
||||
assertEquals(
|
||||
listOf("EN52", "EN53"),
|
||||
repo.parseConfirmedGridQsos(report(pair))!!["EN52"]!!.first().theirGrids
|
||||
)
|
||||
// A 6-char GRIDSQUARE plus a VUCC pair: every field, truncated to 4 chars, in order.
|
||||
val six = "<PROP_MODE:3>SAT\n<SAT_NAME:5>SO-50\n" +
|
||||
"<GRIDSQUARE:6>OM60IL\n<VUCC_GRIDS:11>EN52en,EN53fa\n<EOR>\n"
|
||||
assertEquals(
|
||||
listOf("OM60", "EN52", "EN53"),
|
||||
repo.parseConfirmedGridQsos(report(six))!!["OM60"]!!.first().theirGrids
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parseQsosRejectsBodyWithoutEoh() {
|
||||
assertNull(repo.parseConfirmedGridQsos("<HTML>Username/password incorrect</HTML>"))
|
||||
|
||||
+106
-1
@@ -52,6 +52,9 @@ class QsoRepositoryTest {
|
||||
// The row is also renamed to the ARRL spelling while it is merged.
|
||||
assertEquals("SO-50", rows.first().satelliteName)
|
||||
assertEquals(145_850_000L, rows.first().txFrequencyHz)
|
||||
// The opposite station's grid set from the report lands on the row.
|
||||
assertEquals("EN52", rows.first().theirGrid)
|
||||
assertEquals("EN52,EN53", rows.first().theirVuccGrids)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -124,6 +127,106 @@ class QsoRepositoryTest {
|
||||
assertEquals(setOf("SO-50", "FOO-1"), dao.getAll().map { it.satelliteName }.toSet())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun consolidateConfirmations_foldsADoubledConfirmedPairKeepingTheRicherRow() = runBlocking {
|
||||
// The mirrored import the old parser created (no frequencies, no report-side grids —
|
||||
// the pre-fix parser filled neither — band direction reversed)... seeded FIRST so it
|
||||
// carries the lower id.
|
||||
dao.save(
|
||||
reportConfirmation(satName = "SO-50")
|
||||
.copy(band = "70CM", rxBand = "2M", theirGrid = "", theirVuccGrids = emptyList())
|
||||
.toEntity()
|
||||
)
|
||||
// ...plus the app row that was confirmed on its own later (frequencies, upload state).
|
||||
val localId = dao.save(
|
||||
loggedInApp("SO-50", lotwUploaded = true).copy(lotwConfirmed = true).toEntity()
|
||||
)
|
||||
|
||||
assertEquals(1, repository.consolidateConfirmations())
|
||||
|
||||
val rows = dao.getAll()
|
||||
assertEquals(1, rows.size)
|
||||
assertEquals(localId, rows.first().id)
|
||||
// The surviving row keeps the correct uplink-first bands and gains the report fills.
|
||||
assertEquals("2M", rows.first().band)
|
||||
assertEquals("70CM", rows.first().rxBand)
|
||||
assertEquals("OL62", rows.first().myGrid)
|
||||
assertEquals(318, rows.first().dxcc)
|
||||
assertEquals("China", rows.first().country)
|
||||
assertTrue(rows.first().lotwUploaded)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun mergeLoTW_healsADoubledMirroredContact() = runBlocking {
|
||||
// Two mirrored copies of one confirmation (as the pre-fix parser imported on separate
|
||||
// syncs — no frequencies, no report-side grids): the full sync must consolidate them
|
||||
// into one row with the report's direction.
|
||||
val stale = reportConfirmation(satName = "SO-50")
|
||||
.copy(band = "70CM", rxBand = "2M", theirGrid = "", theirVuccGrids = emptyList())
|
||||
dao.save(stale.toEntity())
|
||||
dao.save(stale.copy(id = 0L).toEntity())
|
||||
|
||||
repository.mergeLoTW(listOf(reportConfirmation(satName = "SO-50")))
|
||||
|
||||
val rows = dao.getAll()
|
||||
assertEquals(1, rows.size)
|
||||
assertEquals("2M", rows.first().band)
|
||||
assertEquals("70CM", rows.first().rxBand)
|
||||
// The report's fills land on the healed row.
|
||||
assertEquals("EN52", rows.first().theirGrid)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun mergeLoTW_healsAMirroredRowWhoseDedupeKeyDivergedFromTheReport() = runBlocking {
|
||||
// The doubled contacts of the reported bug: a mirrored row written by an older release
|
||||
// (millisecond-precision timestamp here — enough to make the dedupe key, which holds
|
||||
// the exact time, differ from the report's) used to gain a second row next to it
|
||||
// instead of being healed, because every net relied on the key or the strict bands.
|
||||
val staleId = dao.save(
|
||||
reportConfirmation(satName = "SO-50")
|
||||
.copy(
|
||||
band = "70CM", rxBand = "2M", theirGrid = "", theirVuccGrids = emptyList(),
|
||||
startUtcMillis = qsoStart + 456L
|
||||
)
|
||||
.toEntity()
|
||||
)
|
||||
|
||||
repository.mergeLoTW(listOf(reportConfirmation(satName = "SO-50")))
|
||||
|
||||
val rows = dao.getAll()
|
||||
assertEquals(1, rows.size)
|
||||
assertEquals(staleId, rows.first().id)
|
||||
assertEquals("2M", rows.first().band)
|
||||
assertEquals("70CM", rows.first().rxBand)
|
||||
assertTrue(rows.first().lotwConfirmed)
|
||||
assertEquals("EN52", rows.first().theirGrid)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun mergeLoTW_foldsAMirroredRowSpelledAsPlainMfskIntoItsFt4Report() = runBlocking {
|
||||
// Releases that persisted no sub-mode stored FT4 rows as plain MFSK while the report
|
||||
// carries MFSK + FT4. The spelling difference must not block the heal either.
|
||||
dao.save(
|
||||
reportConfirmation(satName = "SO-50")
|
||||
.copy(
|
||||
band = "70CM", rxBand = "2M", mode = "MFSK", submode = "",
|
||||
theirGrid = "", theirVuccGrids = emptyList()
|
||||
)
|
||||
.toEntity()
|
||||
)
|
||||
|
||||
repository.mergeLoTW(
|
||||
listOf(reportConfirmation(satName = "SO-50").copy(mode = "MFSK", submode = "FT4"))
|
||||
)
|
||||
|
||||
val rows = dao.getAll()
|
||||
assertEquals(1, rows.size)
|
||||
assertEquals("2M", rows.first().band)
|
||||
assertEquals("70CM", rows.first().rxBand)
|
||||
assertTrue(rows.first().lotwConfirmed)
|
||||
assertEquals("EN52", rows.first().theirGrid)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun mergeLoTW_fixesBandDirectionOfAStaleConfirmationRow() = runBlocking {
|
||||
// A confirmation downloaded by the pre-fix parser: ARRL name but the band direction
|
||||
@@ -221,7 +324,8 @@ class QsoRepositoryTest {
|
||||
cqz = 24,
|
||||
state = "GD",
|
||||
myGrid = "OL62",
|
||||
myGrids = setOf("OL62", "OL63")
|
||||
myGrids = setOf("OL62", "OL63"),
|
||||
theirGrids = listOf("EN52", "EN53")
|
||||
).toConfirmedRecord("BA7OPF")
|
||||
|
||||
private fun QsoRecord.toEntity() = QsoEntity(
|
||||
@@ -254,6 +358,7 @@ class QsoRepositoryTest {
|
||||
lotwReceived = lotwReceived,
|
||||
lotwQslDate = lotwQslDate,
|
||||
vuccGrids = vuccGrids.joinToString(","),
|
||||
theirVuccGrids = theirVuccGrids.joinToString(","),
|
||||
dxcc = dxcc,
|
||||
country = country,
|
||||
cqZone = cqZone,
|
||||
|
||||
+6
-1
@@ -17,7 +17,12 @@ interface IQsoRepository {
|
||||
suspend fun find(id: Long): QsoRecord?
|
||||
suspend fun save(record: QsoRecord): Long
|
||||
suspend fun delete(id: Long)
|
||||
suspend fun markUploaded(ids: List<Long>)
|
||||
/**
|
||||
* Marks the batch as uploaded and stamps the station-location grids the batch was
|
||||
* 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 exportAdi(ids: Set<Long>? = null, includeIncomplete: Boolean = false): String
|
||||
suspend fun importAdi(content: String): AdifImportResult
|
||||
suspend fun mergeConfirmed(records: List<QsoRecord>): AdifImportResult
|
||||
|
||||
+2
@@ -27,6 +27,8 @@ fun GridQso.toConfirmedRecord(accountCallsign: String): QsoRecord = QsoRecord(
|
||||
startUtcMillis = epochMs,
|
||||
theirCallsign = call,
|
||||
myCallsign = accountCallsign.trim().uppercase(),
|
||||
theirGrid = theirGrids.firstOrNull().orEmpty(),
|
||||
theirVuccGrids = theirGrids,
|
||||
myGrid = (myGrids.firstOrNull() ?: myGrid).orEmpty(),
|
||||
vuccGrids = myGrids.toList(),
|
||||
band = bandUp,
|
||||
|
||||
+1
@@ -31,6 +31,7 @@ fun LoTWProblem.label(): String = when (this) {
|
||||
LoTWProblem.SATELLITE -> "satellite name not in ARRL's list"
|
||||
LoTWProblem.MODE -> "mode not accepted by LoTW"
|
||||
LoTWProblem.QSO_DATE -> "date outside the certificate"
|
||||
LoTWProblem.QSO_FUTURE -> "record time is in the future"
|
||||
LoTWProblem.CALLSIGN_MISMATCH -> "MY callsign does not match the certificate"
|
||||
LoTWProblem.INVALID_CONTACT -> "invalid callsign/record"
|
||||
LoTWProblem.STATION_GRID -> "station grid missing"
|
||||
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
package com.rtbishop.look4sat.core.domain.logbook
|
||||
|
||||
/**
|
||||
* The records a resubmit actually re-signs: whatever the operator checked, complete rows only.
|
||||
*
|
||||
* Unlike a normal upload this deliberately keeps already-uploaded and confirmed records —
|
||||
* the point is to send them again under a corrected station location, and LoTW treats an
|
||||
* identical contact (call/band/mode/time/satellite) as an update of the existing record.
|
||||
*/
|
||||
fun resubmitCandidates(records: List<QsoRecord>, selectedIds: Set<Long>): List<QsoRecord> =
|
||||
records.filter { it.id in selectedIds && it.status == QsoStatus.COMPLETE }
|
||||
+77
-16
@@ -80,7 +80,7 @@ fun sameContactIdentity(local: QsoRecord, remote: QsoRecord): Boolean {
|
||||
local.theirCallsign.trim().equals(remote.theirCallsign.trim(), true) &&
|
||||
(local.myCallsign.isBlank() || remote.myCallsign.isBlank() || local.myCallsign.equals(remote.myCallsign, true)) &&
|
||||
local.isSatellite == remote.isSatellite &&
|
||||
local.displayMode == remote.displayMode &&
|
||||
local.matchableMode == remote.matchableMode &&
|
||||
abs(local.startUtcMillis - remote.startUtcMillis) < 60_000L
|
||||
}
|
||||
|
||||
@@ -93,45 +93,100 @@ fun sameConfirmedContact(local: QsoRecord, remote: QsoRecord): Boolean =
|
||||
sameContactIdentity(local, remote) && sameBandOrBlank(local.band, remote.band)
|
||||
|
||||
fun QsoRecord.confirmationLookupKey(): String = listOf(
|
||||
theirCallsign.trim().uppercase(Locale.US), satelliteIdentity(satelliteName), displayMode
|
||||
theirCallsign.trim().uppercase(Locale.US), satelliteIdentity(satelliteName), matchableMode
|
||||
).joinToString("|")
|
||||
|
||||
/** A local row and the confirmation row that belongs to it, as indices into one list. */
|
||||
/** A row to keep and the duplicate row to fold into it (then delete), as indices into one list. */
|
||||
data class SplitConfirmationPair(val localIndex: Int, val confirmationIndex: Int)
|
||||
|
||||
/**
|
||||
* Rows that a confirmation should have been folded into but was not, so they can be
|
||||
* consolidated back into one.
|
||||
* Rows that should have been one row but were not, so they can be consolidated back into one.
|
||||
*
|
||||
* Confirmations merged before the identity fix landed as their own rows: the tracker
|
||||
* name vs ARRL name comparison failed, and the band direction of the report was read
|
||||
* mirrored (BAND_RX was taken as the uplink), so even satellites with matching names
|
||||
* were stored twice. The pair is only accepted when the contact identity matches and
|
||||
* the bands are either identical or exactly mirrored — the signature of that second
|
||||
* mismatch — which keeps unrelated QSOs of the same operator and minute apart.
|
||||
* Two known holes leave a contact stored twice:
|
||||
*
|
||||
* 1. Confirmations merged before the identity fix landed as their own rows: the tracker name
|
||||
* vs ARRL name comparison failed, and the band direction of the report was read mirrored
|
||||
* (BAND_RX taken as the uplink) — so an unconfirmed local row and its confirmation stayed
|
||||
* apart. Pass 1 folds the confirmation into the local row.
|
||||
* 2. A contact can also end up with TWO confirmed rows: a mirrored import the old parser
|
||||
* created plus the row that was confirmed on its own later, or a duplicate import added
|
||||
* while ambiguous candidates made the match fail. Pass 1 cannot fold those — both sides
|
||||
* are confirmed — so pass 2 pairs confirmed rows with each other.
|
||||
*
|
||||
* Both passes accept a pair only when the contact identity matches and the bands are either
|
||||
* identical or exactly mirrored — the mirror being the fingerprint of the pre-fix parser —
|
||||
* which keeps unrelated QSOs of the same operator and minute apart. In pass 2 the row with
|
||||
* more content is kept (frequencies/grids/reports beat an empty mirrored import) and the
|
||||
* other one is folded into it, so no data is lost.
|
||||
*/
|
||||
fun splitConfirmationPairs(records: List<QsoRecord>): List<SplitConfirmationPair> {
|
||||
val pendingIndices = records.indices.filter { !records[it].lotwConfirmed }
|
||||
val taken = mutableSetOf<Int>()
|
||||
return records.indices.filter { records[it].lotwConfirmed }.mapNotNull { confirmationIndex ->
|
||||
val pairs = mutableListOf<SplitConfirmationPair>()
|
||||
// Pass 1: fold a downloaded confirmation into the unconfirmed local row it belongs to.
|
||||
val pendingIndices = records.indices.filter { !records[it].lotwConfirmed }
|
||||
records.indices.filter { records[it].lotwConfirmed }.forEach { confirmationIndex ->
|
||||
val confirmation = records[confirmationIndex]
|
||||
val localIndex = pendingIndices.firstOrNull { index ->
|
||||
index !in taken && sameContactIdentity(records[index], confirmation) &&
|
||||
bandsMatchOrMirror(records[index], confirmation)
|
||||
} ?: return@mapNotNull null
|
||||
} ?: return@forEach
|
||||
taken += localIndex
|
||||
SplitConfirmationPair(localIndex, confirmationIndex)
|
||||
taken += confirmationIndex
|
||||
pairs += SplitConfirmationPair(localIndex, confirmationIndex)
|
||||
}
|
||||
// Pass 2: two CONFIRMED rows describing the same contact (mirrored import + the confirmed
|
||||
// row it never folded into, or a duplicate import). Keep the richer row, drop the other.
|
||||
val confirmedLeft = records.indices.filter { records[it].lotwConfirmed && it !in taken }
|
||||
confirmedLeft.forEach { index ->
|
||||
if (index in taken) return@forEach
|
||||
val cluster = mutableListOf(index)
|
||||
confirmedLeft.forEach { candidate ->
|
||||
if (candidate != index && candidate !in taken &&
|
||||
cluster.all {
|
||||
sameContactIdentity(records[it], records[candidate]) &&
|
||||
bandsMatchOrMirror(records[it], records[candidate])
|
||||
}
|
||||
) {
|
||||
cluster += candidate
|
||||
}
|
||||
}
|
||||
if (cluster.size < 2) return@forEach
|
||||
cluster.forEach { taken += it }
|
||||
val keep = cluster.maxBy { contentScore(records[it]) }
|
||||
cluster.filter { it != keep }.forEach { pairs += SplitConfirmationPair(keep, it) }
|
||||
}
|
||||
return pairs
|
||||
}
|
||||
|
||||
/** More operator/report detail first — decides which row survives a pass-2 fold. */
|
||||
fun contentScore(record: QsoRecord): Int {
|
||||
var score = 0
|
||||
// Frequencies are the strongest operator-entered signal; a band label consistent with its
|
||||
// frequency (frequencyBand(tx) == band) beats the mirrored parser output, whose labels
|
||||
// contradict the side they describe.
|
||||
record.txFrequencyHz?.let { hz -> score += 2; if (record.band == frequencyBand(hz)) score += 1 }
|
||||
record.rxFrequencyHz?.let { hz -> score += 2; if (record.rxBand == frequencyBand(hz)) score += 1 }
|
||||
score += listOf(record.sentReport, record.receivedReport, record.myGrid, record.theirGrid, record.comment)
|
||||
.count { it.isNotBlank() }
|
||||
score += record.vuccGrids.size
|
||||
score += record.theirVuccGrids.size
|
||||
if (record.lotwUploaded) score += 1
|
||||
if (record.lotwReceived) score += 1
|
||||
if (record.dxcc != null) score += 1
|
||||
if (record.country.isNotBlank()) score += 1
|
||||
if (record.lotwQslDate.isNotBlank()) score += 1
|
||||
return score
|
||||
}
|
||||
|
||||
/** Same band on both sides, or the mirrored pair the pre-fix parser produced. */
|
||||
private fun bandsMatchOrMirror(local: QsoRecord, confirmation: QsoRecord): Boolean =
|
||||
fun bandsMatchOrMirror(local: QsoRecord, confirmation: QsoRecord): Boolean =
|
||||
(sameBandOrBlank(local.band, confirmation.band) && sameBandOrBlank(local.rxBand, confirmation.rxBand)) ||
|
||||
(sameBandOrBlank(local.band, confirmation.rxBand) && sameBandOrBlank(local.rxBand, confirmation.band))
|
||||
|
||||
fun QsoRecord.withConfirmation(confirmed: QsoRecord): QsoRecord = copy(
|
||||
myCallsign = myCallsign.ifBlank { confirmed.myCallsign },
|
||||
theirGrid = confirmed.theirGrid.ifBlank { theirGrid },
|
||||
theirVuccGrids = confirmed.theirVuccGrids.ifEmpty { theirVuccGrids },
|
||||
myGrid = myGrid.ifBlank { confirmed.myGrid },
|
||||
sentReport = sentReport.ifBlank { confirmed.sentReport },
|
||||
receivedReport = receivedReport.ifBlank { confirmed.receivedReport },
|
||||
@@ -159,6 +214,12 @@ val QsoRecord.displayMode: String
|
||||
return label.trim().uppercase(Locale.US)
|
||||
}
|
||||
|
||||
/** The mode label as the merge compares it: satellite FT4 is written as MFSK + FT4, and
|
||||
* releases that dropped the sub-mode persisted plain MFSK — the same mode, spelled
|
||||
* differently, so both spellings must match. */
|
||||
val QsoRecord.matchableMode: String
|
||||
get() = displayMode.let { if (it == "MFSK") "FT4" else it }
|
||||
|
||||
val QsoRecord.isSatellite: Boolean
|
||||
get() = propagationMode.equals("SAT", true) || (propagationMode.isBlank() && satelliteName.isNotBlank())
|
||||
|
||||
|
||||
@@ -18,6 +18,10 @@ data class QsoRecord(
|
||||
val theirCallsign: String,
|
||||
val myCallsign: String,
|
||||
val theirGrid: String = "",
|
||||
/** Every 4-char grid the OPPOSITE station logged this QSO under (ADIF
|
||||
* <GRIDSQUARE> + <VUCC_GRIDS>; 1–4 grids). [theirGrid] keeps the first;
|
||||
* the logbook's QSL slot shows the abbreviated set. */
|
||||
val theirVuccGrids: List<String> = emptyList(),
|
||||
val myGrid: String = "",
|
||||
val sentReport: String = "",
|
||||
val receivedReport: String = "",
|
||||
|
||||
@@ -72,7 +72,13 @@ data class GridQso(
|
||||
* MY_STATE + MY_CQ_ZONE + MY_ITU_ZONE + MY_IOTA + MY_COUNTRY), so the
|
||||
* operated-grid selector can group QSOs per 台址 and show each 台址 with
|
||||
* its full grid set. Null for data synced before this field existed. */
|
||||
val stationKey: String? = null
|
||||
val stationKey: String? = null,
|
||||
/** Every 4-char grid of the OPPOSITE station for this QSO — ADIF
|
||||
* <GRIDSQUARE> plus every field of <VUCC_GRIDS> (comma-separated when
|
||||
* the contact spans several squares; up to four in one QSO). The logbook
|
||||
* shows the set in the QSL slot of a confirmed row; [myGrids] is the
|
||||
* same thing for the user's own side. */
|
||||
val theirGrids: List<String> = emptyList()
|
||||
) {
|
||||
/** Short uplink/downlink band label ("U/V", "V/A"), or "" when unknown. */
|
||||
val bandLabel: String
|
||||
|
||||
+51
-2
@@ -19,6 +19,7 @@
|
||||
package com.rtbishop.look4sat.core.domain.repository
|
||||
|
||||
import com.rtbishop.look4sat.core.domain.logbook.QsoRecord
|
||||
import java.util.Locale
|
||||
|
||||
interface ILoTWUploadRepository {
|
||||
suspend fun certificate(): LoTWCertificate?
|
||||
@@ -96,7 +97,26 @@ data class LoTWUploadPreview(
|
||||
val duplicateSkipped: Int = 0,
|
||||
/** Ids of the records that actually made it into this TQ8 batch. Only these
|
||||
* may be marked "uploaded" after an accepted POST — never the full candidate list. */
|
||||
val submittedIds: List<Long> = emptyList()
|
||||
val submittedIds: List<Long> = emptyList(),
|
||||
/** The full station-location grid set this batch is signed with (1 inside a grid,
|
||||
* 2 on a line, 4 on a corner). Stamped onto the uploaded records so the logbook
|
||||
* can show which gridsquares the QSO went out under. */
|
||||
val grids: List<String> = emptyList(),
|
||||
/** Records whose own grids fall outside this batch's station location — the fingerprint
|
||||
* of uploading while roaming with a stale station location. Null when all covered. */
|
||||
val gridWarning: LoTWGridWarning? = null,
|
||||
/** 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
|
||||
)
|
||||
|
||||
/** Pre-upload grid audit result: [count] records carry own grids outside the station grids. */
|
||||
data class LoTWGridWarning(
|
||||
val count: Int,
|
||||
/** Distinct own grids of the affected records (normalized, 4 characters). */
|
||||
val recordGrids: List<String>,
|
||||
/** Distinct station-location grids this batch would be signed with. */
|
||||
val stationGrids: List<String>
|
||||
)
|
||||
|
||||
/** Why the un-uploadable records of a selection cannot be signed, and what was left out. */
|
||||
@@ -128,7 +148,36 @@ class LoTWOperationException(val reason: LoTWProblem, val detail: String = "") :
|
||||
|
||||
enum class LoTWProblem {
|
||||
CERTIFICATE_PASSWORD, CERTIFICATE_INVALID, CERTIFICATE_EXPIRED, CERTIFICATE_MISSING,
|
||||
CERTIFICATE_FORMAT, STORAGE, EMPTY_SELECTION, CALLSIGN_MISMATCH, QSO_DATE, STATION_GRID,
|
||||
CERTIFICATE_FORMAT, STORAGE, EMPTY_SELECTION, CALLSIGN_MISMATCH, QSO_DATE, QSO_FUTURE, STATION_GRID,
|
||||
STATION_REGION, STATION_ZONE, STATION_IOTA, MODE, BAND, SATELLITE, INVALID_CONTACT,
|
||||
LOCATION_MISMATCH, TOO_MANY_CONTACTS
|
||||
}
|
||||
|
||||
/**
|
||||
* Records whose own grids (the set a previous upload stamped, else the logged grid) do not
|
||||
* appear at all in the station-location grids an upload goes out under — the signature of
|
||||
* uploading while roaming with a station location that was never updated. Records without
|
||||
* any grid are not flagged, and a partially covered set (boundary operations) is not
|
||||
* flagged either: only a fully disjoint pair, which is always a mistake, warns.
|
||||
*/
|
||||
fun uploadGridWarning(records: List<QsoRecord>, stationGrids: List<String>): LoTWGridWarning? {
|
||||
val station = stationGrids.mapNotNull { it.grid4() }.toSet()
|
||||
if (station.isEmpty()) return null
|
||||
val affected = records.filter { record ->
|
||||
val own = record.ownGrids()
|
||||
own.isNotEmpty() && own.none { it in station }
|
||||
}
|
||||
if (affected.isEmpty()) return null
|
||||
return LoTWGridWarning(
|
||||
affected.size,
|
||||
affected.flatMap { it.ownGrids() }.distinct().sorted(),
|
||||
station.sorted()
|
||||
)
|
||||
}
|
||||
|
||||
/** The grid set a previous upload stamped onto the record, else the grid it was logged under. */
|
||||
private fun QsoRecord.ownGrids(): Set<String> =
|
||||
(vuccGrids.ifEmpty { listOf(myGrid) }).mapNotNull { it.grid4() }.toSet()
|
||||
|
||||
/** Normalized 4-character grid, null when the value is not a usable grid. */
|
||||
private fun String.grid4(): String? = trim().uppercase(Locale.US).take(4).takeIf { it.length >= 4 }
|
||||
+42
-2
@@ -40,7 +40,8 @@ class LogbookMergeTest {
|
||||
propagationMode = "SAT",
|
||||
status = QsoStatus.COMPLETE,
|
||||
lotwConfirmed = true,
|
||||
vuccGrids = listOf("OM60", "OM50")
|
||||
vuccGrids = listOf("OM60", "OM50"),
|
||||
theirVuccGrids = listOf("EN52", "EN53")
|
||||
)
|
||||
|
||||
@Test
|
||||
@@ -73,12 +74,24 @@ class LogbookMergeTest {
|
||||
assertEquals(local.confirmationLookupKey(), confirmed.confirmationLookupKey())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun confirmationLookupKey_groupsMfskAndFt4Spellings() {
|
||||
// Satellite FT4 is MFSK + FT4; a row persisted by an older release as plain MFSK is
|
||||
// the same mode and must resolve to the same contact.
|
||||
val plainMfsk = confirmed.copy(mode = "MFSK", submode = "")
|
||||
val ft4 = confirmed.copy(mode = "MFSK", submode = "FT4")
|
||||
assertEquals(plainMfsk.confirmationLookupKey(), ft4.confirmationLookupKey())
|
||||
assertTrue(sameContactIdentity(plainMfsk, ft4))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun withConfirmation_marksConfirmedAndMergesVuccGrids() {
|
||||
val merged = local.withConfirmation(confirmed)
|
||||
assertTrue(merged.lotwConfirmed)
|
||||
assertTrue(merged.lotwReceived)
|
||||
assertEquals(listOf("OM60", "OM50"), merged.vuccGrids)
|
||||
// The opposite station's grid set arrives with the confirmation too.
|
||||
assertEquals(listOf("EN52", "EN53"), merged.theirVuccGrids)
|
||||
// Local myCallsign is kept when present; only blanks are backfilled.
|
||||
assertEquals("ba7opf", merged.myCallsign)
|
||||
}
|
||||
@@ -97,13 +110,16 @@ class LogbookMergeTest {
|
||||
cqz = 24,
|
||||
state = "GD",
|
||||
myGrid = "OM60",
|
||||
myGrids = setOf("OM60", "OM50")
|
||||
myGrids = setOf("OM60", "OM50"),
|
||||
theirGrids = listOf("EN52", "EN53")
|
||||
)
|
||||
val record = qso.toConfirmedRecord("ba7opf")
|
||||
assertEquals("BH6RJD", record.theirCallsign)
|
||||
assertEquals("BA7OPF", record.myCallsign)
|
||||
assertEquals("OM60", record.myGrid)
|
||||
assertEquals(listOf("OM60", "OM50"), record.vuccGrids)
|
||||
assertEquals("EN52", record.theirGrid)
|
||||
assertEquals(listOf("EN52", "EN53"), record.theirVuccGrids)
|
||||
assertTrue(record.lotwConfirmed)
|
||||
assertTrue(record.isSatellite)
|
||||
assertEquals("70CM", record.band)
|
||||
@@ -198,6 +214,30 @@ class LogbookMergeTest {
|
||||
assertEquals(1, pairs.first().confirmationIndex)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun splitConfirmationPairs_foldsTwoConfirmedRowsAndKeepsTheRicher() {
|
||||
// A contact stored twice with BOTH sides confirmed: the mirrored import the old parser
|
||||
// created (no frequencies, report-only fills) plus the app row that was confirmed on
|
||||
// its own later (frequencies, upload state). Pass 1 cannot fold those; without pass 2
|
||||
// they stay doubled in the logbook forever.
|
||||
val mirroredImport = reportedRecord("SO-50", band = "70CM", rxBand = "2M").copy(id = 1L)
|
||||
val confirmedLocal = loggedRecord("SAUDISAT 1C", 145_850_000L, 436_795_000L)
|
||||
.copy(id = 2L, lotwConfirmed = true)
|
||||
val pairs = splitConfirmationPairs(listOf(mirroredImport, confirmedLocal))
|
||||
assertEquals(1, pairs.size)
|
||||
// The richer row (frequencies + consistent bands + upload state) is the survivor.
|
||||
assertEquals(1, pairs.first().localIndex)
|
||||
assertEquals(0, pairs.first().confirmationIndex)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun splitConfirmationPairs_keepsTwoConfirmedRowsOfDifferentMinutesApart() {
|
||||
val one = reportedRecord("SO-50", band = "70CM", rxBand = "2M").copy(id = 1L)
|
||||
val otherMinute = reportedRecord("SO-50", band = "2M", rxBand = "70CM")
|
||||
.copy(id = 2L, startUtcMillis = REPORTED_START + 90_000L)
|
||||
assertTrue(splitConfirmationPairs(listOf(one, otherMinute)).isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun splitConfirmationPairs_keepsUnrelatedRowsApart() {
|
||||
val uploaded = loggedRecord("SAUDISAT 1C", 145_850_000L, 436_795_000L)
|
||||
|
||||
+61
@@ -0,0 +1,61 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
package com.rtbishop.look4sat.core.domain.logbook
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
class LogbookResubmitTest {
|
||||
|
||||
private fun record(
|
||||
id: Long,
|
||||
status: QsoStatus = QsoStatus.COMPLETE,
|
||||
uploaded: Boolean = false,
|
||||
confirmed: Boolean = false
|
||||
) = QsoRecord(
|
||||
id = id,
|
||||
startUtcMillis = 1_760_000_000_000L + id,
|
||||
theirCallsign = "BG5JSB",
|
||||
myCallsign = "BA7OPF",
|
||||
mode = "FM",
|
||||
satelliteName = "SO-50",
|
||||
status = status,
|
||||
lotwUploaded = uploaded,
|
||||
lotwConfirmed = confirmed
|
||||
)
|
||||
|
||||
@Test
|
||||
fun keepsUploadedAndConfirmedRecords() {
|
||||
// The whole point of a resubmit: rows already on LoTW must not be filtered out.
|
||||
val records = listOf(record(1, uploaded = true), record(2, confirmed = true), record(3))
|
||||
assertEquals(listOf(1L, 2L, 3L), resubmitCandidates(records, setOf(1, 2, 3)).map { it.id })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun dropsRecordsOutsideTheSelection() {
|
||||
val records = listOf(record(1), record(2), record(3))
|
||||
assertEquals(listOf(2L), resubmitCandidates(records, setOf(2)).map { it.id })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun dropsIncompleteRecords() {
|
||||
val records = listOf(
|
||||
record(1, status = QsoStatus.DRAFT),
|
||||
record(2, status = QsoStatus.ABORTED),
|
||||
record(3, status = QsoStatus.COMPLETE)
|
||||
)
|
||||
assertEquals(listOf(3L), resubmitCandidates(records, setOf(1, 2, 3)).map { it.id })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun emptySelectionYieldsNothing() {
|
||||
assertEquals(emptyList<Long>(), resubmitCandidates(listOf(record(1)), emptySet()).map { it.id })
|
||||
}
|
||||
}
|
||||
+77
@@ -0,0 +1,77 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
package com.rtbishop.look4sat.core.domain.repository
|
||||
|
||||
import com.rtbishop.look4sat.core.domain.logbook.QsoRecord
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
|
||||
class LoTWGridWarningTest {
|
||||
|
||||
private fun record(myGrid: String, vuccGrids: List<String> = emptyList()) = QsoRecord(
|
||||
startUtcMillis = 1_700_000_000_000L,
|
||||
theirCallsign = "BG5JSB",
|
||||
myCallsign = "BA7OPF",
|
||||
myGrid = myGrid,
|
||||
vuccGrids = vuccGrids,
|
||||
mode = "FM",
|
||||
satelliteName = "SO-50",
|
||||
propagationMode = "SAT"
|
||||
)
|
||||
|
||||
@Test
|
||||
fun matchingGridsProduceNoWarning() {
|
||||
assertNull(uploadGridWarning(listOf(record("OM91")), listOf("OM91")))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun recordsLoggedOutsideTheStationAreCounted() {
|
||||
val warning = uploadGridWarning(
|
||||
listOf(record("OM91"), record("OM91"), record("OL62")),
|
||||
listOf("OL62")
|
||||
)
|
||||
assertEquals(2, warning?.count)
|
||||
assertEquals(listOf("OM91"), warning?.recordGrids)
|
||||
assertEquals(listOf("OL62"), warning?.stationGrids)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun boundaryStationCoversItsRecords() {
|
||||
val warning = uploadGridWarning(
|
||||
listOf(record("OL62", listOf("OL61", "OL62"))),
|
||||
listOf("OL61", "OL62", "OM60", "OM61")
|
||||
)
|
||||
assertNull(warning)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun partialOverlapIsNotFlagged() {
|
||||
// The record's set touches the station set — a boundary-bookkeeping difference,
|
||||
// not the roaming signature.
|
||||
assertNull(uploadGridWarning(listOf(record("OL62", listOf("OL62", "OL63"))), listOf("OL62")))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun recordsWithoutGridsOrStationAreNotFlagged() {
|
||||
assertNull(uploadGridWarning(listOf(record("")), listOf("OL62")))
|
||||
assertNull(uploadGridWarning(listOf(record("OM91")), emptyList()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun stampedGridSetIsComparedInsteadOfTheLoggedGrid() {
|
||||
val warning = uploadGridWarning(
|
||||
listOf(record("OL62", listOf("OM91", "OM92", "PM01", "PM02"))),
|
||||
listOf("OL62")
|
||||
)
|
||||
assertEquals(1, warning?.count)
|
||||
assertEquals(listOf("OM91", "OM92", "PM01", "PM02"), warning?.recordGrids)
|
||||
}
|
||||
}
|
||||
+4
-1
@@ -492,6 +492,7 @@ fun SwipeRevealRow(
|
||||
controller: SwipeController,
|
||||
modifier: Modifier = Modifier,
|
||||
revealWidth: Dp = 64.dp,
|
||||
gesturesEnabled: Boolean = true,
|
||||
revealAction: () -> Unit,
|
||||
revealButton: @Composable () -> Unit = {
|
||||
Box(
|
||||
@@ -555,7 +556,9 @@ fun SwipeRevealRow(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.graphicsLayer { translationX = offsetX }
|
||||
.pointerInput(revealPx) {
|
||||
.pointerInput(revealPx, gesturesEnabled) {
|
||||
// Selection mode turns reveals off; rows must not shift under the checkboxes.
|
||||
if (!gesturesEnabled) return@pointerInput
|
||||
detectHorizontalDragGestures(
|
||||
onDragStart = { settleJob?.cancel() },
|
||||
onHorizontalDrag = { change, dragAmount ->
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
/*
|
||||
* 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.util.Locale
|
||||
|
||||
/**
|
||||
* Grid sets abbreviated the way operators read them: a grid is shortened to its
|
||||
* last two characters while its field (first two characters) stays the same as
|
||||
* the previous grid's; a field change is spelled out — "OL61/62" (same field)
|
||||
* but "OM91/PM01" (a shared prefix would read as OM01, the wrong square).
|
||||
* Used for the station grids a QSO was uploaded under (logbook rows and the
|
||||
* radar log page) and for the opposite station's confirmed grid set.
|
||||
*/
|
||||
fun gridsLabel(grids: List<String>): String {
|
||||
val clean = grids.map { it.trim().uppercase(Locale.US).take(4) }
|
||||
.filter { it.length >= 4 }.distinct().sorted()
|
||||
if (clean.isEmpty()) return ""
|
||||
return buildString {
|
||||
append(clean.first())
|
||||
for (index in 1 until clean.size) {
|
||||
val grid = clean[index]
|
||||
if (grid.take(2) == clean[index - 1].take(2)) append('/').append(grid.takeLast(2))
|
||||
else append('/').append(grid)
|
||||
}
|
||||
}
|
||||
}
|
||||
+63
@@ -0,0 +1,63 @@
|
||||
/*
|
||||
* 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 androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.rtbishop.look4sat.core.domain.repository.LoTWGridWarning
|
||||
|
||||
/**
|
||||
* Pre-upload grid check: the prepared batch holds records whose own grids fall outside the
|
||||
* station-location grids they would be signed with — the fingerprint of uploading while
|
||||
* roaming with a station location that was never updated. The operator either jumps to the
|
||||
* station location to fix it, or ignores the warning and proceeds to the normal preview.
|
||||
*/
|
||||
@Composable
|
||||
fun LoTWGridWarningDialog(
|
||||
warning: LoTWGridWarning,
|
||||
onFixStation: () -> Unit,
|
||||
onIgnore: () -> Unit
|
||||
) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onIgnore,
|
||||
shape = sheetDialogShape(),
|
||||
containerColor = sheetDialogContainerColor(),
|
||||
title = { SheetDialogTitle(stringResource(R.string.lotw_upload_grid_title)) },
|
||||
text = {
|
||||
Text(
|
||||
text = stringResource(
|
||||
R.string.lotw_upload_grid_mismatch,
|
||||
warning.count,
|
||||
gridsLabel(warning.recordGrids),
|
||||
gridsLabel(warning.stationGrids)
|
||||
),
|
||||
fontSize = 14.sp
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = onFixStation) { Text(stringResource(R.string.lotw_upload_grid_fix)) }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onIgnore) { Text(stringResource(R.string.lotw_upload_grid_ignore)) }
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -197,6 +197,14 @@
|
||||
<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="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$d 条记录的自己的网格不在上传台址内(记录:%2$s;台址:%3$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>
|
||||
@@ -406,6 +414,9 @@
|
||||
<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>
|
||||
|
||||
@@ -226,6 +226,14 @@
|
||||
<string name="prefs_logbook_upload">Upload</string>
|
||||
<string name="prefs_logbook_upload_title">Upload to LoTW</string>
|
||||
<string name="prefs_logbook_upload_confirm">Upload</string>
|
||||
<string name="prefs_logbook_resubmit">Resubmit (%1$d)</string>
|
||||
<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="lotw_upload_grid_title">Upload check</string>
|
||||
<string name="lotw_upload_grid_fix">Fix station location</string>
|
||||
<string name="lotw_upload_grid_ignore">Ignore</string>
|
||||
<string name="lotw_upload_grid_mismatch">%1$d record(s) log a grid outside the upload station location (records: %2$s; station: %3$s). Verify the station location is where you actually operated.</string>
|
||||
<string name="prefs_lotw_upload_title">LoTW upload certificate</string>
|
||||
<string name="prefs_lotw_upload_configured">Certificate imported · station grid %1$s</string>
|
||||
<string name="prefs_lotw_upload_not_configured">Not configured — import your TrustedQSL certificate to upload QSOs</string>
|
||||
@@ -434,6 +442,9 @@ Upstream: https://github.com/rt-bishop/Look4Sat</string>
|
||||
<string name="qso_edit_comment">Comment</string>
|
||||
<string name="qso_edit_save">Save</string>
|
||||
<string name="qso_edit_cancel">Cancel</string>
|
||||
<string name="log_out_window_title">Outside pass window</string>
|
||||
<string name="log_out_window_message">The current time is outside this pass window. The contact will be logged at the pass midpoint %1$s by default; tap the record afterwards to change the time if needed.</string>
|
||||
<string name="log_out_window_confirm">Log it</string>
|
||||
<string name="qso_edit_err_callsign">Callsign is required</string>
|
||||
<string name="qso_edit_err_datetime">Invalid date/time — use yyyy-MM-dd and HH:mm:ss (UTC)</string>
|
||||
<string name="qso_edit_err_frequency">Invalid frequency — enter MHz, e.g. 145.850</string>
|
||||
|
||||
Reference in new issue
Block a user