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 00000000..b4203e42
Binary files /dev/null and b/core/data/src/test/resources/test_tqsl_empty.p12 differ
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()