mirror of
https://github.com/atsunatsu/Look4Sat.git
synced 2026-10-02 03:15:37 +00:00
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:
1 parent
7a865fffd7
commit
fc028f852c
11 files changed
+365
-57
No files matched your search
+100
-18
@@ -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()
|
||||
|
||||
|
||||
+35
-1
@@ -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 }
|
||||
+6
@@ -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>
|
||||
|
||||
+69
-14
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
+16
-2
@@ -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)) }
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+19
-2
@@ -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
|
||||
|
||||
+74
-20
@@ -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
|
||||
|
||||
Reference in new issue
Block a user