mirror of
https://github.com/atsunatsu/Look4Sat.git
synced 2026-10-05 20:17:29 +00:00
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:
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)
|
||||
|
||||
+82
@@ -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.
+3
@@ -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>
|
||||
|
||||
+34
-12
@@ -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 = {
|
||||
|
||||
+3
@@ -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)) }
|
||||
)
|
||||
|
||||
+1
@@ -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
|
||||
|
||||
|
||||
+23
-1
@@ -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()
|
||||
|
||||
Reference in new issue
Block a user