From bf65d8d8cf799787303d742db0b2bba02851ab69 Mon Sep 17 00:00:00 2001 From: atsunatsu Date: Sat, 10 Oct 2026 18:42:53 +0800 Subject: [PATCH] =?UTF-8?q?fix(wavelog):=20=E5=8F=AA=E6=8A=8A=E5=B7=B2?= =?UTF-8?q?=E7=A1=AE=E8=AE=A4=E9=80=9A=E8=81=94=E7=AE=97=E8=BF=9B=E7=BD=91?= =?UTF-8?q?=E6=A0=BC=EF=BC=88=E6=9C=AA=E7=A1=AE=E8=AE=A4=E4=BD=86=E6=9C=89?= =?UTF-8?q?=E7=BD=91=E6=A0=BC=E7=9A=84=E4=B8=8D=E7=AE=97=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wavelog 拉取原先把所有带对方网格的卫星通联都算成方格,包括未确认的通联(网格只是 Wavelog 按呼号查出来的)。而 LoTW 侧网格取自 ARRL 确认报告,两侧口径不一致。 现在要求确认:LOTW_QSL_RCVD / QSL_RCVD / EQSL_QSL_RCVD 任一为 Y(= Wavelog 自己 qsl_filter 的三条途径),AdifCodec 补解析后两个字段;roamed 台址不受影响。 存量清理:全量同步改为替换(与 LoTW 全量同口径,台站失败时降级为并入);迟到确认 因 Wavelog 只能按行 id 增量拉取而不可见,增量请求在上次全量重扫超过 3 天后自动升级 为全量。新增 WavelogSyncTest 5 例与 AdifCodecTest 1 例。 --- .../core/data/repository/SettingsRepo.kt | 7 ++ .../core/data/repository/DatabaseRepoTest.kt | 2 + .../repository/SelectionRepoSearchTest.kt | 2 + .../look4sat/core/domain/logbook/AdifCodec.kt | 2 + .../look4sat/core/domain/logbook/QsoModels.kt | 6 + .../core/domain/repository/ISettingsRepo.kt | 5 + .../core/domain/repository/WavelogSync.kt | 58 +++++++-- .../core/domain/logbook/AdifCodecTest.kt | 17 +++ .../domain/repository/LoTWGridSyncTest.kt | 2 + .../core/domain/repository/WavelogSyncTest.kt | 111 +++++++++++++++++- .../rtbishop/look4sat/feature/mutual/Fakes.kt | 2 + .../feature/settings/SettingsViewModel.kt | 28 ++++- 12 files changed, 222 insertions(+), 20 deletions(-) diff --git a/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/SettingsRepo.kt b/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/SettingsRepo.kt index 392a560c..c0f3ba58 100644 --- a/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/SettingsRepo.kt +++ b/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/SettingsRepo.kt @@ -408,6 +408,7 @@ class SettingsRepo( private val keyWavelogSyncCursors = "wavelogSyncCursors" private val keyWavelogSyncUrl = "wavelogSyncUrl" private val keyLastWavelogSyncEpochMs = "wavelogLastSyncEpochMs" + private val keyLastWavelogFullSyncEpochMs = "wavelogLastFullSyncEpochMs" override fun getWavelogStations(): List { val json = preferences.getString(keyWavelogStations, null).orEmpty() @@ -473,6 +474,12 @@ class SettingsRepo( override fun setLastWavelogSyncEpochMs(value: Long) = preferences.edit { putLong(keyLastWavelogSyncEpochMs, value) } + + override fun getLastWavelogFullSyncEpochMs(): Long = + preferences.getLong(keyLastWavelogFullSyncEpochMs, 0L) + + override fun setLastWavelogFullSyncEpochMs(value: Long) = + preferences.edit { putLong(keyLastWavelogFullSyncEpochMs, value) } //endregion //region # Transceivers settings diff --git a/core/data/src/test/java/com/rtbishop/look4sat/core/data/repository/DatabaseRepoTest.kt b/core/data/src/test/java/com/rtbishop/look4sat/core/data/repository/DatabaseRepoTest.kt index 89d4ceb6..9c0ba0c1 100644 --- a/core/data/src/test/java/com/rtbishop/look4sat/core/data/repository/DatabaseRepoTest.kt +++ b/core/data/src/test/java/com/rtbishop/look4sat/core/data/repository/DatabaseRepoTest.kt @@ -486,6 +486,8 @@ private class FakeSettingsRepo(dataSources: DataSourcesSettings = defaultDataSou override fun setWavelogSyncUrl(url: String) = Unit override fun getLastWavelogSyncEpochMs(): Long = 0L override fun setLastWavelogSyncEpochMs(value: Long) = Unit + override fun getLastWavelogFullSyncEpochMs(): Long = 0L + override fun setLastWavelogFullSyncEpochMs(value: Long) = Unit override fun getWorkedGridQsos(): Map> = emptyMap() diff --git a/core/data/src/test/java/com/rtbishop/look4sat/core/data/repository/SelectionRepoSearchTest.kt b/core/data/src/test/java/com/rtbishop/look4sat/core/data/repository/SelectionRepoSearchTest.kt index 3102066b..50101273 100644 --- a/core/data/src/test/java/com/rtbishop/look4sat/core/data/repository/SelectionRepoSearchTest.kt +++ b/core/data/src/test/java/com/rtbishop/look4sat/core/data/repository/SelectionRepoSearchTest.kt @@ -434,4 +434,6 @@ private class FakeSettingsRepoForSearch : ISettingsRepo { override fun setWavelogSyncUrl(url: String) = Unit override fun getLastWavelogSyncEpochMs(): Long = 0L override fun setLastWavelogSyncEpochMs(value: Long) = Unit + override fun getLastWavelogFullSyncEpochMs(): Long = 0L + override fun setLastWavelogFullSyncEpochMs(value: Long) = Unit } diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/logbook/AdifCodec.kt b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/logbook/AdifCodec.kt index fe98d00a..ec87e018 100644 --- a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/logbook/AdifCodec.kt +++ b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/logbook/AdifCodec.kt @@ -117,6 +117,8 @@ object AdifCodec { lotwConfirmed = values["LOTW_QSL_RCVD"].equals("Y", true), lotwReceived = values["LOTW_QSL_SENT"].equals("Y", true), lotwQslDate = values["LOTW_QSLRDATE"].orEmpty(), + qslConfirmed = values["QSL_RCVD"].equals("Y", true), + eqslConfirmed = values["EQSL_QSL_RCVD"].equals("Y", true), dxcc = values["DXCC"]?.toIntOrNull(), country = values["COUNTRY"].orEmpty(), cqZone = values["CQZ"]?.toIntOrNull()?.takeIf { it in 1..40 }, diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/logbook/QsoModels.kt b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/logbook/QsoModels.kt index fc4b763c..1aeb5364 100644 --- a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/logbook/QsoModels.kt +++ b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/logbook/QsoModels.kt @@ -48,6 +48,12 @@ data class QsoRecord( val wavelogStation: String = "", val lotwReceived: Boolean = false, val lotwQslDate: String = "", + /** Paper QSL received (ADIF QSL_RCVD=Y) — one of the routes Wavelog counts as + * "confirmed". Sync-only: the logbook stores no paper-QSL column, so this survives + * only inside the record a Wavelog pull produced (the grid rule reads it there). */ + val qslConfirmed: Boolean = false, + /** eQSL confirmation received (ADIF EQSL_QSL_RCVD=Y). Sync-only, see [qslConfirmed]. */ + val eqslConfirmed: Boolean = false, /** 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 = emptyList(), diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/ISettingsRepo.kt b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/ISettingsRepo.kt index 16063191..dfcf734a 100644 --- a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/ISettingsRepo.kt +++ b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/ISettingsRepo.kt @@ -141,6 +141,11 @@ interface ISettingsRepo { /** Epoch ms of the last successful Wavelog sync (0 when never synced). */ fun getLastWavelogSyncEpochMs(): Long fun setLastWavelogSyncEpochMs(value: Long) + /** Epoch ms of the last FULL Wavelog re-scan (0 when never). An incremental request is + * promoted to a full pull once this is stale — Wavelog's pull filters by row id, so a + * confirmation arriving for an already-pulled row is invisible to the incremental cursor. */ + fun getLastWavelogFullSyncEpochMs(): Long + fun setLastWavelogFullSyncEpochMs(value: Long) //endregion //region # LoTW confirmed-grids settings diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/WavelogSync.kt b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/WavelogSync.kt index 6b107c80..7f24ab72 100644 --- a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/WavelogSync.kt +++ b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/WavelogSync.kt @@ -51,10 +51,39 @@ internal fun isValidGrid4(grid: String): Boolean = grid.length == 4 && grid[0] in 'A'..'R' && grid[1] in 'A'..'R' && grid[2] in '0'..'9' && grid[3] in '0'..'9' +/** + * "Confirmed" in Wavelog's own sense. Its API's `qsl_filter` maps lotw / qsl / eqsl to + * COL_LOTW_QSL_RCVD / COL_QSL_RCVD / COL_EQSL_QSL_RCVD = 'Y', and the ADIF export carries + * those as LOTW_QSL_RCVD / QSL_RCVD / EQSL_QSL_RCVD. + */ +fun QsoRecord.isQslConfirmed(): Boolean = lotwConfirmed || qslConfirmed || eqslConfirmed + +/** + * How stale the last full pull may get before an incremental request is promoted to a full + * one. Wavelog's pull API filters by row id only, so a confirmation that arrives for a row + * pulled earlier (LoTW confirmations land on the same row, keeping its id) can never be seen + * by the incremental cursor — the record was already fetched and is not fetched again. A full + * pull re-reads every row with its current confirmation flags, which is the only way to pick + * such confirmations up; it also rebuilds the stored grid set from scratch, so a square that + * only ever existed because an unconfirmed QSO carried a looked-up grid drops out on its own. + */ +const val WAVELOG_FULL_RESCAN_INTERVAL_MS: Long = 3L * 24 * 60 * 60 * 1000 + +fun shouldPromoteWavelogFullSync( + lastFullSyncEpochMs: Long, + now: Long = System.currentTimeMillis() +): Boolean = lastFullSyncEpochMs <= 0L || now - lastFullSyncEpochMs >= WAVELOG_FULL_RESCAN_INTERVAL_MS + /** * Derives the worked-grid set, the per-grid QSO detail and the roamed (own) grids from * records pulled by a Wavelog sync — mirroring what the LoTW report parser produces, so * both sync sources feed the map's highlight and its 台址 selector the same data shape. + * + * Only CONFIRMED contacts turn a square green: the LoTW side derives its grids from ARRL's + * confirmation report, so a Wavelog record that merely carries the opposite station's grid + * (Wavelog fills those in from a callsign lookup) must not count before it is confirmed. + * The roamed set is the operator's own operating location, not the opposite station's + * confirmation, so it keeps taking every satellite record with a valid own grid. */ fun buildWavelogGridData(records: List): WavelogGridData { val grids = linkedSetOf() @@ -73,6 +102,7 @@ fun buildWavelogGridData(records: List): WavelogGridData { .filter(::isValidGrid4) .toSet() roamed += myGrids + if (!record.isQslConfirmed()) return@forEach val qso = GridQso( call = record.theirCallsign, epochMs = record.startUtcMillis, @@ -100,14 +130,16 @@ fun buildWavelogGridData(records: List): WavelogGridData { /** * Applies a successful Wavelog sync to the stored map data and sync bookkeeping. * - * Grids, per-grid QSO detail and roamed grids only ever GROW (union): both the - * incremental and the full re-pull merge into the stored sets, matching how the LoTW - * sync treats stale data — a full re-pull heals missing records but never deletes map - * data the app already knows about. Cursors are replaced wholesale ([cursors] covers - * every station just fetched), and the sync URL/epoch advance so the next run resumes - * incrementally. Persistence runs on the IO dispatcher — building the per-grid QSO - * JSON blocks the caller, and for large accounts that froze the main thread after a - * manual sync. + * An incremental pull MERGES into the stored sets (union: a late confirmation only ever + * adds a square). A full pull REPLACES them — same contract as the LoTW full sync — so the + * squares left behind by unconfirmed records that used to be counted disappear, and so do + * rows the log no longer has. A full pull that lost a station is partial: it degrades to + * the merge instead of wiping the squares that station contributed. + * + * Cursors are replaced wholesale ([cursors] covers every station just fetched), and the + * sync URL/epoch advance so the next run resumes incrementally. Persistence runs on the IO + * dispatcher — building the per-grid QSO JSON blocks the caller, and for large accounts that + * froze the main thread after a manual sync. * * @return the resulting number of worked grids. */ @@ -116,18 +148,22 @@ suspend fun applyWavelogSyncResult( records: List, cursors: Map, url: String, + mode: WavelogSyncMode, + failedStations: Set = emptySet(), now: Long = System.currentTimeMillis() ): Int { val data = buildWavelogGridData(records) + val replace = mode == WavelogSyncMode.Full && failedStations.isEmpty() val existingGrids = settingsRepo.getWorkedGrids() val existingQsos = settingsRepo.getWorkedGridQsos() val existingRoamed = settingsRepo.getRoamedGrids() - val mergedGrids = existingGrids + data.grids - val mergedQsos = mergeGridQsos(existingQsos, data.gridQsos) - val mergedRoamed = existingRoamed + data.roamedGrids + val mergedGrids = if (replace) data.grids else existingGrids + data.grids + val mergedQsos = if (replace) data.gridQsos else mergeGridQsos(existingQsos, data.gridQsos) + val mergedRoamed = if (replace) data.roamedGrids else existingRoamed + data.roamedGrids settingsRepo.setWavelogSyncCursors(cursors) settingsRepo.setWavelogSyncUrl(normalizeWavelogUrl(url)) settingsRepo.setLastWavelogSyncEpochMs(now) + if (replace) settingsRepo.setLastWavelogFullSyncEpochMs(now) withContext(Dispatchers.IO) { settingsRepo.setWorkedGrids(mergedGrids) settingsRepo.setWorkedGridQsos(mergedQsos) diff --git a/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/logbook/AdifCodecTest.kt b/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/logbook/AdifCodecTest.kt index 2a0941f8..f7f359f3 100644 --- a/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/logbook/AdifCodecTest.kt +++ b/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/logbook/AdifCodecTest.kt @@ -9,6 +9,7 @@ */ package com.rtbishop.look4sat.core.domain.logbook +import com.rtbishop.look4sat.core.domain.repository.isQslConfirmed import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue @@ -124,6 +125,22 @@ class AdifCodecTest { assertTrue(adif.contains("SO-50")) } + @Test + fun decodeReadsEveryConfirmationRouteWavelogCounts() { + // Wavelog's qsl_filter treats lotw / qsl / eqsl alike; the pull must see all three, + // and an unconfirmed record must stay unconfirmed. + val adi = "BG5JVM202609160745Y" + + "BG5JVN202609160746Y" + + "BG5JVO202609160747Y" + + "BG5JVP202609160748" + val records = AdifCodec.decode(adi) + assertTrue(records[0].isQslConfirmed()) + assertTrue(records[1].isQslConfirmed()) + assertTrue(records[2].isQslConfirmed()) + assertTrue(!records[3].isQslConfirmed()) + assertTrue(records[3].lotwConfirmed.not() && records[3].qslConfirmed.not() && records[3].eqslConfirmed.not()) + } + @Test fun encodeKeepsOwnGridsOutOfTheOppositeVuccField() { // The reported defect: an operator's stamped own grid set must never land in diff --git a/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/repository/LoTWGridSyncTest.kt b/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/repository/LoTWGridSyncTest.kt index 98703884..ed13aed0 100644 --- a/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/repository/LoTWGridSyncTest.kt +++ b/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/repository/LoTWGridSyncTest.kt @@ -284,6 +284,8 @@ class LoTWGridSyncTest { override fun setWavelogSyncUrl(url: String) { wavelogSyncUrl = url } override fun getLastWavelogSyncEpochMs(): Long = lastWavelogSyncEpochMs override fun setLastWavelogSyncEpochMs(value: Long) { lastWavelogSyncEpochMs = value } + override fun getLastWavelogFullSyncEpochMs(): Long = 0L + override fun setLastWavelogFullSyncEpochMs(value: Long) = Unit override fun setSelectedIds(ids: List) = TODO() override fun setSelectedTypes(types: List) = TODO() diff --git a/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/repository/WavelogSyncTest.kt b/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/repository/WavelogSyncTest.kt index d83d6f61..9514476d 100644 --- a/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/repository/WavelogSyncTest.kt +++ b/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/repository/WavelogSyncTest.kt @@ -35,6 +35,7 @@ import com.rtbishop.look4sat.core.domain.predict.GeoPos import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.runBlocking import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Test @@ -124,6 +125,35 @@ class WavelogSyncTest { assertEquals(listOf("EN52"), qso.theirGrids) } + @Test + fun gridDataSkipsUnconfirmedContactsButKeepsTheRoamedGrid() { + val data = buildWavelogGridData( + listOf( + record(start = 1_000L, theirGrid = "EN52", myGrid = "OL62", confirmed = false), + record(start = 2_000L, theirGrid = "EN53", myGrid = "OL63", confirmed = false) + ) + ) + // Wavelog fills the opposite station's grid in from a callsign lookup; an unconfirmed + // contact must not turn a square green even though the grid is there. + assertTrue(data.grids.isEmpty()) + assertTrue(data.gridQsos.isEmpty()) + // Where the operator was is not something the opposite station confirms. + assertEquals(setOf("OL62", "OL63"), data.roamedGrids) + } + + @Test + fun gridDataAcceptsPaperAndEqslConfirmations() { + val data = buildWavelogGridData( + listOf( + record(start = 1_000L, theirGrid = "EN52", confirmed = false, paper = true), + record(start = 2_000L, theirGrid = "EN53", confirmed = false, eqsl = true) + ) + ) + assertEquals(setOf("EN52", "EN53"), data.grids) + assertEquals(1, data.gridQsos.getValue("EN52").size) + assertEquals(1, data.gridQsos.getValue("EN53").size) + } + // endregion // region # applyWavelogSyncResult @@ -138,6 +168,7 @@ class WavelogSyncTest { listOf(record(start = 1_000L, theirGrid = "EN52", myGrid = "OL62")), mapOf("1" to 500L), "http://a/wavelog/", + WavelogSyncMode.Incremental, now = 42L ) @@ -147,6 +178,7 @@ class WavelogSyncTest { assertEquals(mapOf("1" to 500L), settings.getWavelogSyncCursors()) assertEquals("http://a/wavelog", settings.getWavelogSyncUrl()) assertEquals(42L, settings.getLastWavelogSyncEpochMs()) + assertEquals(0L, settings.getLastWavelogFullSyncEpochMs()) assertEquals(2, count) } @@ -156,14 +188,76 @@ class WavelogSyncTest { settings.setWorkedGrids(setOf("PM01")) settings.setRoamedGrids(setOf("OM60")) - applyWavelogSyncResult(settings, listOf(record(1_000L, "EN52", myGrid = "OL62")), mapOf("1" to 10L), "http://a") - applyWavelogSyncResult(settings, listOf(record(2_000L, "EN53", myGrid = "OL63")), mapOf("1" to 20L), "http://a") + applyWavelogSyncResult( + settings, listOf(record(1_000L, "EN52", myGrid = "OL62")), mapOf("1" to 10L), "http://a", + WavelogSyncMode.Incremental + ) + applyWavelogSyncResult( + settings, listOf(record(2_000L, "EN53", myGrid = "OL63")), mapOf("1" to 20L), "http://a", + WavelogSyncMode.Incremental + ) assertEquals(setOf("PM01", "EN52", "EN53"), settings.getWorkedGrids()) assertEquals(setOf("OM60", "OL62", "OL63"), settings.getRoamedGrids()) assertEquals(mapOf("1" to 20L), settings.getWavelogSyncCursors()) } + @Test + fun fullSyncReplacesTheStoredSets() = runBlocking { + val settings = FakeSettings() + // A square kept from before the confirmation rule existed, plus one that is real now. + settings.setWorkedGrids(setOf("PM01", "EN52")) + settings.setWorkedGridQsos(mapOf("PM01" to emptyList())) + settings.setRoamedGrids(setOf("OM60", "OL62")) + + val count = applyWavelogSyncResult( + settings, + listOf(record(1_000L, "EN52", myGrid = "OL62")), + mapOf("1" to 10L), + "http://a", + WavelogSyncMode.Full, + now = 99L + ) + + assertEquals(setOf("EN52"), settings.getWorkedGrids()) + assertEquals(setOf("EN52"), settings.getWorkedGridQsos().keys) + assertEquals(setOf("OL62"), settings.getRoamedGrids()) + assertEquals(99L, settings.getLastWavelogFullSyncEpochMs()) + assertEquals(1, count) + } + + @Test + fun fullSyncWithAFailedStationDegradesToMerge() = runBlocking { + val settings = FakeSettings() + settings.setWorkedGrids(setOf("PM01")) + + applyWavelogSyncResult( + settings, + listOf(record(1_000L, "EN52")), + mapOf("1" to 10L), + "http://a", + WavelogSyncMode.Full, + failedStations = setOf("2"), + now = 99L + ) + + // Partial pull: never wipe what the station that failed contributed. + assertEquals(setOf("PM01", "EN52"), settings.getWorkedGrids()) + assertEquals(0L, settings.getLastWavelogFullSyncEpochMs()) + } + + @Test + fun incrementalRequestIsPromotedOnceTheLastFullScanIsStale() { + val day = 24L * 60 * 60 * 1000 + assertTrue(shouldPromoteWavelogFullSync(lastFullSyncEpochMs = 0L, now = day)) + assertTrue(shouldPromoteWavelogFullSync(lastFullSyncEpochMs = 0L, now = 1000L * day)) + assertEquals( + false, + shouldPromoteWavelogFullSync(lastFullSyncEpochMs = 10 * day, now = 12 * day) + ) + assertTrue(shouldPromoteWavelogFullSync(lastFullSyncEpochMs = 10 * day, now = 13 * day)) + } + // endregion private fun record( @@ -171,7 +265,10 @@ class WavelogSyncTest { theirGrid: String = "", theirVucc: List = emptyList(), myGrid: String = "OL62TI", - sat: String = "SO-50" + sat: String = "SO-50", + confirmed: Boolean = true, + paper: Boolean = false, + eqsl: Boolean = false ) = QsoRecord( startUtcMillis = start, theirCallsign = "BG5JVM", @@ -186,7 +283,10 @@ class WavelogSyncTest { mode = "FM", satelliteName = sat, propagationMode = if (sat.isNotBlank()) "SAT" else "", - status = QsoStatus.COMPLETE + status = QsoStatus.COMPLETE, + lotwConfirmed = confirmed, + qslConfirmed = paper, + eqslConfirmed = eqsl ) private class FakeSettings : ISettingsRepo { @@ -196,6 +296,7 @@ class WavelogSyncTest { private var cursors: Map = emptyMap() private var syncUrl: String = "" private var lastSync: Long = 0L + private var lastFullSync: Long = 0L override fun getWorkedGrids(): Set = workedGrids override fun setWorkedGrids(grids: Set) { workedGrids = grids } @@ -209,6 +310,8 @@ class WavelogSyncTest { override fun setWavelogSyncUrl(url: String) { syncUrl = url } override fun getLastWavelogSyncEpochMs(): Long = lastSync override fun setLastWavelogSyncEpochMs(value: Long) { lastSync = value } + override fun getLastWavelogFullSyncEpochMs(): Long = lastFullSync + override fun setLastWavelogFullSyncEpochMs(value: Long) { lastFullSync = value } override val appVersionName: String get() = TODO() override val selectedIds: StateFlow> get() = TODO() diff --git a/feature/mutual/src/test/java/com/rtbishop/look4sat/feature/mutual/Fakes.kt b/feature/mutual/src/test/java/com/rtbishop/look4sat/feature/mutual/Fakes.kt index a8284f31..60aaaea1 100644 --- a/feature/mutual/src/test/java/com/rtbishop/look4sat/feature/mutual/Fakes.kt +++ b/feature/mutual/src/test/java/com/rtbishop/look4sat/feature/mutual/Fakes.kt @@ -159,4 +159,6 @@ class FakeSettingsRepo( override fun setWavelogSyncUrl(url: String) = Unit override fun getLastWavelogSyncEpochMs(): Long = 0L override fun setLastWavelogSyncEpochMs(value: Long) = Unit + override fun getLastWavelogFullSyncEpochMs(): Long = 0L + override fun setLastWavelogFullSyncEpochMs(value: Long) = Unit } \ No newline at end of file diff --git a/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsViewModel.kt b/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsViewModel.kt index db91c155..578957de 100644 --- a/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsViewModel.kt +++ b/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsViewModel.kt @@ -41,6 +41,7 @@ import com.rtbishop.look4sat.core.domain.repository.WavelogSyncFetch import com.rtbishop.look4sat.core.domain.repository.WavelogSyncMode import com.rtbishop.look4sat.core.domain.repository.applyWavelogSyncResult import com.rtbishop.look4sat.core.domain.repository.resolveWavelogSyncMode +import com.rtbishop.look4sat.core.domain.repository.shouldPromoteWavelogFullSync import com.rtbishop.look4sat.core.domain.repository.WavelogStationInfo import com.rtbishop.look4sat.core.domain.repository.WavelogUploadOutcome import com.rtbishop.look4sat.core.domain.repository.WavelogUploadPreview @@ -390,6 +391,11 @@ class SettingsViewModel( return } val mode = resolveWavelogSyncMode(settingsRepo.getWavelogSyncUrl(), settings.url, requested) + // Late confirmations can only be picked up by a full pull (Wavelog's incremental + // cursor is a row id), so promote a stale incremental request to a full re-scan. + val promoted = mode == WavelogSyncMode.Incremental && + shouldPromoteWavelogFullSync(settingsRepo.getLastWavelogFullSyncEpochMs()) + val actualMode = if (promoted) WavelogSyncMode.Full else mode _uiState.update { it.copy(wavelogSyncing = true, wavelogUploadMessage = null) } viewModelScope.launch { try { @@ -398,7 +404,7 @@ class SettingsViewModel( settings.apiKey, stations, settingsRepo.getWavelogSyncCursors(), - full = mode == WavelogSyncMode.Full + full = actualMode == WavelogSyncMode.Full ) if (fetch == null) { _uiState.update { @@ -413,13 +419,20 @@ class SettingsViewModel( } // Stations that failed keep their previous cursor; fetched ones advance. val cursors = settingsRepo.getWavelogSyncCursors() + fetch.cursors - applyWavelogSyncResult(settingsRepo, fetch.records, cursors, settings.url) + applyWavelogSyncResult( + settingsRepo, + fetch.records, + cursors, + settings.url, + actualMode, + failedStations = fetch.failedStations.toSet() + ) _uiState.update { it.copy( wavelogSyncing = false, workedGridsCount = settingsRepo.getWorkedGrids().size, wavelogLastSyncEpochMs = settingsRepo.getLastWavelogSyncEpochMs(), - wavelogUploadMessage = wavelogSyncSummary(mode, fetch, merge, stations.size) + wavelogUploadMessage = wavelogSyncSummary(actualMode, fetch, merge, stations.size, promoted) ) } } catch (e: kotlinx.coroutines.CancellationException) { @@ -436,9 +449,14 @@ class SettingsViewModel( mode: WavelogSyncMode, fetch: WavelogSyncFetch, merge: com.rtbishop.look4sat.core.domain.logbook.AdifImportResult, - totalStations: Int + totalStations: Int, + promoted: Boolean = false ): String { - val head = if (mode == WavelogSyncMode.Full) "Full Wavelog sync" else "Incremental Wavelog sync" + val head = when { + promoted -> "Full Wavelog sync (auto re-scan)" + mode == WavelogSyncMode.Full -> "Full Wavelog sync" + else -> "Incremental Wavelog sync" + } val base = "$head — ${merge.imported} new, ${merge.skipped} already there" return if (fetch.failedStations.isEmpty()) base else "$base · ${fetch.failedStations.size}/$totalStations station(s) failed"