From 059c816075452e904226425ba40cdbb679946f49 Mon Sep 17 00:00:00 2001 From: atsunatsu Date: Thu, 17 Sep 2026 10:13:52 +0800 Subject: [PATCH] feat(lotw): show last sync time on settings, precise incremental cursor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- .../com/rtbishop/look4sat/MainApplication.kt | 3 +- .../core/domain/repository/LoTWGridSync.kt | 50 +++++++++++++++++-- .../domain/repository/LoTWGridSyncTest.kt | 45 ++++++++++++++--- .../feature/settings/SettingsScreen.kt | 10 ++++ .../feature/settings/SettingsState.kt | 2 + .../feature/settings/SettingsViewModel.kt | 10 ++-- 6 files changed, 105 insertions(+), 15 deletions(-) diff --git a/app/src/main/java/com/rtbishop/look4sat/MainApplication.kt b/app/src/main/java/com/rtbishop/look4sat/MainApplication.kt index 8819c22e..7ecf1182 100644 --- a/app/src/main/java/com/rtbishop/look4sat/MainApplication.kt +++ b/app/src/main/java/com/rtbishop/look4sat/MainApplication.kt @@ -27,6 +27,7 @@ import com.rtbishop.look4sat.core.domain.repository.IMainContainer import com.rtbishop.look4sat.core.domain.repository.LoTWSyncMode import com.rtbishop.look4sat.core.domain.repository.LoTWResult import com.rtbishop.look4sat.core.domain.repository.applyLoTWGridResult +import com.rtbishop.look4sat.core.domain.repository.lotwCursorApi import com.rtbishop.look4sat.core.domain.repository.lotwSyncToday import com.rtbishop.look4sat.core.domain.repository.resolveLoTWSyncMode import com.rtbishop.look4sat.core.domain.repository.shouldAutoSyncLoTW @@ -110,7 +111,7 @@ class MainApplication : Application(), IContainerProvider { ) return val callsign = lotwSettings.callsign.trim().uppercase() val mode = resolveLoTWSyncMode(settingsRepo.getLastLotwSyncCallsign(), callsign, requested = null) - val since = if (mode == LoTWSyncMode.Incremental) settingsRepo.getLastLotwSyncDate() else "" + val since = if (mode == LoTWSyncMode.Incremental) lotwCursorApi(settingsRepo.getLastLotwSyncDate()) else "" println("Started periodic LoTW grid sync (${mode.name.lowercase()})") val result = container.lotwRepo.fetchConfirmedGridQsos(callsign, lotwSettings.password, since) if (result is LoTWResult.Success) { diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/LoTWGridSync.kt b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/LoTWGridSync.kt index 75821db8..aa2dbb90 100644 --- a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/LoTWGridSync.kt +++ b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/LoTWGridSync.kt @@ -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) 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 ce0a7481..b64fda82 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 @@ -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()) } diff --git a/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsScreen.kt b/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsScreen.kt index 46389862..f7fb9872 100644 --- a/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsScreen.kt +++ b/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsScreen.kt @@ -338,6 +338,7 @@ private fun SettingsScreen(uiState: SettingsState, onAction: (SettingsAction) -> LoTWCard( settings = uiState.lotwSettings, workedGridsCount = uiState.workedGridsCount, + lastSyncEpochMs = uiState.lotwLastSyncEpochMs, showLoTWDialog = { dialogs.lotw = true } ) } @@ -582,6 +583,7 @@ private fun SwitchRow(labelResId: Int, checked: Boolean, onCheckedChange: (Boole private fun LoTWCard( settings: com.rtbishop.look4sat.core.domain.model.LoTWSettings, workedGridsCount: Int, + lastSyncEpochMs: Long, showLoTWDialog: () -> Unit ) { ElevatedCard(modifier = Modifier.fillMaxWidth()) { @@ -600,6 +602,14 @@ private fun LoTWCard( style = MaterialTheme.typography.bodySmall, maxLines = 2 ) + // Last successful sync, shown exactly like the ephemeris update time. + if (lastSyncEpochMs != 0L) { + Spacer(modifier = Modifier.height(2.dp)) + Text( + text = formatUpdateTime(updateTime = lastSyncEpochMs), + style = MaterialTheme.typography.bodySmall + ) + } Spacer(modifier = Modifier.height(4.dp)) CardButton( onClick = showLoTWDialog, diff --git a/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsState.kt b/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsState.kt index 0c383ddc..818e4334 100644 --- a/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsState.kt +++ b/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsState.kt @@ -65,6 +65,8 @@ data class SettingsState( val lotwSyncMode: LoTWSyncMode? = null, val lotwProgress: com.rtbishop.look4sat.core.domain.repository.LoTWProgress? = null, val lotwError: LoTWError? = null, + /** Epoch ms of the last successful LoTW sync (0 = never) — shown like the ephemeris update time. */ + val lotwLastSyncEpochMs: Long = 0L, val updateChecker: UpdateCheckerState = UpdateCheckerState() ) 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 32df50ea..fa8dc721 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 @@ -30,6 +30,8 @@ import com.rtbishop.look4sat.core.domain.repository.IUpdateRepository import com.rtbishop.look4sat.core.domain.repository.LoTWResult import com.rtbishop.look4sat.core.domain.repository.LoTWSyncMode import com.rtbishop.look4sat.core.domain.repository.applyLoTWGridResult +import com.rtbishop.look4sat.core.domain.repository.lotwCursorApi +import com.rtbishop.look4sat.core.domain.repository.lotwCursorEpochMs import com.rtbishop.look4sat.core.domain.repository.resolveLoTWSyncMode import com.rtbishop.look4sat.core.domain.repository.IWavelogRepository import com.rtbishop.look4sat.core.domain.usecase.IShowToast @@ -66,7 +68,8 @@ class SettingsViewModel( dataSourcesSettings = settingsRepo.dataSourcesSettings.value, dataSourcesStatus = settingsRepo.dataSourcesStatus.value, wavelogSettings = settingsRepo.wavelogSettings.value, - workedGridsCount = settingsRepo.getWorkedGrids().size + workedGridsCount = settingsRepo.getWorkedGrids().size, + lotwLastSyncEpochMs = lotwCursorEpochMs(settingsRepo.getLastLotwSyncDate()) ) ) @@ -215,7 +218,7 @@ class SettingsViewModel( val callsign = settings.callsign.trim().uppercase() val effectiveMode = resolveLoTWSyncMode(settingsRepo.getLastLotwSyncCallsign(), callsign, mode) val since = if (effectiveMode == LoTWSyncMode.Incremental) { - settingsRepo.getLastLotwSyncDate() + lotwCursorApi(settingsRepo.getLastLotwSyncDate()) } else "" _uiState.update { it.copy(lotwSyncing = true, lotwSyncMode = effectiveMode, lotwProgress = null, lotwError = null) @@ -233,7 +236,8 @@ class SettingsViewModel( _uiState.update { state -> state.copy( lotwSyncing = false, lotwSyncMode = null, lotwProgress = null, - workedGridsCount = mergedGridsCount, lotwError = null + workedGridsCount = mergedGridsCount, lotwError = null, + lotwLastSyncEpochMs = lotwCursorEpochMs(settingsRepo.getLastLotwSyncDate()) ) } }