feat(lotw): show last sync time on settings, precise incremental cursor

The stored sync cursor was 'yyyyMMdd', which LoTW's qso_qslsince silently
ignores (measured live: it falls back to the system-supplied default).
Write a full UTC 'yyyy-MM-dd HH:mm:ss' cursor instead — the documented
format LoTW honors — so incremental pulls are exact (second precision)
and the settings page can show the last successful sync time on the LoTW
card, formatted like the ephemeris update time (prefs_updated_title).
Legacy yyyyMMdd cursors are normalized for the API, the gate and the
display (lotwCursorApi/lotwCursorDate/lotwCursorEpochMs).
This commit is contained in:
atsunatsu committed 2026-09-17 10:13:52 +08:00
1 parent 60dfa5d600
commit 059c816075
6 files changed
+105 -15

No files matched your search

@@ -36,12 +36,53 @@ import java.util.TimeZone
* - [LoTWSyncMode.Full] pulls everything and REPLACES the stored data.
*/
/** UTC date ("yyyyMMdd") of the given instant — the LoTW sync cursor granularity. */
/**
* UTC date ("yyyyMMdd") of the given instant — the "already synced today"
* gate granularity.
*/
fun lotwSyncToday(now: Long = System.currentTimeMillis()): String =
SimpleDateFormat("yyyyMMdd", Locale.US)
.apply { timeZone = TimeZone.getTimeZone("UTC") }
.format(Date(now))
/**
* Full UTC timestamp cursor ("yyyy-MM-dd HH:mm:ss") written after a
* successful sync. This is exactly the format LoTW's qso_qslsince accepts
* (measured live 2026-09-17: the legacy "yyyyMMdd" cursor is silently
* ignored by LoTW, which falls back to its own system-supplied default),
* and it keeps enough precision to display the last-sync time like the
* ephemeris update time.
*/
fun lotwSyncCursor(now: Long = System.currentTimeMillis()): String =
SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US)
.apply { timeZone = TimeZone.getTimeZone("UTC") }
.format(Date(now))
/** Date part of a stored cursor as "yyyyMMdd", tolerating the legacy
* "yyyyMMdd" format and the current "yyyy-MM-dd HH:mm:ss" format. */
fun lotwCursorDate(cursor: String): String = when {
cursor.length >= 10 && cursor[4] == '-' -> cursor.substring(0, 10).replace("-", "")
else -> cursor.take(8)
}
/** qso_qslsince value for the API: passes the full timestamp through and
* normalizes a legacy "yyyyMMdd" cursor to the documented "yyyy-MM-dd". */
fun lotwCursorApi(cursor: String): String = when {
cursor.isBlank() -> ""
cursor.length >= 10 && cursor[4] == '-' -> cursor
else -> "${cursor.take(4)}-${cursor.substring(4, 6)}-${cursor.substring(6, 8)}"
}
/** Stored cursor parsed to UTC epoch ms for display; 0 when unparseable. */
fun lotwCursorEpochMs(cursor: String): Long = try {
val pattern = if (cursor.length >= 10 && cursor[4] == '-') "yyyy-MM-dd HH:mm:ss" else "yyyyMMdd"
SimpleDateFormat(pattern, Locale.US)
.apply { timeZone = TimeZone.getTimeZone("UTC") }
.parse(cursor)?.time ?: 0L
} catch (_: Exception) {
0L
}
/**
* Resolves the effective sync mode for a given request:
* - an explicit [LoTWSyncMode.Full] request is always honored;
@@ -73,7 +114,10 @@ fun shouldAutoSyncLoTW(
autoLotwSyncEnabled: Boolean,
lastSyncDate: String,
today: String
): Boolean = isConfigured && autoLotwSyncEnabled && lastSyncDate.isNotBlank() && lastSyncDate != today
): Boolean {
val lastDate = lotwCursorDate(lastSyncDate)
return isConfigured && autoLotwSyncEnabled && lastDate.isNotBlank() && lastDate != today
}
/**
* Applies a successful LoTW report to the stored grid data and advances the
@@ -98,7 +142,7 @@ suspend fun applyLoTWGridResult(
val mergedGrids = if (mode == LoTWSyncMode.Incremental) existingGrids + result.grids else result.grids
val mergedQsos = if (mode == LoTWSyncMode.Incremental) mergeGridQsos(existingQsos, result.qsos) else result.qsos
val mergedRoamed = if (mode == LoTWSyncMode.Incremental) existingRoamed + result.roamedGrids else result.roamedGrids
settingsRepo.setLastLotwSyncDate(lotwSyncToday(now))
settingsRepo.setLastLotwSyncDate(lotwSyncCursor(now))
settingsRepo.setLastLotwSyncCallsign(callsign)
withContext(Dispatchers.IO) {
settingsRepo.setWorkedGrids(mergedGrids)
@@ -79,15 +79,44 @@ class LoTWGridSyncTest {
fun autoSyncGateRequiresEverything() {
val today = "20260916"
// Not configured.
assertFalse(shouldAutoSyncLoTW(false, true, "20260915", today))
// Auto-update toggle off.
assertFalse(shouldAutoSyncLoTW(true, false, "20260915", today))
assertFalse(shouldAutoSyncLoTW(false, true, "2026-09-15 10:00:00", today))
// LoTW auto-sync toggle off.
assertFalse(shouldAutoSyncLoTW(true, false, "2026-09-15 10:00:00", today))
// Never synced manually — first sync stays manual/full.
assertFalse(shouldAutoSyncLoTW(true, true, "", today))
// Already synced today — ARRL rate-limit guard.
assertFalse(shouldAutoSyncLoTW(true, true, today, today))
// All gates open.
// Already synced today (both legacy and current cursor formats) — ARRL rate-limit guard.
assertFalse(shouldAutoSyncLoTW(true, true, "20260916", today))
assertFalse(shouldAutoSyncLoTW(true, true, "2026-09-16 08:00:00", today))
// All gates open — yesterday in either format.
assertTrue(shouldAutoSyncLoTW(true, true, "20260915", today))
assertTrue(shouldAutoSyncLoTW(true, true, "2026-09-15 10:00:00", today))
}
// endregion
// region cursor helpers
@Test
fun cursorApiNormalizesLegacyAndPassesFullThrough() {
assertEquals("2026-09-16", lotwCursorApi("20260916"))
assertEquals("2026-09-16 08:16:02", lotwCursorApi("2026-09-16 08:16:02"))
assertEquals("", lotwCursorApi(""))
}
@Test
fun cursorDateToleratesBothFormats() {
assertEquals("20260916", lotwCursorDate("20260916"))
assertEquals("20260916", lotwCursorDate("2026-09-16 08:16:02"))
assertEquals("", lotwCursorDate(""))
}
@Test
fun cursorEpochMsParsesBothFormatsUtc() {
// 2026-09-16T00:00:00Z in both spellings.
assertEquals(now, lotwCursorEpochMs("20260916"))
assertEquals(now, lotwCursorEpochMs("2026-09-16 00:00:00"))
assertEquals(0L, lotwCursorEpochMs(""))
assertEquals(0L, lotwCursorEpochMs("garbage"))
}
// endregion
@@ -148,7 +177,7 @@ class LoTWGridSyncTest {
assertEquals(setOf("OL62", "PM95"), repo.getWorkedGrids())
assertEquals(1, repo.getWorkedGridQsos()["OL62"]?.size) // deduped
assertEquals(setOf("OL62", "PM95"), repo.getRoamedGrids())
assertEquals("20260916", repo.getLastLotwSyncDate())
assertEquals("2026-09-16 00:00:00", repo.getLastLotwSyncDate())
assertEquals("BA7OPF", repo.getLastLotwSyncCallsign())
}
@@ -172,7 +201,7 @@ class LoTWGridSyncTest {
assertEquals(setOf("PM95"), repo.getWorkedGrids())
assertEquals(setOf("PM95"), repo.getWorkedGridQsos().keys)
assertEquals(setOf("PM95"), repo.getRoamedGrids())
assertEquals("20260916", repo.getLastLotwSyncDate())
assertEquals("2026-09-16 00:00:00", repo.getLastLotwSyncDate())
assertEquals("BA7OPF", repo.getLastLotwSyncCallsign())
}