feat(lotw): auto-sync grids on app start with separate toggle

Mirrors the ephemeris auto-update check: on every app start, if LoTW
credentials are configured, the new 'auto-sync LoTW grids' toggle is on,
at least one manual sync was done before, and today has not synced yet
(ARRL rate limit, ~once a day), pull confirmations since the last sync
and merge them. Same callsign -> incremental merge; callsign change ->
full replace. Manual dialog and automatic path share the orchestration
(mode resolution, gate, merge/persist) moved to core:domain
LoTWGridSync.kt; LoTWSyncMode moved to core:domain. 8 unit tests.
This commit is contained in:
atsunatsu committed 2026-09-16 20:18:22 +08:00
1 parent 5f1eb239ed
commit 60dfa5d600
17 files changed
+466 -61

No files matched your search

@@ -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})")
}
}
}
@@ -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),
@@ -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(
@@ -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 <https://www.gnu.org/licenses/>.
*/
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<String, List<GridQso>>,
fresh: Map<String, List<GridQso>>
): Map<String, List<GridQso>> {
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
}
@@ -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 }
@@ -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 <https://www.gnu.org/licenses/>.
*/
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<String, List<GridQso>>(), 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<GeoPos> = MutableStateFlow(GeoPos(23.13, 113.26))
override val appVersionName: String = "test"
override val selectedIds: StateFlow<List<Int>> = MutableStateFlow(emptyList())
override val selectedTypes: StateFlow<List<String>> = MutableStateFlow(emptyList())
override val passesSettings: StateFlow<PassesSettings> = MutableStateFlow(
PassesSettings(hoursAhead = 24, minElevation = 10.0, selectedModes = emptyList())
)
override val databaseState: StateFlow<DatabaseState> = MutableStateFlow(DatabaseState(0, 0, 0L))
override val rcSettings: StateFlow<RCSettings> = MutableStateFlow(
RCSettings(false, "", "", "", false, "", "", "", 0L, false, "", "", "", false, "", "")
)
override val otherSettings: StateFlow<OtherSettings> = MutableStateFlow(
OtherSettings(
stateOfAutoUpdate = true,
stateOfSensors = false,
stateOfSweep = false,
stateOfUtc = false,
stateOfLightTheme = false,
stateOfNightMode = false,
stateOfMapGrid = false,
shouldSeeWarning = false,
shouldSeeWhatsNew = false
)
)
override val dataSourcesSettings: StateFlow<DataSourcesSettings> = MutableStateFlow(
DataSourcesSettings(satelliteUrls = emptyList(), transceiversUrls = emptyList())
)
override val dataSourcesStatus: StateFlow<Map<String, Int>> = MutableStateFlow(emptyMap())
override val radioControlSettings: StateFlow<RadioControlSettings> = MutableStateFlow(
RadioControlSettings(false, RadioControlSettings.MODEL_YAESU_FT817, "", "", "", "", 9600)
)
override val wavelogSettings: StateFlow<WavelogSettings> = MutableStateFlow(WavelogSettings())
override val lotwSettings: StateFlow<LoTWSettings> = MutableStateFlow(LoTWSettings())
private var workedGrids: Set<String> = emptySet()
private var workedGridQsos: Map<String, List<GridQso>> = emptyMap()
private var roamedGrids: Set<String> = emptySet()
private var lastSyncDate: String = ""
private var lastSyncCallsign: String = ""
override fun getWorkedGrids(): Set<String> = workedGrids
override fun setWorkedGrids(grids: Set<String>) { workedGrids = grids }
override fun getWorkedGridQsos(): Map<String, List<GridQso>> = workedGridQsos
override fun setWorkedGridQsos(qsos: Map<String, List<GridQso>>) { workedGridQsos = qsos }
override fun getRoamedGrids(): Set<String> = roamedGrids
override fun setRoamedGrids(grids: Set<String>) { roamedGrids = grids }
override fun getLastLotwSyncDate(): String = lastSyncDate
override fun setLastLotwSyncDate(date: String) { lastSyncDate = date }
override fun getLastLotwSyncCallsign(): String = lastSyncCallsign
override fun setLastLotwSyncCallsign(callsign: String) { lastSyncCallsign = callsign }
override fun setSelectedIds(ids: List<Int>) = TODO()
override fun setSelectedTypes(types: List<String>) = TODO()
override fun setPassesSettings(settings: PassesSettings) = TODO()
override fun setStationPosition(latitude: Double, longitude: Double, altitude: Double): Boolean = TODO()
override fun setStationPosition(): Boolean = TODO()
override fun setStationPosition(locator: String): Boolean = TODO()
override fun getSatelliteTypesIds(types: List<String>): List<Int> = TODO()
override fun setSatelliteTypeIds(type: String, ids: List<Int>) = TODO()
override fun updateDatabaseState(state: DatabaseState) = TODO()
override fun updateRCSettings(settings: RCSettings) = TODO()
override fun updateOtherSettings(transform: (OtherSettings) -> OtherSettings) = TODO()
override fun updateDataSourcesSettings(settings: DataSourcesSettings) = TODO()
override fun updateDataSourcesStatus(status: Map<String, Int>) = TODO()
override fun updateRadioControlSettings(settings: RadioControlSettings) = TODO()
override fun getSatelliteOffset(catnum: Int): String = ""
override fun setSatelliteOffset(catnum: Int, offset: String) = TODO()
override fun getAmSatCallsign(): String = ""
override fun setAmSatCallsign(callsign: String) = TODO()
override fun updateWavelogSettings(settings: WavelogSettings) = TODO()
override fun updateLoTWSettings(settings: LoTWSettings) = TODO()
}
}
@@ -147,6 +147,7 @@
<string name="prefs_other_title">Otros ajustes</string>
<string name="prefs_other_switch_utc">Mostrar tiempos de pase en UTC</string>
<string name="prefs_other_switch_update">Habilitar auto actualización de datos</string>
<string name="prefs_other_switch_lotw_sync">Sincronizar automáticamente las cuadrículas LoTW confirmadas</string>
<string name="prefs_other_switch_sweep">Habilitar animaciones radar</string>
<string name="prefs_other_switch_sensors">Usar sensores para rotar vista de radar</string>
<string name="prefs_other_switch_night_mode">Activar filtro nocturno rojo</string>
@@ -146,6 +146,7 @@
<string name="prefs_other_title">Другие настройки</string>
<string name="prefs_other_switch_utc">Показывать время по UTC</string>
<string name="prefs_other_switch_update">Обновлять данные автоматически</string>
<string name="prefs_other_switch_lotw_sync">Автосинхронизация подтверждённых сеток LoTW</string>
<string name="prefs_other_switch_sweep">Показывать анимацию радара</string>
<string name="prefs_other_switch_sensors">Использовать сенсоры устройства</string>
<string name="prefs_other_switch_night_mode">Включить красный ночной фильтр</string>
@@ -147,6 +147,7 @@
<string name="prefs_other_title">වෙනත් සැකසුම්</string>
<string name="prefs_other_switch_utc">පසුකර වේලාවන් UTC මගින්</string>
<string name="prefs_other_switch_update">ස්වයං දත්ත යාවත්කාලීනය</string>
<string name="prefs_other_switch_lotw_sync">LoTW තහවුරු කළ ජාල ස්වයං සමමුහුර්තකරණය</string>
<string name="prefs_other_switch_sweep">radar sweep සජීවිකරණය සබල කරන්න</string>
<string name="prefs_other_switch_sensors">Radar දර්ශනය කරකැවීමට සංවේදක භාවිතා කරන්න </string>
<string name="prefs_other_switch_night_mode">රතු රාත්‍රී පෙරහන සබල කරන්න</string>
@@ -189,6 +189,7 @@
<string name="prefs_other_title">Diğer ayarlar</string>
<string name="prefs_other_switch_utc">Geçiş saatini UTC olarak göster</string>
<string name="prefs_other_switch_update">Otomatik veri güncellemeyi etkinleştir</string>
<string name="prefs_other_switch_lotw_sync">LoTW onaylı kareleri otomatik senkronize et</string>
<string name="prefs_other_switch_sweep">Radar taramasını etkinleştir</string>
<string name="prefs_other_switch_sensors">Radar görünümünü döndürmek için sensörleri kullan</string>
<string name="prefs_other_switch_night_mode">Kırmızı gece filtresini etkinleştir</string>
@@ -147,6 +147,7 @@
<string name="prefs_other_title">Інші налаштування</string>
<string name="prefs_other_switch_utc">Показувати час в UTC</string>
<string name="prefs_other_switch_update">Автоматичне оновлення даних</string>
<string name="prefs_other_switch_lotw_sync">Автосинхронізація підтверджених сіток LoTW</string>
<string name="prefs_other_switch_sweep">Показувати анімацію радара</string>
<string name="prefs_other_switch_sensors">Використовувати сенсори пристрою</string>
<string name="prefs_other_switch_night_mode">Увімкнути червоний нічний фільтр</string>
@@ -237,6 +237,7 @@
<string name="prefs_other_title">其他设置</string>
<string name="prefs_other_switch_utc">以 UTC 时间显示</string>
<string name="prefs_other_switch_update">启用卫星数据自动更新</string>
<string name="prefs_other_switch_lotw_sync">自动同步 LoTW 已确认网格</string>
<string name="prefs_other_switch_sweep">启用雷达扫描动画</string>
<string name="prefs_other_switch_sensors">使用传感器旋转雷达视图</string>
<string name="prefs_other_switch_night_mode">启用红色夜间模式</string>
@@ -271,6 +271,7 @@
<string name="prefs_other_title">Other settings</string>
<string name="prefs_other_switch_utc">Show pass time in UTC</string>
<string name="prefs_other_switch_update">Enable automatic data update</string>
<string name="prefs_other_switch_lotw_sync">Auto-sync LoTW confirmed grids</string>
<string name="prefs_other_switch_sweep">Enable radar sweep animation</string>
<string name="prefs_other_switch_sensors">Use sensors to rotate radar view</string>
<string name="prefs_other_switch_night_mode">Enable red night mode filter</string>
@@ -87,6 +87,7 @@ import androidx.compose.ui.zIndex
import com.rtbishop.look4sat.core.domain.model.Constants
import com.rtbishop.look4sat.core.domain.model.RCSettings
import com.rtbishop.look4sat.core.domain.model.RadioControlSettings
import com.rtbishop.look4sat.core.domain.repository.LoTWSyncMode
import com.rtbishop.look4sat.core.domain.source.NetworkResult
import com.rtbishop.look4sat.core.domain.source.Sources
import com.rtbishop.look4sat.core.presentation.CardButton
@@ -67,6 +67,7 @@ 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 com.rtbishop.look4sat.core.domain.repository.IContainerProvider
import com.rtbishop.look4sat.core.domain.repository.LoTWSyncMode
import com.rtbishop.look4sat.core.presentation.CardButton
import com.rtbishop.look4sat.core.presentation.IconCard
import com.rtbishop.look4sat.core.presentation.MainTheme
@@ -536,7 +537,7 @@ private fun OtherCard(settings: OtherSettings, onAction: (SettingsAction) -> Uni
ElevatedCard(
modifier = Modifier
.fillMaxWidth()
.height(272.dp)
.height(320.dp)
) {
Column(modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)) {
Text(
@@ -549,6 +550,9 @@ private fun OtherCard(settings: OtherSettings, onAction: (SettingsAction) -> Uni
SwitchRow(R.string.prefs_other_switch_update, settings.stateOfAutoUpdate) {
onAction(SettingsAction.ToggleUpdate(it))
}
SwitchRow(R.string.prefs_other_switch_lotw_sync, settings.stateOfAutoLotwSync) {
onAction(SettingsAction.ToggleAutoLotwSync(it))
}
SwitchRow(R.string.prefs_other_switch_sweep, settings.stateOfSweep) {
onAction(SettingsAction.ToggleSweep(it))
}
@@ -24,6 +24,7 @@ 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 com.rtbishop.look4sat.core.domain.repository.LoTWSyncMode
import java.io.File
data class PositionSettings(
@@ -67,9 +68,6 @@ data class SettingsState(
val updateChecker: UpdateCheckerState = UpdateCheckerState()
)
/** What a LoTW sync button does: pull everything, or only new QSLs since the last sync. */
enum class LoTWSyncMode { Full, Incremental }
/** LoTW sync failure, kept as a translatable code until the UI renders it. */
sealed interface LoTWError {
data object NotConfigured : LoTWError
@@ -95,6 +93,7 @@ sealed interface SettingsAction {
// Toggles
data class ToggleUtc(val value: Boolean) : SettingsAction
data class ToggleUpdate(val value: Boolean) : SettingsAction
data class ToggleAutoLotwSync(val value: Boolean) : SettingsAction
data class ToggleSweep(val value: Boolean) : SettingsAction
data class ToggleSensor(val value: Boolean) : SettingsAction
data class ToggleLightTheme(val value: Boolean) : SettingsAction
@@ -27,6 +27,10 @@ import com.rtbishop.look4sat.core.domain.model.WavelogSettings
import com.rtbishop.look4sat.core.domain.repository.IMainContainer
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
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.resolveLoTWSyncMode
import com.rtbishop.look4sat.core.domain.repository.IWavelogRepository
import com.rtbishop.look4sat.core.domain.usecase.IShowToast
import com.rtbishop.look4sat.core.domain.utility.VersionComparator
@@ -35,7 +39,6 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.io.File
class SettingsViewModel(
@@ -148,6 +151,7 @@ class SettingsViewModel(
// Toggles
is SettingsAction.ToggleUtc -> settingsRepo.updateOtherSettings { it.copy(stateOfUtc = action.value) }
is SettingsAction.ToggleUpdate -> settingsRepo.updateOtherSettings { it.copy(stateOfAutoUpdate = action.value) }
is SettingsAction.ToggleAutoLotwSync -> settingsRepo.updateOtherSettings { it.copy(stateOfAutoLotwSync = action.value) }
is SettingsAction.ToggleSweep -> settingsRepo.updateOtherSettings { it.copy(stateOfSweep = action.value) }
is SettingsAction.ToggleSensor -> settingsRepo.updateOtherSettings { it.copy(stateOfSensors = action.value) }
is SettingsAction.ToggleLightTheme -> settingsRepo.updateOtherSettings { it.copy(stateOfLightTheme = action.value) }
@@ -209,10 +213,7 @@ class SettingsViewModel(
// a first sync, an unknown/empty record or a callsign change must fall
// back to a full report so no stale grids from another account linger.
val callsign = settings.callsign.trim().uppercase()
val lastCallsign = settingsRepo.getLastLotwSyncCallsign()
val effectiveMode = if (mode == LoTWSyncMode.Incremental &&
lastCallsign.isNotBlank() && lastCallsign == callsign
) LoTWSyncMode.Incremental else LoTWSyncMode.Full
val effectiveMode = resolveLoTWSyncMode(settingsRepo.getLastLotwSyncCallsign(), callsign, mode)
val since = if (effectiveMode == LoTWSyncMode.Incremental) {
settingsRepo.getLastLotwSyncDate()
} else ""
@@ -224,67 +225,40 @@ class SettingsViewModel(
_uiState.update { it.copy(lotwProgress = progress) }
}
when (result) {
is com.rtbishop.look4sat.core.domain.repository.LoTWResult.Success -> {
is LoTWResult.Success -> {
// Incremental: merge new grids/QSOs into the stored set (dedup
// by call + QSO time); full: the fresh report replaces it all.
val existingGrids = settingsRepo.getWorkedGrids()
val existingQsos = settingsRepo.getWorkedGridQsos()
val existingRoamed = settingsRepo.getRoamedGrids()
val mergedGrids = if (effectiveMode == LoTWSyncMode.Incremental) {
existingGrids + result.grids
} else result.grids
val mergedQsos = if (effectiveMode == LoTWSyncMode.Incremental) {
mergeGridQsos(existingQsos, result.qsos)
} else result.qsos
val mergedRoamed = if (effectiveMode == LoTWSyncMode.Incremental) {
existingRoamed + result.roamedGrids
} else result.roamedGrids
// Remember when/what we synced, so the next incremental pull
// asks LoTW for only the confirmations since today.
val today = java.text.SimpleDateFormat("yyyyMMdd", java.util.Locale.US)
.apply { timeZone = java.util.TimeZone.getTimeZone("UTC") }
.format(java.util.Date())
settingsRepo.setLastLotwSyncDate(today)
settingsRepo.setLastLotwSyncCallsign(callsign)
// Persist on the IO dispatcher: building the per-grid QSO JSON
// and writing SharedPreferences synchronously blocks the
// calling thread, and for large accounts (tens of thousands
// of QSOs → multi-MB JSON) that froze the main thread after
// the download finished — 'bar done but app stuck'.
withContext(kotlinx.coroutines.Dispatchers.IO) {
settingsRepo.setWorkedGrids(mergedGrids)
settingsRepo.setWorkedGridQsos(mergedQsos)
settingsRepo.setRoamedGrids(mergedRoamed)
}
// Shared with the automatic sync on app start (MainApplication).
val mergedGridsCount = applyLoTWGridResult(settingsRepo, result, effectiveMode, callsign)
_uiState.update { state ->
state.copy(
lotwSyncing = false, lotwSyncMode = null, lotwProgress = null,
workedGridsCount = mergedGrids.size, lotwError = null
workedGridsCount = mergedGridsCount, lotwError = null
)
}
}
is com.rtbishop.look4sat.core.domain.repository.LoTWResult.BadCredentials ->
is LoTWResult.BadCredentials ->
_uiState.update { state ->
state.copy(
lotwSyncing = false, lotwSyncMode = null, lotwProgress = null,
lotwError = LoTWError.BadCredentials
)
}
is com.rtbishop.look4sat.core.domain.repository.LoTWResult.RateLimited ->
is LoTWResult.RateLimited ->
_uiState.update { state ->
state.copy(
lotwSyncing = false, lotwSyncMode = null, lotwProgress = null,
lotwError = LoTWError.RateLimited
)
}
is com.rtbishop.look4sat.core.domain.repository.LoTWResult.Timeout ->
is LoTWResult.Timeout ->
_uiState.update { state ->
state.copy(
lotwSyncing = false, lotwSyncMode = null, lotwProgress = null,
lotwError = LoTWError.Timeout
)
}
is com.rtbishop.look4sat.core.domain.repository.LoTWResult.NetworkError ->
is LoTWResult.NetworkError ->
_uiState.update { state ->
state.copy(
lotwSyncing = false, lotwSyncMode = null, lotwProgress = null,
@@ -302,22 +276,6 @@ class SettingsViewModel(
_uiState.update { it.copy(lotwSyncing = false, lotwSyncMode = null, lotwProgress = null) }
}
/** Merge fresh QSO detail into existing per-grid lists, dedup by call + QSO time. */
private fun mergeGridQsos(
existing: Map<String, List<com.rtbishop.look4sat.core.domain.model.GridQso>>,
fresh: Map<String, List<com.rtbishop.look4sat.core.domain.model.GridQso>>
): Map<String, List<com.rtbishop.look4sat.core.domain.model.GridQso>> {
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
}
// endregion
// region Update checker