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()