feat(lotw): full/incremental sync modes with live progress and time estimate

Two explicit sync buttons in the LoTW dialog:
- Full sync: qso_qslsince=2000-01-01, fresh report replaces all stored data.
- Incremental merge: since=last successful sync date, merged into the stored
  set (grids union, QSOs deduped by call + time). First sync / callsign change
  automatically falls back to full replace so no stale grids from another
  account linger.

Progress & estimates:
- Report body is now streamed; the header's <APP_LoTW_NUMREC> gives the real
  QSL count, total size is estimated as NUMREC * 720 B (measured), and the UI
  shows a determinate bar with 'N QSOs, ~X s remaining' once known.
- Pre-sync hint shows the measured rate (~8 s per 100 QSOs).
- readTimeout raised to 600 s (stall timeout, not a total cap — huge accounts
  legitimately take 30+ min at ARRL's ~10 KB/s stream rate); connectTimeout
  raised to 60 s (a busy server took 36.5 s to accept a connection).

Also: ISettingsRepo gains last-sync date/callsign bookkeeping, and the mutual
test fake implements the new members.
This commit is contained in:
atsunatsu committed 2026-09-16 14:34:00 +08:00
1 parent 7a865fffd7
commit fc028f852c
11 files changed
+365 -57

No files matched your search

@@ -18,6 +18,8 @@
package com.rtbishop.look4sat.core.data.repository
import com.rtbishop.look4sat.core.domain.repository.ILoTWRepository
import com.rtbishop.look4sat.core.domain.repository.LoTWPhase
import com.rtbishop.look4sat.core.domain.repository.LoTWProgress
import com.rtbishop.look4sat.core.domain.repository.LoTWResult
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
@@ -46,7 +48,7 @@ class LoTWRepository : ILoTWRepository {
override suspend fun fetchConfirmedGrids(callsign: String, password: String): LoTWResult =
withContext(Dispatchers.IO) {
fetchReportBody(callsign, password).fold(
fetchReportBody(callsign, password, since = "", onProgress = {}).fold(
onSuccess = { body ->
parseBoth(body)?.let { (grids, _, roamed) ->
LoTWResult.Success(grids, emptyMap(), roamed)
@@ -58,9 +60,11 @@ class LoTWRepository : ILoTWRepository {
override suspend fun fetchConfirmedGridQsos(
callsign: String,
password: String
password: String,
since: String,
onProgress: (LoTWProgress) -> Unit
): LoTWResult = withContext(Dispatchers.IO) {
fetchReportBody(callsign, password).fold(
fetchReportBody(callsign, password, since, onProgress).fold(
onSuccess = { body ->
parseBoth(body)?.let { (grids, qsos, roamed) ->
LoTWResult.Success(grids, qsos, roamed)
@@ -87,33 +91,46 @@ class LoTWRepository : ILoTWRepository {
return Triple(grids, qsos, roamed)
}
private fun fetchReportBody(callsign: String, password: String): Result<String> {
private fun fetchReportBody(
callsign: String,
password: String,
since: String,
onProgress: (LoTWProgress) -> Unit
): Result<String> {
val call = callsign.trim().uppercase()
val pwd = password.trim()
if (call.isBlank() || pwd.isBlank()) return Result.failure(IOException("empty credentials"))
// qso_qslsince with an early date forces a FULL confirmed-QSL report.
// Without it, LoTW applies a "system supplied default" since-date and
// only returns confirmations newer than the account's last query —
// subsequent syncs would return an empty/incremental report.
val since = "2000-01-01"
// Empty since -> full report. qso_qslsince with an early date forces a
// FULL confirmed-QSL report; without it LoTW applies a "system supplied
// default" since-date and only returns confirmations newer than the
// account's last query — subsequent syncs would be incremental/empty.
val effectiveSince = since.ifBlank { "2000-01-01" }
val query = buildString {
append("login=").append(URLEncoder.encode(call, "UTF-8"))
append("&password=").append(URLEncoder.encode(pwd, "UTF-8"))
append("&qso_query=1&qso_qsl=yes&qso_qsldetail=yes&qso_mydetail=yes")
append("&qso_qslsince=").append(URLEncoder.encode(since, "UTF-8"))
append("&qso_qslsince=").append(URLEncoder.encode(effectiveSince, "UTF-8"))
}
return try {
val connection = URL("$BASE_URL?$query").openConnection() as HttpURLConnection
// ARRL can be slow to accept connections from mobile networks
// (long TLS handshakes across the Pacific, occasional server-side
// queueing). 30s connect + 120s read gives the request enough
// headroom; the sync button stays disabled meanwhile so users
// see progress rather than a hung dialog.
connection.connectTimeout = 30_000
connection.readTimeout = 120_000
// queueing — a busy server took 36.5 s to accept a connection in
// testing, so 30 s connect would have killed it).
//
// NOTE: readTimeout is NOT a total-download cap. It is the longest
// wait for the NEXT chunk of data (a stall timeout). A full report
// for a huge account (tens of thousands of QSLs ≈ tens of MB at
// ARRL's ~10 KB/s stream rate) legitimately takes 30+ minutes;
// as long as chunks keep arriving it must never be cut off.
// 600 s tolerates long server-side pauses while it generates the
// report; a truly dead link (no data at all) still fails within it.
connection.connectTimeout = 60_000
connection.readTimeout = 600_000
connection.requestMethod = "GET"
connection.setRequestProperty("Accept-Encoding", "gzip")
connection.instanceFollowRedirects = true
onProgress(LoTWProgress(LoTWPhase.Connecting))
val code = connection.responseCode
if (code !in 200..299) {
connection.disconnect()
@@ -123,9 +140,8 @@ class LoTWRepository : ILoTWRepository {
else Result.failure(IOException("HTTP $code"))
}
val stream = connection.inputStream
val body = ("gzip".equals(connection.contentEncoding, ignoreCase = true))
.let { gz -> if (gz) java.util.zip.GZIPInputStream(stream) else stream }
.bufferedReader().use { it.readText() }
val isGzip = "gzip".equals(connection.contentEncoding, ignoreCase = true)
val body = readBodyWithProgress(stream, isGzip, onProgress)
connection.disconnect()
when {
// Login failure: HTTP 200 + HTML login page with the error text.
@@ -145,6 +161,63 @@ class LoTWRepository : ILoTWRepository {
}
}
/**
* Streams the report body into a string while reporting download progress.
* Once the header's record count (<APP_LoTW_NUMREC>) is seen, the total
* body size is estimated as NUMREC * AVG_RECORD_BYTES so the UI can show a
* meaningful progress bar and remaining-time estimate.
*/
private fun readBodyWithProgress(
stream: java.io.InputStream,
isGzip: Boolean,
onProgress: (LoTWProgress) -> Unit
): String {
val input = if (isGzip) java.util.zip.GZIPInputStream(stream) else stream
val reader = java.io.InputStreamReader(input, Charsets.UTF_8)
val sb = StringBuilder()
val buf = CharArray(8192)
var bytesRead = 0L
var expected = 0L
var qsoCount = 0L
var speed = 0L
var headerSeen = false
var lastSampleMs = System.currentTimeMillis()
var lastSampleBytes = 0L
var lastEmitMs = 0L
while (true) {
val n = reader.read(buf)
if (n <= 0) break
sb.append(buf, 0, n)
bytesRead += n
// The record count sits in the report header, long before the data.
if (!headerSeen) {
NUMREC_REGEX.find(sb)?.let { m ->
qsoCount = m.groupValues[1].toLongOrNull() ?: 0L
expected = qsoCount * AVG_RECORD_BYTES
headerSeen = true
}
}
// Sample speed every 500ms, EMA-smoothed.
val now = System.currentTimeMillis()
if (now - lastSampleMs >= 500) {
val elapsedS = (now - lastSampleMs) / 1000.0
if (elapsedS > 0) {
val inst = ((bytesRead - lastSampleBytes) / elapsedS).toLong()
speed = if (speed == 0L) inst else (speed * 7 + inst * 3) / 10
lastSampleMs = now
lastSampleBytes = bytesRead
}
}
// Throttle callbacks to ~4/s so StateFlow updates stay cheap.
if (now - lastEmitMs >= 250) {
onProgress(LoTWProgress(LoTWPhase.Downloading, bytesRead, expected, speed, qsoCount))
lastEmitMs = now
}
}
onProgress(LoTWProgress(LoTWPhase.Downloading, bytesRead, expected, speed, qsoCount))
return sb.toString()
}
internal class CredentialsException : Exception("bad callsign/password")
internal class RateLimitException : Exception("report refused (rate limit / server error)")
internal class TimeoutException(message: String) : Exception(message)
@@ -362,5 +435,14 @@ class LoTWRepository : ILoTWRepository {
private companion object {
const val BASE_URL = "https://lotw.arrl.org/lotwuser/lotwreport.adi"
/** Record count in the report header: `<APP_LoTW_NUMREC:4>2367`. */
val NUMREC_REGEX = Regex("<APP_LoTW_NUMREC:\\d+>(\\d+)")
/**
* Measured 2026-09-16 (BH6RJD, 2367 QSLs): 1.7 MB body streamed at
* ~9.5 KB/s ≈ 180 s, i.e. ≈ 720 bytes per ADIF record. Used to predict
* the total body size from the header record count.
*/
const val AVG_RECORD_BYTES = 720L
}
}
@@ -289,6 +289,24 @@ class SettingsRepo(
callsign = preferences.getString(keyLoTWCall, null).orEmpty(),
password = preferences.getString(keyLoTWPass, null).orEmpty()
)
// Last successful sync bookkeeping: date ("yyyyMMdd") and callsign. Used to
// decide incremental (same callsign) vs full (first time / callsign change)
// report requests and to merge increments into the stored grid data.
private val keyLastLotwSyncDate = "lotwLastSyncDate"
private val keyLastLotwSyncCallsign = "lotwLastSyncCallsign"
override fun getLastLotwSyncDate(): String =
preferences.getString(keyLastLotwSyncDate, null).orEmpty()
override fun setLastLotwSyncDate(date: String) =
preferences.edit { putString(keyLastLotwSyncDate, date) }
override fun getLastLotwSyncCallsign(): String =
preferences.getString(keyLastLotwSyncCallsign, null).orEmpty()
override fun setLastLotwSyncCallsign(callsign: String) =
preferences.edit { putString(keyLastLotwSyncCallsign, callsign.trim().uppercase()) }
//endregion
//region # Transceivers settings
@@ -296,6 +296,10 @@ private class FakeSettingsRepo(dataSources: DataSourcesSettings = defaultDataSou
MutableStateFlow(com.rtbishop.look4sat.core.domain.model.LoTWSettings())
override fun updateLoTWSettings(settings: com.rtbishop.look4sat.core.domain.model.LoTWSettings) = Unit
override fun getLastLotwSyncDate(): String = ""
override fun setLastLotwSyncDate(date: String) = Unit
override fun getLastLotwSyncCallsign(): String = ""
override fun setLastLotwSyncCallsign(callsign: String) = Unit
override fun getWorkedGridQsos(): Map<String, List<com.rtbishop.look4sat.core.domain.model.GridQso>> = emptyMap()
@@ -30,9 +30,43 @@ interface ILoTWRepository {
* Same report, but keeps the per-QSO detail of every confirmed satellite
* QSO (call / time / satellite / mode / bands), grouped by worked 4-char
* gridsquare. Failure cause is kept explicit.
*
* @param since QSL-since date ("yyyyMMdd" or "yyyy-MM-dd"); use an early
* date for a full report, or the last sync date for an
* incremental pull. Empty falls back to a full report.
* @param onProgress streamed download progress (phase + bytes + estimate),
* invoked from the IO dispatcher; may be called on every
* read chunk.
*/
suspend fun fetchConfirmedGridQsos(
callsign: String,
password: String
password: String,
since: String = "",
onProgress: (LoTWProgress) -> Unit = {}
): LoTWResult
}
/** Streamed progress of a LoTW report download. */
data class LoTWProgress(
val phase: LoTWPhase,
/** Bytes read so far (uncompressed body). */
val bytesRead: Long = 0,
/** Estimated total body bytes, derived from <APP_LoTW_NUMREC>; 0 before known. */
val expectedBytes: Long = 0,
/** Smoothed download speed in bytes/second; 0 while unknown. */
val speedBps: Long = 0,
/** Total QSL records reported in the header (<APP_LoTW_NUMREC>); 0 before known. */
val qsoCount: Long = 0
) {
/** Fraction 0..1 of the estimated body already read; 0 while unknown. */
val fraction: Float
get() = if (expectedBytes > 0 && bytesRead > 0)
(bytesRead.toFloat() / expectedBytes).coerceIn(0f, 1f) else 0f
/** Estimated seconds remaining; 0 while size or speed is unknown. */
val remainingSeconds: Long
get() = if (expectedBytes > 0 && speedBps > 0)
((expectedBytes - bytesRead) / speedBps).coerceAtLeast(0) else 0L
}
enum class LoTWPhase { Connecting, Downloading }
@@ -110,5 +110,11 @@ interface ISettingsRepo {
//region # LoTW confirmed-grids settings
val lotwSettings: StateFlow<com.rtbishop.look4sat.core.domain.model.LoTWSettings>
fun updateLoTWSettings(settings: com.rtbishop.look4sat.core.domain.model.LoTWSettings)
/** Last successful LoTW sync date ("yyyyMMdd", empty when never synced). */
fun getLastLotwSyncDate(): String
fun setLastLotwSyncDate(date: String)
/** Callsign of the last successful LoTW sync (empty when never synced). */
fun getLastLotwSyncCallsign(): String
fun setLastLotwSyncCallsign(callsign: String)
//endregion
}
@@ -165,6 +165,18 @@
<string name="prefs_lotw_syncing">同步中…</string>
<string name="prefs_lotw_configured">已从 LoTW 同步 — 共 %1$d 个网格</string>
<string name="prefs_lotw_not_configured">未配置 — 直接登录 ARRL LoTW 拉取已确认 QSO 的网格,无需自建服务器</string>
<string name="lotw_sync_error_not_configured">LoTW 呼号/密码未配置</string>
<string name="lotw_sync_error_credentials">LoTW 同步失败——呼号或密码错误</string>
<string name="lotw_sync_error_rate_limited">LoTW 同步失败——服务器限流,请等几分钟后再试</string>
<string name="lotw_sync_error_timeout">LoTW 同步失败——连接超时,请更换网络重试</string>
<string name="lotw_sync_error_network">LoTW 同步失败——网络错误(%1$s)</string>
<string name="lotw_sync_full">全量同步</string>
<string name="lotw_sync_incremental">增量合并</string>
<string name="lotw_sync_rate_hint">下载速度约每 100 条 QSO 需 8 秒(另有 5–40 秒服务器生成时间)</string>
<string name="lotw_sync_progress_connecting">正在连接 LoTW 服务器…</string>
<string name="lotw_sync_progress_downloading">正在下载报告…</string>
<string name="lotw_sync_progress_count">本次同步 %1$d 条 QSO…</string>
<string name="lotw_sync_progress_qso">本次同步 %1$d 条 QSO,预计还需 %2$d 秒</string>
<string name="prefs_locator_text">使用梅登黑德网格设置站点位置</string>
<string name="prefs_data_title">卫星数据更新</string>
@@ -200,6 +200,18 @@
<string name="prefs_lotw_syncing">Syncing…</string>
<string name="prefs_lotw_configured">Synced from LoTW — %1$d grids total</string>
<string name="prefs_lotw_not_configured">Not configured — pull confirmed grids directly from ARRL LoTW, no self-hosted server needed</string>
<string name="lotw_sync_error_not_configured">LoTW callsign/password not configured</string>
<string name="lotw_sync_error_credentials">LoTW sync failed — callsign or password incorrect</string>
<string name="lotw_sync_error_rate_limited">LoTW sync failed — rate limited by server, wait a few minutes and retry</string>
<string name="lotw_sync_error_timeout">LoTW sync failed — connection timed out, try another network</string>
<string name="lotw_sync_error_network">LoTW sync failed — network error (%1$s)</string>
<string name="lotw_sync_full">Full sync</string>
<string name="lotw_sync_incremental">Incremental merge</string>
<string name="lotw_sync_rate_hint">Download runs at ~8 s per 100 QSOs (plus 5–40 s server generation time)</string>
<string name="lotw_sync_progress_connecting">Connecting to LoTW…</string>
<string name="lotw_sync_progress_downloading">Downloading report…</string>
<string name="lotw_sync_progress_count">Syncing %1$d QSOs…</string>
<string name="lotw_sync_progress_qso">Syncing %1$d QSOs, ~%2$d s remaining</string>
<string name="prefs_locator_text">Set station\'s position using locator</string>
<string name="prefs_data_title">Satellite data</string>
@@ -50,6 +50,7 @@ import androidx.compose.material3.Checkbox
import androidx.compose.material3.FilterChip
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.TextButton
import androidx.compose.material3.OutlinedTextField
@@ -228,23 +229,23 @@ fun LoTWDialog(
initialSettings: com.rtbishop.look4sat.core.domain.model.LoTWSettings,
workedGridsCount: Int,
isSyncing: Boolean,
syncMode: LoTWSyncMode?,
progress: com.rtbishop.look4sat.core.domain.repository.LoTWProgress?,
message: String?,
dismiss: () -> Unit,
onSave: (com.rtbishop.look4sat.core.domain.model.LoTWSettings) -> Unit,
onSync: (com.rtbishop.look4sat.core.domain.model.LoTWSettings) -> Unit
onSyncFull: (com.rtbishop.look4sat.core.domain.model.LoTWSettings) -> Unit,
onSyncIncremental: (com.rtbishop.look4sat.core.domain.model.LoTWSettings) -> Unit
) {
val call = rememberSaveable { mutableStateOf(initialSettings.callsign) }
val pass = rememberSaveable { mutableStateOf(initialSettings.password) }
SharedDialog(
title = stringResource(R.string.prefs_lotw_title),
onDismissRequest = dismiss,
onCancel = dismiss,
// The confirm button IS the sync action: saving credentials and
// fetching confirmed grids happen in one step.
onAccept = {
onSync(com.rtbishop.look4sat.core.domain.model.LoTWSettings(call.value, pass.value))
},
acceptText = stringResource(if (isSyncing) R.string.prefs_lotw_syncing else R.string.prefs_lotw_sync),
acceptEnabled = !isSyncing
// The two sync buttons below are the primary actions: saving
// credentials and fetching confirmed grids happen in one step.
onAccept = null
) {
OutlinedTextField(
value = call.value,
@@ -268,12 +269,51 @@ fun LoTWDialog(
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(horizontal = LocalSpacing.current.large)
)
Text(
text = stringResource(R.string.prefs_lotw_sync_duration_hint),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = LocalSpacing.current.large)
)
if (isSyncing) {
// Live progress: once the report header reveals the record count,
// switch to "N QSOs, ~X s remaining" with a determinate bar.
val progressText = when {
progress == null ||
progress.phase == com.rtbishop.look4sat.core.domain.repository.LoTWPhase.Connecting ->
stringResource(R.string.lotw_sync_progress_connecting)
progress.qsoCount > 0 && progress.remainingSeconds > 0 ->
stringResource(R.string.lotw_sync_progress_qso, progress.qsoCount, progress.remainingSeconds)
progress.qsoCount > 0 ->
stringResource(R.string.lotw_sync_progress_count, progress.qsoCount)
else -> stringResource(R.string.lotw_sync_progress_downloading)
}
Text(
text = progressText,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = LocalSpacing.current.large)
)
if (progress != null && progress.expectedBytes > 0) {
LinearProgressIndicator(
progress = { progress.fraction },
modifier = Modifier.fillMaxWidth().padding(horizontal = LocalSpacing.current.large)
)
} else {
LinearProgressIndicator(
modifier = Modifier.fillMaxWidth().padding(horizontal = LocalSpacing.current.large)
)
}
} else {
// Pre-sync estimate: rate per 100 QSOs (measured ~8 s at ARRL's
// ~10 KB/s stream rate), plus the fixed server-side generation time.
Text(
text = stringResource(R.string.lotw_sync_rate_hint),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = LocalSpacing.current.large)
)
Text(
text = stringResource(R.string.prefs_lotw_sync_duration_hint),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = LocalSpacing.current.large)
)
}
if (message != null) {
Text(
text = message,
@@ -282,6 +322,21 @@ fun LoTWDialog(
modifier = Modifier.padding(horizontal = LocalSpacing.current.large)
)
}
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.fillMaxWidth().padding(horizontal = LocalSpacing.current.large)
) {
CardButton(
onClick = { onSyncFull(com.rtbishop.look4sat.core.domain.model.LoTWSettings(call.value, pass.value)) },
text = stringResource(R.string.lotw_sync_full),
isEnabled = !isSyncing
)
CardButton(
onClick = { onSyncIncremental(com.rtbishop.look4sat.core.domain.model.LoTWSettings(call.value, pass.value)) },
text = stringResource(R.string.lotw_sync_incremental),
isEnabled = !isSyncing
)
}
Spacer(modifier = Modifier.height(0.dp))
}
}
@@ -204,14 +204,28 @@ private fun SettingsScreen(uiState: SettingsState, onAction: (SettingsAction) ->
)
}
if (dialogs.lotw) {
// LoTW sync failures are typed codes from the ViewModel; render them
// through string resources so the dialog follows the system language.
val lotwErrorMessage = uiState.lotwError?.let { error ->
when (error) {
LoTWError.NotConfigured -> stringResource(R.string.lotw_sync_error_not_configured)
LoTWError.BadCredentials -> stringResource(R.string.lotw_sync_error_credentials)
LoTWError.RateLimited -> stringResource(R.string.lotw_sync_error_rate_limited)
LoTWError.Timeout -> stringResource(R.string.lotw_sync_error_timeout)
is LoTWError.Network -> stringResource(R.string.lotw_sync_error_network, error.detail)
}
}
LoTWDialog(
initialSettings = uiState.lotwSettings,
workedGridsCount = uiState.workedGridsCount,
isSyncing = uiState.lotwSyncing,
message = uiState.lotwMessage,
syncMode = uiState.lotwSyncMode,
progress = uiState.lotwProgress,
message = lotwErrorMessage,
dismiss = { dialogs.lotw = false },
onSave = { onAction(SettingsAction.UpdateLoTW(it)) },
onSync = { onAction(SettingsAction.SyncLoTWGrids(it)) }
onSyncFull = { onAction(SettingsAction.SyncLoTWGrids(it, LoTWSyncMode.Full)) },
onSyncIncremental = { onAction(SettingsAction.SyncLoTWGrids(it, LoTWSyncMode.Incremental)) }
)
}
@@ -61,10 +61,24 @@ data class SettingsState(
val wavelogMessage: String? = null,
val lotwSettings: com.rtbishop.look4sat.core.domain.model.LoTWSettings = com.rtbishop.look4sat.core.domain.model.LoTWSettings(),
val lotwSyncing: Boolean = false,
val lotwMessage: String? = null,
val lotwSyncMode: LoTWSyncMode? = null,
val lotwProgress: com.rtbishop.look4sat.core.domain.repository.LoTWProgress? = null,
val lotwError: LoTWError? = null,
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
data object BadCredentials : LoTWError
data object RateLimited : LoTWError
data object Timeout : LoTWError
data class Network(val detail: String) : LoTWError
}
sealed interface SettingsAction {
// Position
data object SetGpsPosition : SettingsAction
@@ -99,7 +113,10 @@ sealed interface SettingsAction {
// LoTW confirmed grids
data class UpdateLoTW(val settings: com.rtbishop.look4sat.core.domain.model.LoTWSettings) : SettingsAction
data class SyncLoTWGrids(val settings: com.rtbishop.look4sat.core.domain.model.LoTWSettings) : SettingsAction
data class SyncLoTWGrids(
val settings: com.rtbishop.look4sat.core.domain.model.LoTWSettings,
val mode: LoTWSyncMode
) : SettingsAction
// Update checker
data object CheckForUpdate : SettingsAction
@@ -158,7 +158,7 @@ class SettingsViewModel(
is SettingsAction.SyncWorkedGrids -> syncWorkedGrids(action.settings)
// LoTW confirmed grids
is SettingsAction.UpdateLoTW -> settingsRepo.updateLoTWSettings(action.settings)
is SettingsAction.SyncLoTWGrids -> syncLoTWGrids(action.settings)
is SettingsAction.SyncLoTWGrids -> syncLoTWGrids(action.settings, action.mode)
// Update checker
SettingsAction.CheckForUpdate -> checkForUpdate()
SettingsAction.DownloadUpdate -> downloadUpdate()
@@ -191,61 +191,115 @@ class SettingsViewModel(
}
}
private fun syncLoTWGrids(settings: com.rtbishop.look4sat.core.domain.model.LoTWSettings) {
private fun syncLoTWGrids(
settings: com.rtbishop.look4sat.core.domain.model.LoTWSettings,
mode: LoTWSyncMode
) {
if (!settings.isConfigured) {
_uiState.update { it.copy(lotwMessage = "LoTW callsign/password not configured") }
_uiState.update { it.copy(lotwError = LoTWError.NotConfigured) }
return
}
// Persist the credentials first, then sync with the freshly-typed values.
settingsRepo.updateLoTWSettings(settings)
_uiState.update { it.copy(lotwSyncing = true, lotwMessage = null) }
// Incremental only makes sense for the same callsign as the last sync;
// 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 since = if (effectiveMode == LoTWSyncMode.Incremental) {
settingsRepo.getLastLotwSyncDate()
} else ""
_uiState.update {
it.copy(lotwSyncing = true, lotwSyncMode = effectiveMode, lotwProgress = null, lotwError = null)
}
viewModelScope.launch {
when (val result = lotwRepo.fetchConfirmedGridQsos(settings.callsign, settings.password)) {
val result = lotwRepo.fetchConfirmedGridQsos(callsign, settings.password, since) { progress ->
_uiState.update { it.copy(lotwProgress = progress) }
}
when (result) {
is com.rtbishop.look4sat.core.domain.repository.LoTWResult.Success -> {
// Wavelog entry removed: LoTW is now the only source, so the
// synced set fully replaces the stored worked grids.
settingsRepo.setWorkedGrids(result.grids)
settingsRepo.setWorkedGridQsos(result.qsos)
settingsRepo.setRoamedGrids(result.roamedGrids)
// 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
settingsRepo.setWorkedGrids(mergedGrids)
settingsRepo.setWorkedGridQsos(mergedQsos)
settingsRepo.setRoamedGrids(mergedRoamed)
// 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)
_uiState.update { state ->
state.copy(
lotwSyncing = false, workedGridsCount = result.grids.size,
lotwMessage = null
lotwSyncing = false, lotwSyncMode = null, lotwProgress = null,
workedGridsCount = mergedGrids.size, lotwError = null
)
}
}
is com.rtbishop.look4sat.core.domain.repository.LoTWResult.BadCredentials ->
_uiState.update { state ->
state.copy(
lotwSyncing = false,
lotwMessage = "LoTW sync failed — callsign or password incorrect"
lotwSyncing = false, lotwSyncMode = null, lotwProgress = null,
lotwError = LoTWError.BadCredentials
)
}
is com.rtbishop.look4sat.core.domain.repository.LoTWResult.RateLimited ->
_uiState.update { state ->
state.copy(
lotwSyncing = false,
lotwMessage = "LoTW sync failed — rate limited by server, wait a few minutes and retry"
lotwSyncing = false, lotwSyncMode = null, lotwProgress = null,
lotwError = LoTWError.RateLimited
)
}
is com.rtbishop.look4sat.core.domain.repository.LoTWResult.Timeout ->
_uiState.update { state ->
state.copy(
lotwSyncing = false,
lotwMessage = "LoTW sync failed — connection timed out, try another network"
lotwSyncing = false, lotwSyncMode = null, lotwProgress = null,
lotwError = LoTWError.Timeout
)
}
is com.rtbishop.look4sat.core.domain.repository.LoTWResult.NetworkError ->
_uiState.update { state ->
state.copy(
lotwSyncing = false,
lotwMessage = "LoTW sync failed — network error (${result.detail})"
lotwSyncing = false, lotwSyncMode = null, lotwProgress = null,
lotwError = LoTWError.Network(result.detail)
)
}
}
}
}
/** 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