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> = MutableStateFlow(emptyMap()) + override val radioControlSettings: StateFlow = MutableStateFlow( + RadioControlSettings(false, RadioControlSettings.MODEL_YAESU_FT817, "", "", "", "", 9600) + ) + override val wavelogSettings: StateFlow = MutableStateFlow(WavelogSettings()) + override val lotwSettings: StateFlow = MutableStateFlow(LoTWSettings()) + + private var workedGrids: Set = emptySet() + private var workedGridQsos: Map> = emptyMap() + private var roamedGrids: Set = emptySet() + private var lastSyncDate: String = "" + private var lastSyncCallsign: String = "" + + override fun getWorkedGrids(): Set = workedGrids + override fun setWorkedGrids(grids: Set) { workedGrids = grids } + override fun getWorkedGridQsos(): Map> = workedGridQsos + override fun setWorkedGridQsos(qsos: Map>) { workedGridQsos = qsos } + override fun getRoamedGrids(): Set = roamedGrids + override fun setRoamedGrids(grids: Set) { 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) = TODO() + override fun setSelectedTypes(types: List) = 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): List = TODO() + override fun setSatelliteTypeIds(type: String, ids: List) = 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) = 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() + } +} diff --git a/core/presentation/src/main/res/values-es/strings.xml b/core/presentation/src/main/res/values-es/strings.xml index 4f8a0939..cbb75e02 100644 --- a/core/presentation/src/main/res/values-es/strings.xml +++ b/core/presentation/src/main/res/values-es/strings.xml @@ -147,6 +147,7 @@ Otros ajustes Mostrar tiempos de pase en UTC Habilitar auto actualización de datos + Sincronizar automáticamente las cuadrículas LoTW confirmadas Habilitar animaciones radar Usar sensores para rotar vista de radar Activar filtro nocturno rojo diff --git a/core/presentation/src/main/res/values-ru/strings.xml b/core/presentation/src/main/res/values-ru/strings.xml index 9093231a..e506bd88 100644 --- a/core/presentation/src/main/res/values-ru/strings.xml +++ b/core/presentation/src/main/res/values-ru/strings.xml @@ -146,6 +146,7 @@ Другие настройки Показывать время по UTC Обновлять данные автоматически + Автосинхронизация подтверждённых сеток LoTW Показывать анимацию радара Использовать сенсоры устройства Включить красный ночной фильтр diff --git a/core/presentation/src/main/res/values-si/strings.xml b/core/presentation/src/main/res/values-si/strings.xml index ff1fe5dc..24221934 100644 --- a/core/presentation/src/main/res/values-si/strings.xml +++ b/core/presentation/src/main/res/values-si/strings.xml @@ -147,6 +147,7 @@ වෙනත් සැකසුම් පසුකර වේලාවන් UTC මගින් ස්වයං දත්ත යාවත්කාලීනය + LoTW තහවුරු කළ ජාල ස්වයං සමමුහුර්තකරණය radar sweep සජීවිකරණය සබල කරන්න Radar දර්ශනය කරකැවීමට සංවේදක භාවිතා කරන්න රතු රාත්‍රී පෙරහන සබල කරන්න diff --git a/core/presentation/src/main/res/values-tr/strings.xml b/core/presentation/src/main/res/values-tr/strings.xml index dab528c7..9daa6017 100644 --- a/core/presentation/src/main/res/values-tr/strings.xml +++ b/core/presentation/src/main/res/values-tr/strings.xml @@ -189,6 +189,7 @@ Diğer ayarlar Geçiş saatini UTC olarak göster Otomatik veri güncellemeyi etkinleştir + LoTW onaylı kareleri otomatik senkronize et Radar taramasını etkinleştir Radar görünümünü döndürmek için sensörleri kullan Kırmızı gece filtresini etkinleştir diff --git a/core/presentation/src/main/res/values-uk/strings.xml b/core/presentation/src/main/res/values-uk/strings.xml index 73fc6efa..c4cc57b6 100644 --- a/core/presentation/src/main/res/values-uk/strings.xml +++ b/core/presentation/src/main/res/values-uk/strings.xml @@ -147,6 +147,7 @@ Інші налаштування Показувати час в UTC Автоматичне оновлення даних + Автосинхронізація підтверджених сіток LoTW Показувати анімацію радара Використовувати сенсори пристрою Увімкнути червоний нічний фільтр diff --git a/core/presentation/src/main/res/values-zh/strings.xml b/core/presentation/src/main/res/values-zh/strings.xml index 28ea1316..37c83e53 100644 --- a/core/presentation/src/main/res/values-zh/strings.xml +++ b/core/presentation/src/main/res/values-zh/strings.xml @@ -237,6 +237,7 @@ 其他设置 以 UTC 时间显示 启用卫星数据自动更新 + 自动同步 LoTW 已确认网格 启用雷达扫描动画 使用传感器旋转雷达视图 启用红色夜间模式 diff --git a/core/presentation/src/main/res/values/strings.xml b/core/presentation/src/main/res/values/strings.xml index e52ae89d..13a9c246 100644 --- a/core/presentation/src/main/res/values/strings.xml +++ b/core/presentation/src/main/res/values/strings.xml @@ -271,6 +271,7 @@ Other settings Show pass time in UTC Enable automatic data update + Auto-sync LoTW confirmed grids Enable radar sweep animation Use sensors to rotate radar view Enable red night mode filter diff --git a/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsDialog.kt b/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsDialog.kt index b1c80fab..2ea779fe 100644 --- a/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsDialog.kt +++ b/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsDialog.kt @@ -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 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 3f89fdbd..46389862 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 @@ -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)) } 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 0358abf9..0c383ddc 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 @@ -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 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 4407e027..32df50ea 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 @@ -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>, - 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 - } - // endregion // region Update checker