From fc028f852c26cf591662a5005324b3ebab6129d0 Mon Sep 17 00:00:00 2001 From: atsunatsu Date: Wed, 16 Sep 2026 14:34:00 +0800 Subject: [PATCH] feat(lotw): full/incremental sync modes with live progress and time estimate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 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. --- .../core/data/repository/LoTWRepository.kt | 118 +++++++++++++++--- .../core/data/repository/SettingsRepo.kt | 18 +++ .../core/data/repository/DatabaseRepoTest.kt | 4 + .../core/domain/repository/ILoTWRepository.kt | 36 +++++- .../core/domain/repository/ISettingsRepo.kt | 6 + .../src/main/res/values-zh/strings.xml | 12 ++ .../src/main/res/values/strings.xml | 12 ++ .../feature/settings/SettingsDialog.kt | 83 +++++++++--- .../feature/settings/SettingsScreen.kt | 18 ++- .../feature/settings/SettingsState.kt | 21 +++- .../feature/settings/SettingsViewModel.kt | 94 +++++++++++--- 11 files changed, 365 insertions(+), 57 deletions(-) diff --git a/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/LoTWRepository.kt b/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/LoTWRepository.kt index b76b5dfe..be72e1a1 100644 --- a/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/LoTWRepository.kt +++ b/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/LoTWRepository.kt @@ -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 { + private fun fetchReportBody( + callsign: String, + password: String, + since: String, + onProgress: (LoTWProgress) -> Unit + ): Result { 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 () 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: `2367`. */ + val NUMREC_REGEX = Regex("(\\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 } } 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 1a719925..0061f2a5 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 @@ -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 diff --git a/core/data/src/test/java/com/rtbishop/look4sat/core/data/repository/DatabaseRepoTest.kt b/core/data/src/test/java/com/rtbishop/look4sat/core/data/repository/DatabaseRepoTest.kt index 10b37836..7c54b585 100644 --- a/core/data/src/test/java/com/rtbishop/look4sat/core/data/repository/DatabaseRepoTest.kt +++ b/core/data/src/test/java/com/rtbishop/look4sat/core/data/repository/DatabaseRepoTest.kt @@ -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> = emptyMap() diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/ILoTWRepository.kt b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/ILoTWRepository.kt index 7a66cd4d..51e9677b 100644 --- a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/ILoTWRepository.kt +++ b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/ILoTWRepository.kt @@ -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 ; 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 (); 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 } diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/ISettingsRepo.kt b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/ISettingsRepo.kt index 87f1828d..b1961590 100644 --- a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/ISettingsRepo.kt +++ b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/ISettingsRepo.kt @@ -110,5 +110,11 @@ interface ISettingsRepo { //region # LoTW confirmed-grids settings val lotwSettings: StateFlow 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 } diff --git a/core/presentation/src/main/res/values-zh/strings.xml b/core/presentation/src/main/res/values-zh/strings.xml index 13636955..dc28a80e 100644 --- a/core/presentation/src/main/res/values-zh/strings.xml +++ b/core/presentation/src/main/res/values-zh/strings.xml @@ -165,6 +165,18 @@ 同步中… 已从 LoTW 同步 — 共 %1$d 个网格 未配置 — 直接登录 ARRL LoTW 拉取已确认 QSO 的网格,无需自建服务器 + LoTW 呼号/密码未配置 + LoTW 同步失败——呼号或密码错误 + LoTW 同步失败——服务器限流,请等几分钟后再试 + LoTW 同步失败——连接超时,请更换网络重试 + LoTW 同步失败——网络错误(%1$s) + 全量同步 + 增量合并 + 下载速度约每 100 条 QSO 需 8 秒(另有 5–40 秒服务器生成时间) + 正在连接 LoTW 服务器… + 正在下载报告… + 本次同步 %1$d 条 QSO… + 本次同步 %1$d 条 QSO,预计还需 %2$d 秒 使用梅登黑德网格设置站点位置 卫星数据更新 diff --git a/core/presentation/src/main/res/values/strings.xml b/core/presentation/src/main/res/values/strings.xml index ca1d2b20..341e91f6 100644 --- a/core/presentation/src/main/res/values/strings.xml +++ b/core/presentation/src/main/res/values/strings.xml @@ -200,6 +200,18 @@ Syncing… Synced from LoTW — %1$d grids total Not configured — pull confirmed grids directly from ARRL LoTW, no self-hosted server needed + LoTW callsign/password not configured + LoTW sync failed — callsign or password incorrect + LoTW sync failed — rate limited by server, wait a few minutes and retry + LoTW sync failed — connection timed out, try another network + LoTW sync failed — network error (%1$s) + Full sync + Incremental merge + Download runs at ~8 s per 100 QSOs (plus 5–40 s server generation time) + Connecting to LoTW… + Downloading report… + Syncing %1$d QSOs… + Syncing %1$d QSOs, ~%2$d s remaining Set station\'s position using locator Satellite data 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 a50e7075..ddf4ca6e 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 @@ -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)) } } 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 8346e37f..881c7317 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 @@ -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)) } ) } 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 1c86398d..6dfcf13a 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 @@ -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 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 ddacaf5a..4b9ebb53 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 @@ -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>, + 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