From 5c12d64824a09805dc7f92308d0efbe127821f1c Mon Sep 17 00:00:00 2001 From: atsunatsu Date: Mon, 5 Oct 2026 18:05:42 +0800 Subject: [PATCH] =?UTF-8?q?feat(settings):=20LoTW=20=E7=9C=81=E4=BB=BD?= =?UTF-8?q?=E9=80=89=E6=8B=A9=E5=85=A5=E5=8F=A3=E5=B8=B8=E9=A9=BB=20+=20?= =?UTF-8?q?=E5=AF=BC=E5=85=A5=E5=89=8D=E9=A2=84=E8=A7=A3=E6=9E=90=E5=A1=AB?= =?UTF-8?q?=E5=85=85=E9=80=89=E9=A1=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 省份入口从第一帧就在台站区:DXCC 未知时显示「导入证书后可选」占位,不可展开 - 选中 p12 后 400ms 防抖静默预解析(previewCertificate,只读不落库,密码副本用后清零), 点「确认导入」之前下拉即可选省份;错密码/非 p12 静默保持占位,导入报错行为不变 - 修复导入成功不刷新 stationMeta:此前导入完成后省份下拉要关掉重开对话框才出现 - 无省份字段的国家(德国等)维持隐藏;后缀 seq 防慢预览覆盖新结果 - 新增 LoTWUploadRepositoryPreviewTest 2 例(TQSL 风格空密码夹具 test_tqsl_empty.p12, 实测解析 DXCC 318、不落库、错密码映射 CERTIFICATE_PASSWORD);en/zh 文案 --- .../core/data/lotw/LoTWUploadRepository.kt | 13 +++ .../lotw/LoTWUploadRepositoryPreviewTest.kt | 82 ++++++++++++++++++ .../src/test/resources/test_tqsl_empty.p12 | Bin 0 -> 2515 bytes .../repository/ILoTWUploadRepository.kt | 3 + .../src/main/res/values-zh/strings.xml | 2 + .../src/main/res/values/strings.xml | 2 + .../settings/LoTWUploadConfigDialog.kt | 46 +++++++--- .../feature/settings/LoTWUploadDestination.kt | 3 + .../feature/settings/SettingsScreen.kt | 1 + .../feature/settings/SettingsState.kt | 2 + .../feature/settings/SettingsViewModel.kt | 24 ++++- 11 files changed, 165 insertions(+), 13 deletions(-) create mode 100644 core/data/src/test/java/com/rtbishop/look4sat/core/data/lotw/LoTWUploadRepositoryPreviewTest.kt create mode 100644 core/data/src/test/resources/test_tqsl_empty.p12 diff --git a/core/data/src/main/java/com/rtbishop/look4sat/core/data/lotw/LoTWUploadRepository.kt b/core/data/src/main/java/com/rtbishop/look4sat/core/data/lotw/LoTWUploadRepository.kt index 1960fe52..88efaabb 100644 --- a/core/data/src/main/java/com/rtbishop/look4sat/core/data/lotw/LoTWUploadRepository.kt +++ b/core/data/src/main/java/com/rtbishop/look4sat/core/data/lotw/LoTWUploadRepository.kt @@ -114,6 +114,19 @@ class LoTWUploadRepository internal constructor( } } + override suspend fun previewCertificate(data: ByteArray, password: CharArray): LoTWCertificate = withContext(Dispatchers.IO) { + mutex.withLock { + try { + if (data.isEmpty() || data.size > MAX_CERTIFICATE_BYTES) fail(LoTWProblem.CERTIFICATE_INVALID) + LoTWKeyMaterial.read(data, password, now()).info + } finally { + // Never keep the operator's password; the p12 bytes stay the caller's + // own copy (the dialog re-reads the file for every preview attempt). + password.fill('\u0000') + } + } + } + override suspend fun saveCertificatePassword(password: CharArray): LoTWCertificate = withContext(Dispatchers.IO) { mutex.withLock { val stored = storage.read("certificate") ?: fail(LoTWProblem.CERTIFICATE_MISSING) diff --git a/core/data/src/test/java/com/rtbishop/look4sat/core/data/lotw/LoTWUploadRepositoryPreviewTest.kt b/core/data/src/test/java/com/rtbishop/look4sat/core/data/lotw/LoTWUploadRepositoryPreviewTest.kt new file mode 100644 index 00000000..c35b03e3 --- /dev/null +++ b/core/data/src/test/java/com/rtbishop/look4sat/core/data/lotw/LoTWUploadRepositoryPreviewTest.kt @@ -0,0 +1,82 @@ +/* + * Look4Sat. Amateur radio satellite tracker and pass predictor. + * Copyright (C) 2019-2026 Arty Bishop and contributors. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package com.rtbishop.look4sat.core.data.lotw + +import com.rtbishop.look4sat.core.domain.repository.LoTWOperationException +import com.rtbishop.look4sat.core.domain.repository.LoTWProblem +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * previewCertificate parses a .p12 without touching storage — just enough to + * learn the certificate's DXCC entity, so the settings dialog can offer the + * entity's Province/State dropdown before the operator confirms the import. + * + * Fixture test_tqsl_empty.p12 mirrors a modern TQSL export: PBES2/AES-256, + * empty password, TQSL subject attribute 1.3.6.1.4.1.12348.1.1 = BA7OPF and + * extensions .2/.3/.4 = first/last QSO date, DXCC 318 (China). + */ +class LoTWUploadRepositoryPreviewTest { + + private class MemStorage : LoTWStorage { + val files = mutableMapOf() + override fun read(name: String): ByteArray? = files[name] + override fun write(name: String, data: ByteArray) { files[name] = data.copyOf() } + override fun delete(name: String) { files.remove(name) } + } + + private fun fixture(name: String): ByteArray = + javaClass.classLoader!!.getResourceAsStream(name)!!.use { it.readBytes() } + + private fun repository(storage: MemStorage) = LoTWUploadRepository( + storage, { error("station config not needed") }, now = { System.currentTimeMillis() } + ) + + @Test + fun `preview parses TQSL-style certificate without persisting it`() { + runBlocking { + val storage = MemStorage() + val repo = repository(storage) + val info = repo.previewCertificate(fixture("test_tqsl_empty.p12"), charArrayOf()) + assertEquals("BA7OPF", info.callsign) + assertEquals(318, info.dxcc) + assertEquals("2026-01-01", info.firstQsoDate) + assertTrue("nothing written", storage.files.isEmpty()) + assertNull("no certificate stored", repo.certificate()) + } + } + + @Test + fun `wrong password fails as certificate password and wipes the attempt`() { + runBlocking { + val storage = MemStorage() + val repo = repository(storage) + val password = "definitely-wrong".toCharArray() + val error = assertThrows(LoTWOperationException::class.java) { + runBlocking { repo.previewCertificate(fixture("test_pbes2.p12"), password) } + } + assertEquals(LoTWProblem.CERTIFICATE_PASSWORD, error.reason) + assertTrue("password wiped", password.all { it == '\u0000' }) + assertTrue("nothing written", storage.files.isEmpty()) + } + } +} diff --git a/core/data/src/test/resources/test_tqsl_empty.p12 b/core/data/src/test/resources/test_tqsl_empty.p12 new file mode 100644 index 0000000000000000000000000000000000000000..b4203e4251c3e7446ff751f2b8e02c704c24c3e1 GIT binary patch literal 2515 zcmai$S5Om(7KRf@kdlBjkrE-e1c|VOB3-Hk0Yyb>7Nkcy1R})(Bvd1kKwu3Bkt$L| zP^uuEr8g;>xS$k)y%cE@n&h%`@3@cqaOTYU{yB4={(laN$TQ3V=+#{0ghYGKMD|K|8oFCQ5GnLe?|yO3d9Y~ zV{0sSH=A>Ca05^fF7E$bnA82L{2Itc)mbxE#=;(kH))=6M_~aL2*FdgNeCtz?>tR%3 zx}MZjQyjc9fa>4J7$&8<^qF4>x$MJjjIchTNQU!~N-|kA13*VzKYx>$_qOf<6Ml0C z#!pOOXZ8}4ZZ2*d3aJ2B)-nQ@akcU2!U>R?;65JxxNwK|*6HpET8Np!ENCZ#9yj?S z*8M^-5YrCWRl2hz&Sc4lXKb%^?QYj5xN=VBXGyTTZ}e!F`Zesc9@jrtn%W+*y#C9^ zjH*bpk(@9(yc3)YOi9jsIBiYI&fKz)K-kW4yc8|1I1*|s?bTVH3DbL!c3n*O{1v1f zyWx{N&S=uqQtC{(c_<6aeUww$IeVR9Fy$x}$ynZ!DSMR>`@ks8P)q6Lw#fQ!P@~42 zg`_mM`d=aNp}Etm=wZEz8Q9&?B4)6`V9JQ!72J@&DgriKeKpfL^z40FWUuP^NoKr9NTDjI@=W*9fnO5KQNv@@8 zFYA49YUBP30l6h)gsSbt7qxeiTA{LJuKV;u$i0uzyE1wKDCCqq7aV;>pvpelB{qd4 z?@P*UP2NqNjlk3kGKBXxG$OMsE47mBDmVk|)Z>12@QaXT0cmUj~E<7xa`~3n5=MhkffU&f_w09J>`vx0!RE~^QXHlMl+_I1FkX_ zKO*?y`;Mt1l2Cuf*u4I?l0QFOQnbD7E1Sm0+-+0d`~5Z|W6DK%^OYEsJv(Vkxm*G* zFSu3Qp)D=0tLj?JrToC|nvKLp%>0vWkCgNBz7fq-$*qL|!>6jgb52xqvcDg;b=2xB zX?-oef|VXx@OfQ_p?Z$75GbqQ%};t0i+L0F#W!>UMFhwF^y}XN0KNkvf^o+<;JAFa z`2L>_5MCh1v8ZwkmHy4}#*{{3VLWX9Uk$G<*;|@aA+muKtpATL=NmkT2pUOu*Vi}V zMaADP_>ZD=a#pCwJP%Gn|&v|nyA~l-|}?c zrnzK6j9NO>SopjAfk$bAEuW$$5$v9AUA_yhslAUDU5W&@~xwx4t(@Z*wu&ylY1 zUD4-clt9{r16>s5fNx>-4x`WYuK@A3131>buCE(yMer&a*c5)xwi+Gx^L=KH@Ai4D zyJb-3;MQa55fG?)hBQ^XzN{!jXMIZpe5JB;l+?;hi}Lk#F!ZA~op9!wlhp)^MIA~#Tq`kkUchAvyVTP3u{z3pGT#wqhS6VZZ|zrkg{KtA$2;p0< zhH})9OgQDu%z~UZl{VPU;)%7Zu|wPJ$mGfl@z-L1*qNQ<7qS$@xK|azzWmakm!D}6 zN0-+cu#j$J8XJ5S%PzuItX5o)P7+m-ZxEzeWCzZ*97>_<@hi>VvNUJ1KA&diN#;c&+@h@`ua8buX zOB8<|%UuEm#BVK=g~Y81U1JxTY;lw3?`1KXO!lsr>8&ydh~KzWwm})-uY=)0o24$8x@X(6h7J?7cEGCnMb1Md?mjQPPVU z4@P!qgW6(TTP26X7J?7IAr8keqwSs{8u#idtaF@=v|NoHohAn8uOzEp51QU|&nY#x zsy!Mwu-Qx$pW$_2H|?yeLS#(UaYo8 z14MmF4uYpT-Mo~T;mO{gwcfU!ExjuGSw#+Y_GiHIh^m|di9F}S^*xG13dnG8ewe?P z$i$riBT+^u6%_R6JH!D513)qsG-W>tOFy^(*LL}7Uz$M8F!9u&A+Dp>nH*XtalH)0 Qt%+ZlEjQvI{~fje1@jV=rT_o{ literal 0 HcmV?d00001 diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/ILoTWUploadRepository.kt b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/ILoTWUploadRepository.kt index 555c23da..934c1cb3 100644 --- a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/ILoTWUploadRepository.kt +++ b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/ILoTWUploadRepository.kt @@ -25,6 +25,9 @@ interface ILoTWUploadRepository { suspend fun certificate(): LoTWCertificate? suspend fun station(): LoTWStation? suspend fun importCertificate(data: ByteArray, password: CharArray): LoTWCertificate + /** Parse a .p12 without persisting it — enough to learn the certificate's DXCC + * entity so the station form can offer its region field before the import. */ + suspend fun previewCertificate(data: ByteArray, password: CharArray): LoTWCertificate suspend fun saveCertificatePassword(password: CharArray): LoTWCertificate suspend fun saveStation(station: LoTWStation): LoTWStation suspend fun removeCertificate() diff --git a/core/presentation/src/main/res/values-zh/strings.xml b/core/presentation/src/main/res/values-zh/strings.xml index 93679323..0cfc60bf 100644 --- a/core/presentation/src/main/res/values-zh/strings.xml +++ b/core/presentation/src/main/res/values-zh/strings.xml @@ -242,6 +242,8 @@ 移除证书 台址 未选择 + 省份 + 导入证书后可选 网格(逗号分隔,可多格) 保存 关闭 diff --git a/core/presentation/src/main/res/values/strings.xml b/core/presentation/src/main/res/values/strings.xml index 24f5b0dd..91ae8c56 100644 --- a/core/presentation/src/main/res/values/strings.xml +++ b/core/presentation/src/main/res/values/strings.xml @@ -271,6 +271,8 @@ Remove certificate Station location Not selected + Region + Import certificate to select Grid(s), comma-separated Save Close diff --git a/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/LoTWUploadConfigDialog.kt b/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/LoTWUploadConfigDialog.kt index 00b19478..78cee3a1 100644 --- a/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/LoTWUploadConfigDialog.kt +++ b/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/LoTWUploadConfigDialog.kt @@ -59,6 +59,7 @@ import com.rtbishop.look4sat.core.presentation.CardButton import com.rtbishop.look4sat.core.presentation.LocalSpacing import com.rtbishop.look4sat.core.presentation.R import com.rtbishop.look4sat.core.presentation.SharedDialog +import kotlinx.coroutines.delay @Composable fun LoTWUploadCard( @@ -111,6 +112,7 @@ fun LoTWUploadConfigDialog( initialGrid: String = "", onDismiss: () -> Unit, onImport: (ByteArray, CharArray) -> Unit, + onPreview: (ByteArray, CharArray) -> Unit, onRemove: () -> Unit, onSaveStation: (LoTWStation) -> Unit ) { @@ -155,6 +157,19 @@ fun LoTWUploadConfigDialog( } }.getOrNull() ?: uri.lastPathSegment.orEmpty() + // Surface the certificate's region field before the import is confirmed: re-read + // the picked .p12 whenever file or password changes (debounced) and hand it to a + // silent preview — a wrong password simply keeps the pending hint, a successful + // parse fills the Province/State dropdown in the station form below. + LaunchedEffect(selectedFile, password) { + val uri = selectedFile ?: return@LaunchedEffect + delay(400) + val bytes = runCatching { + context.contentResolver.openInputStream(uri)?.use { it.readBytes() } + }.getOrNull() + if (bytes != null && bytes.isNotEmpty()) onPreview(bytes, password.toCharArray()) + } + SharedDialog( title = stringResource(R.string.prefs_lotw_upload_title), onDismissRequest = onDismiss, @@ -254,12 +269,15 @@ fun LoTWUploadConfigDialog( keyboardOptions = androidx.compose.foundation.text.KeyboardOptions(capitalization = KeyboardCapitalization.Characters), modifier = Modifier.fillMaxWidth() ) - // Country-specific region field (US_STATE, CN_PROVINCE, …) shown only when - // the certificate's DXCC entity defines one; selecting it fills CQZ/ITUZ. + // Country-specific region field (US_STATE, CN_PROVINCE, …): the entry is part + // of the form from the first frame (pending hint while no DXCC is known), gets + // its options as soon as a certificate is previewed/imported, and a selection + // fills CQZ/ITUZ. Entities without a region field keep it hidden. // Drawn as a plain Box (not OutlinedTextField): a read-only text field's // internal gesture handler consumes the tap, so clickable never fires. // Options expand inline inside the sheet (no Popup/Dialog window stacking). - if (regionField != null) { + val regionPending = certificate == null && stationMeta == null + if (regionField != null || regionPending) { var regionExpanded by remember { mutableStateOf(false) } val fieldShape = MaterialTheme.shapes.extraSmall Box( @@ -268,21 +286,25 @@ fun LoTWUploadConfigDialog( .clip(fieldShape) .background(MaterialTheme.colorScheme.surface) .border(1.dp, MaterialTheme.colorScheme.outline, fieldShape) - .clickable { regionExpanded = !regionExpanded } + .clickable(enabled = regionField != null) { regionExpanded = !regionExpanded } .padding(horizontal = 12.dp, vertical = 8.dp) ) { Column { - Text(regionField.label, fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurfaceVariant) + Text( + text = regionField?.label ?: stringResource(R.string.prefs_lotw_upload_region_label), + fontSize = 12.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) Spacer(modifier = Modifier.height(3.dp)) Row(verticalAlignment = Alignment.CenterVertically) { Text( - text = if (region.isBlank()) { - stringResource(R.string.prefs_lotw_upload_region_hint) - } else { - "$region — $selectedRegionName" + text = when { + regionPending -> stringResource(R.string.prefs_lotw_upload_region_pending) + region.isBlank() -> stringResource(R.string.prefs_lotw_upload_region_hint) + else -> "$region — $selectedRegionName" }, fontSize = 16.sp, - color = if (region.isBlank()) { + color = if (regionPending || region.isBlank()) { MaterialTheme.colorScheme.onSurfaceVariant } else { MaterialTheme.colorScheme.onSurface @@ -299,9 +321,9 @@ fun LoTWUploadConfigDialog( } } } - AnimatedVisibility(visible = regionExpanded) { + AnimatedVisibility(visible = regionExpanded && regionField != null) { LazyColumn(modifier = Modifier.fillMaxWidth().heightIn(max = 280.dp)) { - items(regionField.options, key = { it.code }) { option -> + items(regionField?.options.orEmpty(), key = { it.code }) { option -> DropdownMenuItem( text = { Text("${option.code} — ${option.name}", fontSize = 13.sp) }, onClick = { diff --git a/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/LoTWUploadDestination.kt b/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/LoTWUploadDestination.kt index bdba7979..a2fa157c 100644 --- a/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/LoTWUploadDestination.kt +++ b/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/LoTWUploadDestination.kt @@ -64,6 +64,9 @@ fun LoTWUploadDestination(navigateUp: () -> Unit) { onImport = { bytes, password -> viewModel.onAction(SettingsAction.ImportLoTWCertificate(bytes, password)) }, + onPreview = { bytes, password -> + viewModel.onAction(SettingsAction.PreviewLoTWCertificate(bytes, password)) + }, onRemove = { viewModel.onAction(SettingsAction.RemoveLoTWCertificate) }, onSaveStation = { viewModel.onAction(SettingsAction.SaveLoTWStation(it)) } ) 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 23c55b57..18bbe5b2 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 @@ -343,6 +343,7 @@ private fun SettingsScreen( onClearStationGridPrefill() }, onImport = { bytes, password -> onAction(SettingsAction.ImportLoTWCertificate(bytes, password)) }, + onPreview = { bytes, password -> onAction(SettingsAction.PreviewLoTWCertificate(bytes, password)) }, onRemove = { onAction(SettingsAction.RemoveLoTWCertificate) }, onSaveStation = { onAction(SettingsAction.SaveLoTWStation(it)) } ) diff --git a/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsState.kt b/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsState.kt index 21a29708..bffd20f0 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 @@ -208,6 +208,8 @@ sealed interface SettingsAction { // LoTW upload configuration (certificate + station) data object LoadLoTWUploadStatus : SettingsAction data class ImportLoTWCertificate(val bytes: ByteArray, val password: CharArray) : SettingsAction + /** Pre-import .p12 parse (not persisted) so the region field appears before confirming. */ + data class PreviewLoTWCertificate(val bytes: ByteArray, val password: CharArray) : SettingsAction data object RemoveLoTWCertificate : SettingsAction data class SaveLoTWStation(val station: com.rtbishop.look4sat.core.domain.repository.LoTWStation) : 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 268ee3fc..04219af1 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 @@ -267,6 +267,7 @@ class SettingsViewModel( // LoTW upload configuration SettingsAction.LoadLoTWUploadStatus -> loadLoTWUploadStatus() is SettingsAction.ImportLoTWCertificate -> importLoTWCertificate(action.bytes, action.password) + is SettingsAction.PreviewLoTWCertificate -> previewLoTWCertificate(action.bytes, action.password) SettingsAction.RemoveLoTWCertificate -> removeLoTWCertificate() is SettingsAction.SaveLoTWStation -> saveLoTWStation(action.station) // Update checker @@ -788,7 +789,10 @@ class SettingsViewModel( _uiState.update { it.copy(lotwUploadBusy = true, lotwUploadError = null) } try { val cert = lotwUploadRepository.importCertificate(bytes, password) - _uiState.update { it.copy(lotwCertificate = cert, lotwUploadBusy = false, lotwUploadError = null) } + // Publish the region field for the fresh certificate immediately — + // previously it only appeared after closing and reopening the dialog. + val meta = runCatching { lotwUploadRepository.stationMeta(cert.dxcc) }.getOrNull() + _uiState.update { it.copy(lotwCertificate = cert, lotwStationMeta = meta, lotwUploadBusy = false, lotwUploadError = null) } } catch (e: com.rtbishop.look4sat.core.domain.repository.LoTWOperationException) { val error = when (e.reason) { com.rtbishop.look4sat.core.domain.repository.LoTWProblem.CERTIFICATE_PASSWORD -> LoTWUploadError.PASSWORD @@ -803,6 +807,24 @@ class SettingsViewModel( } } + // Monotonic token so a slow stale preview never overwrites a newer one's meta. + private var certificatePreviewSeq = 0 + + private fun previewLoTWCertificate(bytes: ByteArray, password: CharArray) { + val seq = ++certificatePreviewSeq + viewModelScope.launch { + // Silent by design: a wrong password or a non-p12 file must not disturb + // the dialog — only a successful parse publishes the region field. + val meta = runCatching { lotwUploadRepository.previewCertificate(bytes, password) }.fold( + onSuccess = { cert -> runCatching { lotwUploadRepository.stationMeta(cert.dxcc) }.getOrNull() }, + onFailure = { null } + ) + if (meta != null && seq == certificatePreviewSeq) { + _uiState.update { it.copy(lotwStationMeta = meta) } + } + } + } + private fun removeLoTWCertificate() { viewModelScope.launch { lotwUploadRepository.removeCertificate()