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 be72e1a1..80b025bc 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 @@ -22,7 +22,9 @@ 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.ensureActive import kotlinx.coroutines.withContext +import kotlin.coroutines.coroutineContext import java.net.HttpURLConnection import java.net.SocketTimeoutException import java.net.URL @@ -91,7 +93,7 @@ class LoTWRepository : ILoTWRepository { return Triple(grids, qsos, roamed) } - private fun fetchReportBody( + private suspend fun fetchReportBody( callsign: String, password: String, since: String, @@ -151,6 +153,9 @@ class LoTWRepository : ILoTWRepository { !body.contains("", ignoreCase = true) -> Result.failure(RateLimitException()) else -> Result.success(body) } + } catch (e: java.util.concurrent.CancellationException) { + // Cancellation must propagate — it is not a network failure. + throw e } catch (e: SocketTimeoutException) { Result.failure(TimeoutException(e.message ?: "timed out")) } catch (e: SSLException) { @@ -167,7 +172,7 @@ class LoTWRepository : ILoTWRepository { * 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( + private suspend fun readBodyWithProgress( stream: java.io.InputStream, isGzip: Boolean, onProgress: (LoTWProgress) -> Unit @@ -185,6 +190,11 @@ class LoTWRepository : ILoTWRepository { var lastSampleBytes = 0L var lastEmitMs = 0L while (true) { + // Cooperative cancellation: lets the user abort a full sync that + // was started by mistake. Throws CancellationException once the + // job is cancelled; the blocking read below is fast while chunks + // keep arriving, so the abort lands on the next chunk boundary. + coroutineContext.ensureActive() val n = reader.read(buf) if (n <= 0) break sb.append(buf, 0, n) @@ -440,9 +450,11 @@ class LoTWRepository : ILoTWRepository { 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. + * ~9.5 KB/s ≈ 180 s, i.e. ≈ 720 bytes per ADIF record. Use a + * CONSERVATIVE (larger) estimate so the progress bar never hits 100% + * before the download truly ends — an under-estimate made the bar sit + * at 100% while data was still flowing, read as "finished but stuck". */ - const val AVG_RECORD_BYTES = 720L + const val AVG_RECORD_BYTES = 900L } } diff --git a/core/presentation/src/main/res/values-zh/strings.xml b/core/presentation/src/main/res/values-zh/strings.xml index dc28a80e..28ea1316 100644 --- a/core/presentation/src/main/res/values-zh/strings.xml +++ b/core/presentation/src/main/res/values-zh/strings.xml @@ -160,7 +160,6 @@ LoTW 呼号 LoTW 密码 直接从 ARRL LoTW 拉取该呼号已确认 QSO 的网格并覆盖本地已存集合(当前 %1$d 个)。密码仅保存在手机本地。点"同步"会自动保存凭据并拉取。 - 同步通常约需 3–10 秒;首次同步或 ARRL 服务器繁忙时可能更久。 同步 同步中… 已从 LoTW 同步 — 共 %1$d 个网格 @@ -175,6 +174,7 @@ 下载速度约每 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 341e91f6..e52ae89d 100644 --- a/core/presentation/src/main/res/values/strings.xml +++ b/core/presentation/src/main/res/values/strings.xml @@ -195,7 +195,6 @@ LoTW callsign LoTW password Fetch confirmed-QSO grids directly from ARRL LoTW for this callsign and replace the stored set (currently %1$d). The password is stored on this device only. Tapping Sync saves the credentials and fetches in one step. - A sync usually takes about 3–10 seconds; the first one or a busy ARRL server can take longer. Sync Syncing… Synced from LoTW — %1$d grids total @@ -210,6 +209,7 @@ Download runs at ~8 s per 100 QSOs (plus 5–40 s server generation time) Connecting to LoTW… Downloading report… + Processing downloaded data… Syncing %1$d QSOs… Syncing %1$d QSOs, ~%2$d s remaining Set station\'s position using locator 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 ca8f10a7..b1c80fab 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 @@ -233,6 +233,8 @@ fun LoTWDialog( progress: com.rtbishop.look4sat.core.domain.repository.LoTWProgress?, message: String?, dismiss: () -> Unit, + /** Called on cancel/back while a sync is running: aborts the job. */ + onCancelSync: () -> Unit, onSave: (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 @@ -241,8 +243,10 @@ fun LoTWDialog( val pass = rememberSaveable { mutableStateOf(initialSettings.password) } SharedDialog( title = stringResource(R.string.prefs_lotw_title), - onDismissRequest = dismiss, - onCancel = dismiss, + // While syncing, cancel/back aborts the download instead of merely + // hiding the dialog — a full sync started by mistake must be stoppable. + onDismissRequest = { if (isSyncing) onCancelSync() else dismiss() }, + onCancel = { if (isSyncing) onCancelSync() else dismiss() }, // The two sync buttons below are the primary actions: saving // credentials and fetching confirmed grids happen in one step. onAccept = null @@ -276,6 +280,11 @@ fun LoTWDialog( progress == null || progress.phase == com.rtbishop.look4sat.core.domain.repository.LoTWPhase.Connecting -> stringResource(R.string.lotw_sync_progress_connecting) + // Download finished (or the size estimate reached its cap): + // parsing and saving may still run — say so instead of the bar + // sitting at 100% with no feedback, which reads as "stuck". + progress.fraction >= 1f -> + stringResource(R.string.lotw_sync_progress_saving) progress.qsoCount > 0 && progress.remainingSeconds > 0 -> stringResource(R.string.lotw_sync_progress_qso, progress.qsoCount, progress.remainingSeconds) progress.qsoCount > 0 -> @@ -307,12 +316,6 @@ fun LoTWDialog( 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( 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 881c7317..3f89fdbd 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 @@ -223,6 +223,7 @@ private fun SettingsScreen(uiState: SettingsState, onAction: (SettingsAction) -> progress = uiState.lotwProgress, message = lotwErrorMessage, dismiss = { dialogs.lotw = false }, + onCancelSync = { onAction(SettingsAction.CancelLoTWSync); dialogs.lotw = false }, onSave = { onAction(SettingsAction.UpdateLoTW(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 6dfcf13a..0358abf9 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 @@ -117,6 +117,8 @@ sealed interface SettingsAction { val settings: com.rtbishop.look4sat.core.domain.model.LoTWSettings, val mode: LoTWSyncMode ) : SettingsAction + /** Abort an in-flight LoTW sync (wrong button / changed mind). */ + data object CancelLoTWSync : 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 4b9ebb53..4407e027 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 @@ -35,6 +35,7 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import java.io.File class SettingsViewModel( @@ -49,6 +50,8 @@ class SettingsViewModel( private val defaultPosSettings = PositionSettings(false, settingsRepo.stationPosition.value, 0) private val defaultDataSettings = DataSettings(false, 0, 0, 0L) + /** In-flight LoTW sync job, cancelled by CancelLoTWSync. */ + private var lotwSyncJob: kotlinx.coroutines.Job? = null private val _uiState = MutableStateFlow( SettingsState( appVersionName = settingsRepo.appVersionName, @@ -159,6 +162,7 @@ class SettingsViewModel( // LoTW confirmed grids is SettingsAction.UpdateLoTW -> settingsRepo.updateLoTWSettings(action.settings) is SettingsAction.SyncLoTWGrids -> syncLoTWGrids(action.settings, action.mode) + SettingsAction.CancelLoTWSync -> cancelLoTWSync() // Update checker SettingsAction.CheckForUpdate -> checkForUpdate() SettingsAction.DownloadUpdate -> downloadUpdate() @@ -215,7 +219,7 @@ class SettingsViewModel( _uiState.update { it.copy(lotwSyncing = true, lotwSyncMode = effectiveMode, lotwProgress = null, lotwError = null) } - viewModelScope.launch { + lotwSyncJob = viewModelScope.launch { val result = lotwRepo.fetchConfirmedGridQsos(callsign, settings.password, since) { progress -> _uiState.update { it.copy(lotwProgress = progress) } } @@ -235,9 +239,6 @@ class SettingsViewModel( 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) @@ -245,6 +246,16 @@ class SettingsViewModel( .format(java.util.Date()) settingsRepo.setLastLotwSyncDate(today) settingsRepo.setLastLotwSyncCallsign(callsign) + // Persist on the IO dispatcher: building the per-grid QSO JSON + // and writing SharedPreferences synchronously blocks the + // calling thread, and for large accounts (tens of thousands + // of QSOs → multi-MB JSON) that froze the main thread after + // the download finished — 'bar done but app stuck'. + withContext(kotlinx.coroutines.Dispatchers.IO) { + settingsRepo.setWorkedGrids(mergedGrids) + settingsRepo.setWorkedGridQsos(mergedQsos) + settingsRepo.setRoamedGrids(mergedRoamed) + } _uiState.update { state -> state.copy( lotwSyncing = false, lotwSyncMode = null, lotwProgress = null, @@ -284,6 +295,13 @@ class SettingsViewModel( } } + /** Abort the in-flight LoTW sync job and reset its progress state. */ + private fun cancelLoTWSync() { + lotwSyncJob?.cancel() + lotwSyncJob = null + _uiState.update { it.copy(lotwSyncing = false, lotwSyncMode = null, lotwProgress = null) } + } + /** Merge fresh QSO detail into existing per-grid lists, dedup by call + QSO time. */ private fun mergeGridQsos( existing: Map>,