diff --git a/app/src/main/java/com/rtbishop/look4sat/MainApplication.kt b/app/src/main/java/com/rtbishop/look4sat/MainApplication.kt
index d36445ef..8819c22e 100644
--- a/app/src/main/java/com/rtbishop/look4sat/MainApplication.kt
+++ b/app/src/main/java/com/rtbishop/look4sat/MainApplication.kt
@@ -24,6 +24,12 @@ import android.os.Bundle
import com.rtbishop.look4sat.core.data.injection.MainContainer
import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
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.lotwSyncToday
+import com.rtbishop.look4sat.core.domain.repository.resolveLoTWSyncMode
+import com.rtbishop.look4sat.core.domain.repository.shouldAutoSyncLoTW
import kotlinx.coroutines.launch
import java.text.SimpleDateFormat
import java.util.Date
@@ -41,6 +47,8 @@ class MainApplication : Application(), IContainerProvider {
clearAmSatCacheOnAppBackground()
// trigger automatic update every 48 hours
container.appScope.launch { checkAutoUpdate() }
+ // automatic LoTW grid sync on every app start (gated, see checkLoTWAutoSync)
+ container.appScope.launch { checkLoTWAutoSync() }
// load satellite data on every app start
container.appScope.launch { container.satelliteRepo.initRepository() }
}
@@ -79,4 +87,37 @@ class MainApplication : Application(), IContainerProvider {
}
}
}
+
+ /**
+ * Automatic LoTW grid sync, mirroring [checkAutoUpdate]: checked on every
+ * app start, but only pulls when due — LoTW credentials configured, the
+ * LoTW auto-sync toggle on, at least one prior (manual) sync, and not
+ * already synced today (ARRL limits report pulls, ~once a day per account).
+ * Incremental when the callsign is unchanged; a callsign change falls back
+ * to a full replace so no stale grids from another account linger.
+ * Failures are silent and simply retried on the next app start — the manual
+ * sync button still surfaces the explicit cause to the user.
+ */
+ private suspend fun checkLoTWAutoSync(timeNow: Long = System.currentTimeMillis()) {
+ val settingsRepo = container.settingsRepo
+ val lotwSettings = settingsRepo.lotwSettings.value
+ if (!shouldAutoSyncLoTW(
+ isConfigured = lotwSettings.isConfigured,
+ autoLotwSyncEnabled = settingsRepo.otherSettings.value.stateOfAutoLotwSync,
+ lastSyncDate = settingsRepo.getLastLotwSyncDate(),
+ today = lotwSyncToday(timeNow)
+ )
+ ) return
+ val callsign = lotwSettings.callsign.trim().uppercase()
+ val mode = resolveLoTWSyncMode(settingsRepo.getLastLotwSyncCallsign(), callsign, requested = null)
+ val since = if (mode == LoTWSyncMode.Incremental) 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) {
+ val count = applyLoTWGridResult(settingsRepo, result, mode, callsign, timeNow)
+ println("Periodic LoTW grid sync finished: $count worked grids")
+ } else {
+ println("Periodic LoTW grid sync skipped (${result::class.simpleName})")
+ }
+ }
}
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 0061f2a5..6bfe79e8 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
@@ -77,6 +77,7 @@ class SettingsRepo(
private val keySelectedTypes = "selectedTypes"
private val keySelectedModes = "selectedModes"
private val keyStateOfAutoUpdate = "stateOfAutoUpdate"
+ private val keyStateOfAutoLotwSync = "stateOfAutoLotwSync"
private val keyStateOfSensors = "stateOfSensors"
private val keyStateOfSweep = "stateOfSweep"
private val keyStateOfUtc = "stateOfUtc"
@@ -538,6 +539,7 @@ class SettingsRepo(
val new = transform(current)
preferences.edit {
putBoolean(keyStateOfAutoUpdate, new.stateOfAutoUpdate)
+ putBoolean(keyStateOfAutoLotwSync, new.stateOfAutoLotwSync)
putBoolean(keyStateOfSensors, new.stateOfSensors)
putBoolean(keyStateOfSweep, new.stateOfSweep)
putBoolean(keyStateOfUtc, new.stateOfUtc)
@@ -556,6 +558,7 @@ class SettingsRepo(
private fun getOtherSettings(): OtherSettings = OtherSettings(
stateOfAutoUpdate = preferences.getBoolean(keyStateOfAutoUpdate, true),
+ stateOfAutoLotwSync = preferences.getBoolean(keyStateOfAutoLotwSync, true),
stateOfSensors = preferences.getBoolean(keyStateOfSensors, true),
stateOfSweep = preferences.getBoolean(keyStateOfSweep, true),
stateOfUtc = preferences.getBoolean(keyStateOfUtc, false),
diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/model/Settings.kt b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/model/Settings.kt
index 5bf9ef08..4f22bda9 100644
--- a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/model/Settings.kt
+++ b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/model/Settings.kt
@@ -64,7 +64,10 @@ data class OtherSettings(
val shouldSeeWhatsNew: Boolean,
val sstvMode: String = "Auto",
val lowElevation: Double = 15.0,
- val highElevation: Double = 45.0
+ val highElevation: Double = 45.0,
+ /** Independent auto-sync toggle for LoTW confirmed grids (separate from the
+ * ephemeris auto-update; only effective after at least one manual sync). */
+ val stateOfAutoLotwSync: Boolean = true
)
data class DataSourcesSettings(
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
new file mode 100644
index 00000000..75821db8
--- /dev/null
+++ b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/LoTWGridSync.kt
@@ -0,0 +1,125 @@
+/*
+ * 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 .
+ */
+package com.rtbishop.look4sat.core.domain.repository
+
+import com.rtbishop.look4sat.core.domain.model.GridQso
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+import java.text.SimpleDateFormat
+import java.util.Date
+import java.util.Locale
+import java.util.TimeZone
+
+/**
+ * Shared LoTW sync orchestration used by BOTH the manual sync (settings screen)
+ * and the automatic sync on app start (MainApplication), so the two paths can
+ * never drift apart:
+ *
+ * - [LoTWSyncMode.Incremental] pulls only the confirmations since the last sync
+ * and MERGES them into the stored grids/QSOs (grids only grow; QSO detail is
+ * deduped by call + QSO time).
+ * - [LoTWSyncMode.Full] pulls everything and REPLACES the stored data.
+ */
+
+/** UTC date ("yyyyMMdd") of the given instant — the LoTW sync cursor granularity. */
+fun lotwSyncToday(now: Long = System.currentTimeMillis()): String =
+ SimpleDateFormat("yyyyMMdd", Locale.US)
+ .apply { timeZone = TimeZone.getTimeZone("UTC") }
+ .format(Date(now))
+
+/**
+ * Resolves the effective sync mode for a given request:
+ * - an explicit [LoTWSyncMode.Full] request is always honored;
+ * - an [LoTWSyncMode.Incremental] request is honored only when the callsign
+ * matches the last sync — a first sync, an unknown/empty record or a
+ * callsign change falls back to [LoTWSyncMode.Full] so no stale grids from
+ * another account linger.
+ * With [requested] == null (automatic sync) it picks incremental whenever
+ * possible and full otherwise.
+ */
+fun resolveLoTWSyncMode(
+ lastSyncCallsign: String,
+ callsign: String,
+ requested: LoTWSyncMode?
+): LoTWSyncMode {
+ if (requested == LoTWSyncMode.Full) return LoTWSyncMode.Full
+ val sameCall = lastSyncCallsign.isNotBlank() && lastSyncCallsign == callsign
+ return if (sameCall) LoTWSyncMode.Incremental else LoTWSyncMode.Full
+}
+
+/**
+ * Whether the automatic LoTW sync should run now — mirrors the ephemeris
+ * auto-update check: credentials configured, the LoTW auto-sync toggle on, at
+ * least one prior manual sync (the first sync stays manual/full), and not
+ * already synced today (ARRL limits report pulls, ~once a day per account).
+ */
+fun shouldAutoSyncLoTW(
+ isConfigured: Boolean,
+ autoLotwSyncEnabled: Boolean,
+ lastSyncDate: String,
+ today: String
+): Boolean = isConfigured && autoLotwSyncEnabled && lastSyncDate.isNotBlank() && lastSyncDate != today
+
+/**
+ * Applies a successful LoTW report to the stored grid data and advances the
+ * sync cursor so the next incremental pull asks LoTW for only the
+ * confirmations since today. Persistence runs on the IO dispatcher — building
+ * the per-grid QSO JSON and writing SharedPreferences blocks the calling
+ * thread, and for large accounts (multi-MB JSON) that froze the main thread
+ * after a manual sync ('bar done but app stuck').
+ *
+ * @return the resulting number of worked grids.
+ */
+suspend fun applyLoTWGridResult(
+ settingsRepo: ISettingsRepo,
+ result: LoTWResult.Success,
+ mode: LoTWSyncMode,
+ callsign: String,
+ now: Long = System.currentTimeMillis()
+): Int {
+ val existingGrids = settingsRepo.getWorkedGrids()
+ val existingQsos = settingsRepo.getWorkedGridQsos()
+ val existingRoamed = settingsRepo.getRoamedGrids()
+ 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.setLastLotwSyncCallsign(callsign)
+ withContext(Dispatchers.IO) {
+ settingsRepo.setWorkedGrids(mergedGrids)
+ settingsRepo.setWorkedGridQsos(mergedQsos)
+ settingsRepo.setRoamedGrids(mergedRoamed)
+ }
+ return mergedGrids.size
+}
+
+/** Merge fresh QSO detail into existing per-grid lists, dedup by call + QSO time. */
+fun mergeGridQsos(
+ existing: Map>,
+ fresh: Map>
+): Map> {
+ val merged = existing.mapValues { (_, list) -> list.toMutableList() }.toMutableMap()
+ fresh.forEach { (grid, list) ->
+ val target = merged.getOrPut(grid) { mutableListOf() }
+ val known = target.mapTo(mutableSetOf()) { it.call to it.epochMs }
+ list.forEach { qso ->
+ if (known.add(qso.call to qso.epochMs)) target.add(qso)
+ }
+ }
+ return merged
+}
diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/LoTWResult.kt b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/LoTWResult.kt
index 8c5149bd..a3dd0b8c 100644
--- a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/LoTWResult.kt
+++ b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/LoTWResult.kt
@@ -46,3 +46,6 @@ sealed class LoTWResult {
/** Any other network-level failure (DNS, TLS, connection reset, HTTP 5xx). */
data class NetworkError(val detail: String) : LoTWResult()
}
+
+/** What a LoTW sync does: pull everything, or only new QSLs since the last sync. */
+enum class LoTWSyncMode { Full, Incremental }
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
new file mode 100644
index 00000000..ce0a7481
--- /dev/null
+++ b/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/repository/LoTWGridSyncTest.kt
@@ -0,0 +1,261 @@
+/*
+ * 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 .
+ */
+package com.rtbishop.look4sat.core.domain.repository
+
+import com.rtbishop.look4sat.core.domain.model.DatabaseState
+import com.rtbishop.look4sat.core.domain.model.DataSourcesSettings
+import com.rtbishop.look4sat.core.domain.model.GridQso
+import com.rtbishop.look4sat.core.domain.model.LoTWSettings
+import com.rtbishop.look4sat.core.domain.model.OtherSettings
+import com.rtbishop.look4sat.core.domain.model.PassesSettings
+import com.rtbishop.look4sat.core.domain.model.RCSettings
+import com.rtbishop.look4sat.core.domain.model.RadioControlSettings
+import com.rtbishop.look4sat.core.domain.model.WavelogSettings
+import com.rtbishop.look4sat.core.domain.predict.GeoPos
+import kotlinx.coroutines.flow.MutableStateFlow
+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
+
+class LoTWGridSyncTest {
+
+ // 2026-09-16T00:00:00Z — fixed "now" for the cursor assertions.
+ private val now = 1_789_516_800_000L
+
+ // region resolveLoTWSyncMode
+
+ @Test
+ fun resolveModeHonorsExplicitFull() {
+ assertEquals(LoTWSyncMode.Full, resolveLoTWSyncMode("BA7OPF", "BA7OPF", LoTWSyncMode.Full))
+ assertEquals(LoTWSyncMode.Full, resolveLoTWSyncMode("", "BA7OPF", LoTWSyncMode.Full))
+ }
+
+ @Test
+ fun resolveModeAllowsIncrementalOnlyForSameCallsign() {
+ assertEquals(
+ LoTWSyncMode.Incremental,
+ resolveLoTWSyncMode("BA7OPF", "BA7OPF", LoTWSyncMode.Incremental)
+ )
+ assertEquals(
+ LoTWSyncMode.Full,
+ resolveLoTWSyncMode("BG7ABC", "BA7OPF", LoTWSyncMode.Incremental)
+ )
+ assertEquals(
+ LoTWSyncMode.Full,
+ resolveLoTWSyncMode("", "BA7OPF", LoTWSyncMode.Incremental)
+ )
+ }
+
+ @Test
+ fun resolveModeForAutoSyncPicksIncrementalWhenPossible() {
+ assertEquals(LoTWSyncMode.Incremental, resolveLoTWSyncMode("BA7OPF", "BA7OPF", null))
+ assertEquals(LoTWSyncMode.Full, resolveLoTWSyncMode("BG7ABC", "BA7OPF", null))
+ assertEquals(LoTWSyncMode.Full, resolveLoTWSyncMode("", "BA7OPF", null))
+ }
+
+ // endregion
+
+ // region shouldAutoSyncLoTW
+
+ @Test
+ fun autoSyncGateRequiresEverything() {
+ val today = "20260916"
+ // Not configured.
+ assertFalse(shouldAutoSyncLoTW(false, true, "20260915", today))
+ // Auto-update toggle off.
+ assertFalse(shouldAutoSyncLoTW(true, false, "20260915", 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.
+ assertTrue(shouldAutoSyncLoTW(true, true, "20260915", today))
+ }
+
+ // endregion
+
+ // region mergeGridQsos
+
+ @Test
+ fun mergeGridQsosDedupsByCallAndTime() {
+ val existing = mapOf(
+ "OL62" to listOf(qso("BA7ABC", 1000L)),
+ "OL63" to listOf(qso("BA7DEF", 2000L))
+ )
+ val fresh = mapOf(
+ // Same grid, same QSO re-delivered by an overlapping since-date.
+ "OL62" to listOf(qso("BA7ABC", 1000L)),
+ // Same grid, brand-new QSO.
+ "OL62" to listOf(qso("BA7GHI", 3000L)),
+ // New grid entirely.
+ "PM95" to listOf(qso("BA7JKL", 4000L))
+ )
+ val merged = mergeGridQsos(existing, fresh)
+ assertEquals(setOf("OL62", "OL63", "PM95"), merged.keys)
+ assertEquals(2, merged["OL62"]?.size) // BA7ABC once, BA7GHI added
+ assertEquals(1, merged["OL63"]?.size)
+ assertEquals(1, merged["PM95"]?.size)
+ }
+
+ @Test
+ fun mergeGridQsosHandlesEmptySides() {
+ val fresh = mapOf("OL62" to listOf(qso("BA7ABC", 1000L)))
+ assertEquals(fresh, mergeGridQsos(emptyMap(), fresh))
+ // An empty incremental pull must leave the stored side untouched.
+ assertEquals(fresh, mergeGridQsos(fresh, emptyMap()))
+ assertEquals(emptyMap>(), mergeGridQsos(emptyMap(), emptyMap()))
+ }
+
+ // endregion
+
+ // region applyLoTWGridResult
+
+ @Test
+ fun applyIncrementalMergesAndAdvancesCursor() = runBlocking {
+ val repo = FakeSettingsRepo().apply {
+ setWorkedGrids(setOf("OL62"))
+ setWorkedGridQsos(mapOf("OL62" to listOf(qso("BA7ABC", 1000L))))
+ setRoamedGrids(setOf("OL62"))
+ setLastLotwSyncDate("20260915")
+ setLastLotwSyncCallsign("BA7OPF")
+ }
+ val result = LoTWResult.Success(
+ grids = setOf("OL62", "PM95"),
+ qsos = mapOf("OL62" to listOf(qso("BA7ABC", 1000L)), "PM95" to listOf(qso("BA7GHI", 2000L))),
+ roamedGrids = setOf("PM95")
+ )
+ val count = applyLoTWGridResult(repo, result, LoTWSyncMode.Incremental, "BA7OPF", now)
+
+ assertEquals(2, count)
+ assertEquals(setOf("OL62", "PM95"), repo.getWorkedGrids())
+ assertEquals(1, repo.getWorkedGridQsos()["OL62"]?.size) // deduped
+ assertEquals(setOf("OL62", "PM95"), repo.getRoamedGrids())
+ assertEquals("20260916", repo.getLastLotwSyncDate())
+ assertEquals("BA7OPF", repo.getLastLotwSyncCallsign())
+ }
+
+ @Test
+ fun applyFullReplacesEverythingAndAdvancesCursor() = runBlocking {
+ val repo = FakeSettingsRepo().apply {
+ setWorkedGrids(setOf("OL62", "STALE"))
+ setWorkedGridQsos(mapOf("STALE" to listOf(qso("BG7OLD", 1L))))
+ setRoamedGrids(setOf("STALE"))
+ setLastLotwSyncDate("20260910")
+ setLastLotwSyncCallsign("BG7OLD")
+ }
+ val result = LoTWResult.Success(
+ grids = setOf("PM95"),
+ qsos = mapOf("PM95" to listOf(qso("BA7GHI", 2000L))),
+ roamedGrids = setOf("PM95")
+ )
+ val count = applyLoTWGridResult(repo, result, LoTWSyncMode.Full, "BA7OPF", now)
+
+ assertEquals(1, count)
+ assertEquals(setOf("PM95"), repo.getWorkedGrids())
+ assertEquals(setOf("PM95"), repo.getWorkedGridQsos().keys)
+ assertEquals(setOf("PM95"), repo.getRoamedGrids())
+ assertEquals("20260916", repo.getLastLotwSyncDate())
+ assertEquals("BA7OPF", repo.getLastLotwSyncCallsign())
+ }
+
+ // endregion
+
+ private fun qso(call: String, epochMs: Long): GridQso =
+ GridQso(call, epochMs, "FO-29", "FM", "70CM", "2M")
+
+ /**
+ * Minimal ISettingsRepo fake backing only the LoTW sync surface; the rest
+ * throws (mirrors the FakeSettingsRepo pattern used in feature tests).
+ */
+ private class FakeSettingsRepo : ISettingsRepo {
+ override val stationPosition: StateFlow = MutableStateFlow(GeoPos(23.13, 113.26))
+ override val appVersionName: String = "test"
+ override val selectedIds: StateFlow> = MutableStateFlow(emptyList())
+ override val selectedTypes: StateFlow> = MutableStateFlow(emptyList())
+ override val passesSettings: StateFlow = MutableStateFlow(
+ PassesSettings(hoursAhead = 24, minElevation = 10.0, selectedModes = emptyList())
+ )
+ override val databaseState: StateFlow = MutableStateFlow(DatabaseState(0, 0, 0L))
+ override val rcSettings: StateFlow = MutableStateFlow(
+ RCSettings(false, "", "", "", false, "", "", "", 0L, false, "", "", "", false, "", "")
+ )
+ override val otherSettings: StateFlow = MutableStateFlow(
+ OtherSettings(
+ stateOfAutoUpdate = true,
+ stateOfSensors = false,
+ stateOfSweep = false,
+ stateOfUtc = false,
+ stateOfLightTheme = false,
+ stateOfNightMode = false,
+ stateOfMapGrid = false,
+ shouldSeeWarning = false,
+ shouldSeeWhatsNew = false
+ )
+ )
+ override val dataSourcesSettings: StateFlow = MutableStateFlow(
+ DataSourcesSettings(satelliteUrls = emptyList(), transceiversUrls = emptyList())
+ )
+ override val dataSourcesStatus: StateFlow