feat(settings): LoTW 省份选择入口常驻 + 导入前预解析填充选项

- 省份入口从第一帧就在台站区:DXCC 未知时显示「导入证书后可选」占位,不可展开
- 选中 p12 后 400ms 防抖静默预解析(previewCertificate,只读不落库,密码副本用后清零),
  点「确认导入」之前下拉即可选省份;错密码/非 p12 静默保持占位,导入报错行为不变
- 修复导入成功不刷新 stationMeta:此前导入完成后省份下拉要关掉重开对话框才出现
- 无省份字段的国家(德国等)维持隐藏;后缀 seq 防慢预览覆盖新结果
- 新增 LoTWUploadRepositoryPreviewTest 2 例(TQSL 风格空密码夹具 test_tqsl_empty.p12,
  实测解析 DXCC 318、不落库、错密码映射 CERTIFICATE_PASSWORD);en/zh 文案
This commit is contained in:
atsunatsu committed 2026-10-05 18:05:42 +08:00
1 parent 881ca442ff
commit 5c12d64824
11 files changed
+165 -13

No files matched your search

@@ -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)
@@ -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 <https://www.gnu.org/licenses/>.
*/
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<String, ByteArray>()
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())
}
}
}
Binary file not shown.
@@ -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()
@@ -242,6 +242,8 @@
<string name="prefs_lotw_upload_remove">移除证书</string>
<string name="prefs_lotw_upload_station_title">台址</string>
<string name="prefs_lotw_upload_region_hint">未选择</string>
<string name="prefs_lotw_upload_region_label">省份</string>
<string name="prefs_lotw_upload_region_pending">导入证书后可选</string>
<string name="prefs_lotw_upload_grid">网格(逗号分隔,可多格)</string>
<string name="prefs_lotw_upload_save">保存</string>
<string name="prefs_lotw_upload_close">关闭</string>
@@ -271,6 +271,8 @@
<string name="prefs_lotw_upload_remove">Remove certificate</string>
<string name="prefs_lotw_upload_station_title">Station location</string>
<string name="prefs_lotw_upload_region_hint">Not selected</string>
<string name="prefs_lotw_upload_region_label">Region</string>
<string name="prefs_lotw_upload_region_pending">Import certificate to select</string>
<string name="prefs_lotw_upload_grid">Grid(s), comma-separated</string>
<string name="prefs_lotw_upload_save">Save</string>
<string name="prefs_lotw_upload_close">Close</string>
@@ -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 = {
@@ -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)) }
)
@@ -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)) }
)
@@ -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
@@ -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()