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:
atsunatsu committed 2026-10-02 12:01:57 +08:00
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()
@@ -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,
@@ -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)
@@ -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,
@@ -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>"))
@@ -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,