From ea3e04d5db4e38bae142f592c2500ff27356027a Mon Sep 17 00:00:00 2001 From: atsunatsu Date: Tue, 6 Oct 2026 22:13:59 +0800 Subject: [PATCH] =?UTF-8?q?fix(lotw):=20=E7=A9=BA=E5=91=BC=E5=8F=B7?= =?UTF-8?q?=E8=AE=B0=E5=BD=95=E6=8C=89=E8=AF=81=E4=B9=A6=E4=B8=8A=E4=BC=A0?= =?UTF-8?q?=E5=B9=B6=E5=9B=9E=E5=A1=AB=EF=BC=8C=E5=91=BC=E5=8F=B7=E5=86=B2?= =?UTF-8?q?=E7=AA=81=E5=8F=AF=E9=80=89=E6=94=B9=E5=86=99=E6=88=96=E6=8D=A2?= =?UTF-8?q?=E8=AF=81=E4=B9=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 证书装好之前记录的 QSO 自己呼号存空,签名时被判 CALLSIGN_MISMATCH,整批上传失败。 现在这类记录改用当前证书呼号签名,不再拒签:预览提示「其中 N 条记录没有呼号,将按证书 XXX 上传」,上传成功后仅把这些记录的空呼号回填为证书呼号(只填空值,经 save() 写入以 保持去重键一致,不动时间/频率/模式/对方呼号/上传状态与确认状态)。 自己呼号非空且与证书不同的记录仍拒签(不冒签他人记录),但预览给出两个选择: 「改写为 XXX」把冲突记录的自己呼号改成当前证书呼号后即可重传;「换一张证书」在设置页 关掉日志本并打开 LoTW 配置导入另一张证书(雷达页提示去设置页导入)。 新增单测:空呼号签名与计数、冲突记录 id、markUploaded 只填空值、rewriteMyCallsign 只改 冲突记录;并修正测试夹具 MemStorage.read 返回共享数组(被调用方 fill(0) 后破坏已存证书, 与文件存储行为不一致)导致的问题。 --- .../core/data/lotw/LoTWUploadRepository.kt | 23 +++++++- .../core/data/repository/QsoRepository.kt | 31 ++++++++-- .../lotw/LoTWUploadRepositoryPreviewTest.kt | 59 ++++++++++++++++++- .../core/data/repository/QsoRepositoryTest.kt | 30 ++++++++++ .../core/domain/logbook/IQsoRepository.kt | 9 ++- .../repository/ILoTWUploadRepository.kt | 8 ++- .../src/main/res/values-zh/strings.xml | 4 ++ .../src/main/res/values/strings.xml | 4 ++ .../look4sat/feature/radar/LogPage.kt | 34 ++++++++++- .../look4sat/feature/radar/LogViewModel.kt | 30 +++++++++- .../feature/settings/LogbookDialog.kt | 38 +++++++++++- .../feature/settings/SettingsScreen.kt | 9 +++ .../feature/settings/SettingsState.kt | 1 + .../feature/settings/SettingsViewModel.kt | 26 +++++++- 14 files changed, 289 insertions(+), 17 deletions(-) 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 88efaabb..713e356d 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 @@ -267,16 +267,29 @@ class LoTWUploadRepository internal constructor( var duplicates = 0 val unique = hashSetOf() val reasons = mutableMapOf() + val certificateCallsign = signing.key.info.callsign + val substitutedIds = mutableSetOf() + val callsignConflicts = mutableListOf() val contacts = records.sortedBy { it.startUtcMillis }.mapNotNull { record -> coroutineContext.ensureActive() if (record.status != QsoStatus.COMPLETE || ((record.lotwReceived || record.lotwUploaded) && !resubmit)) { skipped++; return@mapNotNull null } + // A record logged while no certificate was installed carries no own callsign: sign + // it with the certificate's callsign (the preview says so, and an accepted upload + // writes the callsign into the record). A record naming a DIFFERENT callsign may be + // someone else's contact, so it stays refused — the conflict list lets the operator + // rewrite those records or import the other certificate. + val signable = if (record.myCallsign.isBlank()) { + substitutedIds += record.id + record.copy(myCallsign = certificateCallsign) + } else record val contact = try { - signing.signer.contact(record, signing.key, signing.location, now()) + signing.signer.contact(signable, signing.key, signing.location, now()) } catch (e: LoTWOperationException) { // One un-signable record must not abort the whole batch: // skip it, count the real reason, and let the rest upload. unavailable++ reasons[e.reason] = (reasons[e.reason] ?: 0) + 1 + if (e.reason == LoTWProblem.CALLSIGN_MISMATCH) callsignConflicts += record.id return@mapNotNull null } val previous = ledger[contact.fingerprint] @@ -299,6 +312,7 @@ class LoTWUploadRepository internal constructor( signing.location["MY_VUCC_GRIDS"]?.split(',') ?.map(String::trim)?.filter(String::isNotBlank)?.let(::addAll) }.distinct() + val submittedIds = contacts.map { it.record.id } val preview = LoTWUploadPreview( UUID.randomUUID().toString(), signing.key.info.callsign, signing.key.info.dxcc, signing.location.getValue("GRIDSQUARE"), contacts.size, skipped, @@ -309,9 +323,12 @@ class LoTWUploadRepository internal constructor( unavailable, reasons.toMap(), duplicates, - contacts.map { it.record.id }, + submittedIds, locationGrids, - resubmit + resubmit, + // Only the callsign-filled records that actually made it into this batch are reported. + submittedIds.count { it in substitutedIds }, + callsignConflicts ) if (contacts.isNotEmpty()) pending = Pending( preview, diff --git a/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/QsoRepository.kt b/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/QsoRepository.kt index d128b41b..a5d046c6 100644 --- a/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/QsoRepository.kt +++ b/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/QsoRepository.kt @@ -82,18 +82,39 @@ class QsoRepository( override suspend fun delete(id: Long) = withContext(dispatcher) { importMutex.withLock { dao.delete(id) } } - override suspend fun markUploaded(ids: List, grids: List) = withContext(dispatcher) { + override suspend fun markUploaded(ids: List, grids: List, certificateCallsign: String) = withContext(dispatcher) { if (ids.isEmpty()) return@withContext dao.markUploaded(ids) // Stamp the station grid set this batch went out under (a line/corner prefill from // the grid finder carries 2–4 grids). The logbook row shows it after the timestamp. val normalized = grids.map { it.trim().uppercase(Locale.US).take(4) } .filter { it.length >= 4 }.distinct().sorted() - if (normalized.isEmpty()) return@withContext - val stamped = ids.mapNotNull { id -> - dao.find(id)?.toDomain()?.takeIf { it.vuccGrids != normalized }?.copy(vuccGrids = normalized) + if (normalized.isNotEmpty()) { + val stamped = ids.mapNotNull { id -> + dao.find(id)?.toDomain()?.takeIf { it.vuccGrids != normalized }?.copy(vuccGrids = normalized) + } + if (stamped.isNotEmpty()) dao.saveBatch(stamped.map { it.toEntity() }) } - if (stamped.isNotEmpty()) dao.saveBatch(stamped.map { it.toEntity() }) + // Records logged while no certificate was installed carry no own callsign; they were just + // signed with the certificate's, so write it into them. Blanks only, and through save() so + // the stored dedupe key follows the new value. + if (certificateCallsign.isNotBlank()) { + ids.mapNotNull { id -> dao.find(id)?.toDomain()?.takeIf { it.myCallsign.isBlank() } } + .forEach { save(it.copy(myCallsign = certificateCallsign.trim().uppercase(Locale.US))) } + } + } + + override suspend fun rewriteMyCallsign(ids: List, callsign: String): Int = withContext(dispatcher) { + val target = callsign.trim().uppercase(Locale.US) + if (ids.isEmpty() || target.isBlank()) return@withContext 0 + var changed = 0 + ids.forEach { id -> + val record = dao.find(id)?.toDomain() ?: return@forEach + if (record.myCallsign.trim().equals(target, true)) return@forEach + save(record.copy(myCallsign = target)) + changed++ + } + changed } override suspend fun markWavelogUploaded(ids: List, stationId: String) = withContext(dispatcher) { 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 index c35b03e3..6712b883 100644 --- 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 @@ -17,8 +17,12 @@ */ package com.rtbishop.look4sat.core.data.lotw +import com.rtbishop.look4sat.core.domain.logbook.QsoRecord +import com.rtbishop.look4sat.core.domain.logbook.QsoStatus import com.rtbishop.look4sat.core.domain.repository.LoTWOperationException import com.rtbishop.look4sat.core.domain.repository.LoTWProblem +import com.rtbishop.look4sat.core.domain.repository.LoTWStation +import java.io.File import kotlinx.coroutines.runBlocking import org.junit.Assert.assertEquals import org.junit.Assert.assertNull @@ -39,7 +43,9 @@ class LoTWUploadRepositoryPreviewTest { private class MemStorage : LoTWStorage { val files = mutableMapOf() - override fun read(name: String): ByteArray? = files[name] + // Reads hand out their own copy, like the file-backed storage does: several call sites + // wipe what they read, and that must not corrupt the stored bytes. + override fun read(name: String): ByteArray? = files[name]?.copyOf() override fun write(name: String, data: ByteArray) { files[name] = data.copyOf() } override fun delete(name: String) { files.remove(name) } } @@ -79,4 +85,55 @@ class LoTWUploadRepositoryPreviewTest { assertTrue("nothing written", storage.files.isEmpty()) } } + + /** + * Records logged before a certificate was installed (blank own callsign) or under another + * certificate are signed with the certificate's callsign instead of being refused, and the + * preview reports how many they were. + */ + @Test + fun `records without a matching callsign upload under the certificate and are reported`() { + runBlocking { + val storage = MemStorage() + val repo = LoTWUploadRepository( + storage, + { LoTWConfig(File("src/main/assets/lotw/config.tq6").inputStream()) }, + now = { System.currentTimeMillis() } + ) + repo.importCertificate(fixture("test_tqsl_empty.p12"), charArrayOf()) + repo.saveStation(LoTWStation(grid = "OL62TI")) + val start = 1_787_000_000_000L // 2026-08, inside the fixture certificate's QSO range + val preview = repo.prepare( + listOf( + sampleRecord(start, "BH6RJD", ""), + sampleRecord(start + 60_000L, "BG7QBL", "XX0YY"), + sampleRecord(start + 120_000L, "BA8BLK", "BA7OPF") + ), + resubmit = false + ) + // The blank record is signed with the certificate's callsign; the one naming another + // callsign is refused and offered up for a rewrite instead. + assertEquals(2, preview.count) + assertEquals(1, preview.missingCallsign) + assertEquals(listOf(start + 60_000L), preview.callsignConflicts) + assertEquals(1, preview.unavailableReasons[LoTWProblem.CALLSIGN_MISMATCH]) + } + } + + private fun sampleRecord(start: Long, call: String, myCallsign: String) = QsoRecord( + id = start, + startUtcMillis = start, + endUtcMillis = start, + theirCallsign = call, + myCallsign = myCallsign, + myGrid = "OL62TI", + txFrequencyHz = 145_850_000L, + rxFrequencyHz = 436_795_000L, + band = "2M", + rxBand = "70CM", + mode = "FM", + satelliteName = "SO-50", + propagationMode = "SAT", + status = QsoStatus.COMPLETE + ) } diff --git a/core/data/src/test/java/com/rtbishop/look4sat/core/data/repository/QsoRepositoryTest.kt b/core/data/src/test/java/com/rtbishop/look4sat/core/data/repository/QsoRepositoryTest.kt index ad613502..d215c987 100644 --- a/core/data/src/test/java/com/rtbishop/look4sat/core/data/repository/QsoRepositoryTest.kt +++ b/core/data/src/test/java/com/rtbishop/look4sat/core/data/repository/QsoRepositoryTest.kt @@ -295,6 +295,36 @@ class QsoRepositoryTest { assertEquals(1, dao.getAll().size) } + @Test + fun markUploaded_fillsBlankCallsignsAndKeepsTheOthers() = runBlocking { + val blank = dao.save(loggedInApp("SO-50", lotwUploaded = false).copy(myCallsign = "").toEntity()) + val named = dao.save(loggedInApp("SO-50", lotwUploaded = false).copy(myCallsign = "BH3HCF").toEntity()) + + repository.markUploaded(listOf(blank, named), emptyList(), "BA7OPF") + + val rows = dao.getAll().associateBy { it.id } + // Records logged before a certificate existed get the certificate's callsign; a record that + // names another callsign is not touched (they are refused, not rewritten, by the uploader). + assertEquals("BA7OPF", rows[blank]!!.myCallsign) + assertEquals("BH3HCF", rows[named]!!.myCallsign) + assertTrue(rows[blank]!!.lotwUploaded) + assertTrue(rows[named]!!.lotwUploaded) + } + + @Test + fun rewriteMyCallsign_rewritesTheConflictRecordsOnly() = runBlocking { + val conflict = dao.save(loggedInApp("SO-50", lotwUploaded = false).copy(myCallsign = "XX0YY").toEntity()) + val kept = dao.save(loggedInApp("SO-50", lotwUploaded = false).copy(myCallsign = "BA7OPF").toEntity()) + + val changed = repository.rewriteMyCallsign(listOf(conflict, kept), "ba7opf") + + assertEquals(1, changed) + val rows = dao.getAll().associateBy { it.id } + assertEquals("BA7OPF", rows[conflict]!!.myCallsign) + assertEquals("BA7OPF", rows[kept]!!.myCallsign) + assertFalse(rows[conflict]!!.lotwUploaded) + } + @Test fun markWavelogUploaded_setsTheFlagAndTheStationStamp() = runBlocking { val id = dao.save(loggedInApp("SO-50", lotwUploaded = false).toEntity()) diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/logbook/IQsoRepository.kt b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/logbook/IQsoRepository.kt index d572712b..77675882 100644 --- a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/logbook/IQsoRepository.kt +++ b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/logbook/IQsoRepository.kt @@ -22,7 +22,14 @@ interface IQsoRepository { * signed with (1–4 gridsquares: inside a grid / on a line / on a corner), so the * logbook row can show which grids the QSO went out under. */ - suspend fun markUploaded(ids: List, grids: List = emptyList()) + suspend fun markUploaded(ids: List, grids: List = emptyList(), certificateCallsign: String = "") + + /** + * Rewrites the operator's callsign on the given records. This is the answer to "these contacts + * were logged under another certificate — upload them under the one installed now". Returns the + * number of records that changed. + */ + suspend fun rewriteMyCallsign(ids: List, callsign: String): Int /** * Marks the batch as uploaded to Wavelog (independent of the LoTW upload state) and 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 934c1cb3..6f1aef88 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 @@ -107,7 +107,13 @@ data class LoTWUploadPreview( val grids: List = emptyList(), /** True when this batch is a resubmit: already-uploaded/confirmed records were allowed * through so the corrected station location reaches LoTW as an update of the contact. */ - val resubmit: Boolean = false + val resubmit: Boolean = false, + /** Records in this batch whose own callsign is blank (logged before a certificate was + * installed). They are signed with [callsign]; a successful upload writes it into them. */ + val missingCallsign: Int = 0, + /** Records refused because they name a different own callsign than [callsign]. The upload + * flow offers either rewriting them with [callsign] or using the other certificate. */ + val callsignConflicts: List = emptyList() ) /** Pre-upload location check: the freshest known position grid is not covered by the diff --git a/core/presentation/src/main/res/values-zh/strings.xml b/core/presentation/src/main/res/values-zh/strings.xml index 9e34e68e..45fb505c 100644 --- a/core/presentation/src/main/res/values-zh/strings.xml +++ b/core/presentation/src/main/res/values-zh/strings.xml @@ -216,6 +216,10 @@ 已选 %1$d 条 长按记录行可多选重传 已上传记录将按当前台址就地更新;已被奖项使用的旧网格可能被拒绝。 + 其中 %1$d 条记录没有呼号,将按证书 %2$s 上传。 + %1$d 条记录登记的不是证书 %2$s 的呼号。 + 改写为 %1$s + 换一张证书 台址 全部 上传到 Wavelog diff --git a/core/presentation/src/main/res/values/strings.xml b/core/presentation/src/main/res/values/strings.xml index 07ec3c15..b07db58f 100644 --- a/core/presentation/src/main/res/values/strings.xml +++ b/core/presentation/src/main/res/values/strings.xml @@ -245,6 +245,10 @@ %1$d selected Long-press a row to re-upload corrected records Existing contacts will be updated in place — a grid already used for an award may be rejected. + %1$d record(s) have no callsign — signed with %2$s from the certificate. + %1$d record(s) name another callsign than the certificate %2$s. + Rewrite with %1$s + Use another certificate Station All Upload to Wavelog diff --git a/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/LogPage.kt b/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/LogPage.kt index 6cf2b3f5..f0eced3a 100644 --- a/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/LogPage.kt +++ b/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/LogPage.kt @@ -262,7 +262,9 @@ fun LogPage( preview = preview, busy = logUiState.busy, onConfirm = logViewModel::confirmUpload, - onDismiss = logViewModel::dismissPreview + onDismiss = logViewModel::dismissPreview, + onRewriteCallsign = logViewModel::rewriteCallsignConflicts, + onSwitchCertificate = logViewModel::switchCertificateForConflicts ) } } @@ -417,7 +419,9 @@ private fun UploadPreviewDialog( preview: com.rtbishop.look4sat.core.domain.repository.LoTWUploadPreview, busy: Boolean, onConfirm: () -> Unit, - onDismiss: () -> Unit + onDismiss: () -> Unit, + onRewriteCallsign: () -> Unit, + onSwitchCertificate: () -> Unit ) { AlertDialog( onDismissRequest = onDismiss, @@ -464,6 +468,32 @@ private fun UploadPreviewDialog( } Text(parts.joinToString(" · "), fontSize = 12.sp, color = MaterialTheme.colorScheme.error) } + if (preview.missingCallsign > 0) { + // Logged while no certificate was installed; signed with the certificate's + // callsign instead of being refused. + Text( + stringResource(R.string.prefs_logbook_callsign_missing, preview.missingCallsign, preview.callsign), + fontSize = 12.sp, + color = MaterialTheme.colorScheme.primary + ) + } + if (preview.callsignConflicts.isNotEmpty()) { + // These name another callsign than the certificate: rewrite them with this + // certificate's callsign, or upload them under the other certificate. + Text( + stringResource(R.string.prefs_logbook_callsign_different, preview.callsignConflicts.size, preview.callsign), + fontSize = 12.sp, + color = MaterialTheme.colorScheme.error + ) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + TextButton(onClick = onRewriteCallsign, enabled = !busy) { + Text(stringResource(R.string.prefs_logbook_callsign_rewrite, preview.callsign), fontSize = 12.sp) + } + TextButton(onClick = onSwitchCertificate, enabled = !busy) { + Text(stringResource(R.string.prefs_logbook_callsign_switch), fontSize = 12.sp) + } + } + } Text(preview.contacts.joinToString("\n") { it }, fontSize = 12.sp, maxLines = 8) } }, diff --git a/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/LogViewModel.kt b/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/LogViewModel.kt index 91a2338b..ab8f9b50 100644 --- a/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/LogViewModel.kt +++ b/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/LogViewModel.kt @@ -343,7 +343,7 @@ class LogViewModel( if (result is LoTWUploadResult.Accepted && lastUploadedIds.isNotEmpty()) { // Mark the submitted QSOs as uploaded (distinct from confirmed) and stamp // the station grids this batch went out under. - qsoRepository.markUploaded(lastUploadedIds, preview.grids) + qsoRepository.markUploaded(lastUploadedIds, preview.grids, preview.callsign) lastUploadedIds = emptyList() } val msg = when (result) { @@ -368,6 +368,34 @@ class LogViewModel( fun dismissPreview() = _uiState.update { it.copy(preview = null) } + /** + * Operator chose "rewrite" on the callsign conflict: the refused records get this certificate's + * callsign so the next upload can sign them. The prepared batch is dropped — nothing was sent. + */ + fun rewriteCallsignConflicts() { + val preview = _uiState.value.preview ?: return + val ids = preview.callsignConflicts + if (ids.isEmpty()) return + viewModelScope.launch { + _uiState.update { it.copy(busy = true) } + val changed = qsoRepository.rewriteMyCallsign(ids, preview.callsign) + lotwUploadRepository.discardPreview() + lastUploadedIds = emptyList() + _uiState.update { + it.copy(busy = false, preview = null, message = "$changed record(s) rewritten to ${preview.callsign} — upload again") + } + } + } + + /** Operator chose "use another certificate": that certificate is imported on the settings page. */ + fun switchCertificateForConflicts() { + lotwUploadRepository.discardPreview() + lastUploadedIds = emptyList() + _uiState.update { + it.copy(preview = null, message = "Import the other callsign's certificate in Settings → LoTW upload, then upload again") + } + } + fun dismissWavelogPreview() = _uiState.update { it.copy(wavelogPreview = null) } /** diff --git a/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/LogbookDialog.kt b/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/LogbookDialog.kt index ef4dbcfb..5cdb3e10 100644 --- a/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/LogbookDialog.kt +++ b/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/LogbookDialog.kt @@ -103,6 +103,10 @@ fun LogbookDialog( onDismissMessage: () -> Unit, onIgnorePositionWarning: () -> Unit = {}, onFixGrid: (List) -> Unit = {}, + /** Rewrite the records whose own callsign differs from the certificate's. */ + onRewriteCallsign: () -> Unit = {}, + /** Leave the upload to import a different certificate instead. */ + onSwitchCertificate: () -> Unit = {}, selectionMode: Boolean = false, selectedIds: Set = emptySet(), onStartSelection: (Long) -> Unit = {}, @@ -246,7 +250,9 @@ fun LogbookDialog( preview = preview, busy = uploadBusy, onConfirm = onConfirmUpload, - onDismiss = onDismissPreview + onDismiss = onDismissPreview, + onRewriteCallsign = onRewriteCallsign, + onSwitchCertificate = onSwitchCertificate ) } if (uploadMessage.isNotBlank()) { @@ -280,7 +286,9 @@ private fun LogbookUploadPreviewDialog( preview: com.rtbishop.look4sat.core.domain.repository.LoTWUploadPreview, busy: Boolean, onConfirm: () -> Unit, - onDismiss: () -> Unit + onDismiss: () -> Unit, + onRewriteCallsign: () -> Unit, + onSwitchCertificate: () -> Unit ) { AlertDialog( onDismissRequest = onDismiss, @@ -319,6 +327,32 @@ private fun LogbookUploadPreviewDialog( color = MaterialTheme.colorScheme.primary ) } + if (preview.missingCallsign > 0) { + // Logged while no certificate was installed; signed with the certificate's + // callsign instead of being refused. + Text( + stringResource(R.string.prefs_logbook_callsign_missing, preview.missingCallsign, preview.callsign), + fontSize = 12.sp, + color = MaterialTheme.colorScheme.primary + ) + } + if (preview.callsignConflicts.isNotEmpty()) { + // These name another callsign than the certificate: the operator either rewrites + // them with the certificate's callsign or uploads them under that other one. + Text( + stringResource(R.string.prefs_logbook_callsign_different, preview.callsignConflicts.size, preview.callsign), + fontSize = 12.sp, + color = MaterialTheme.colorScheme.error + ) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + TextButton(onClick = onRewriteCallsign, enabled = !busy) { + Text(stringResource(R.string.prefs_logbook_callsign_rewrite, preview.callsign), fontSize = 12.sp) + } + TextButton(onClick = onSwitchCertificate, enabled = !busy) { + Text(stringResource(R.string.prefs_logbook_callsign_switch), fontSize = 12.sp) + } + } + } Text(preview.contacts.joinToString("\n") { it }, fontSize = 12.sp, maxLines = 8) } }, 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 18bbe5b2..ea7b0aad 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 @@ -308,6 +308,15 @@ private fun SettingsScreen( onConfirmUpload = { onAction(SettingsAction.ConfirmLogbookUpload) }, onDismissPreview = { onAction(SettingsAction.DismissLogbookPreview) }, onDismissMessage = { onAction(SettingsAction.ClearLogbookMessage) }, + onRewriteCallsign = { onAction(SettingsAction.RewriteLogbookCallsigns) }, + onSwitchCertificate = { + // The other callsign's certificate lives in the LoTW upload settings: leave the + // logbook and open that dialog. + onAction(SettingsAction.DismissLogbookPreview) + onAction(SettingsAction.ExitLogbookSelection) + dialogs.logbook = false + dialogs.lotwUpload = true + }, onIgnorePositionWarning = { onAction(SettingsAction.IgnoreLogbookPositionWarning) }, onFixGrid = { grids -> onAction(SettingsAction.AbandonLogbookForGridFix) 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 bffd20f0..07a33b74 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 @@ -193,6 +193,7 @@ sealed interface SettingsAction { data object PrepareLogbookUpload : SettingsAction data object ConfirmLogbookUpload : SettingsAction data object DismissLogbookPreview : SettingsAction + data object RewriteLogbookCallsigns : SettingsAction /** The operator acknowledged the position mismatch and wants to upload anyway. */ data object IgnoreLogbookPositionWarning : SettingsAction /** The operator chose to fix the station location first; the preview is discarded. */ 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 04219af1..a3e74260 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 @@ -257,6 +257,7 @@ class SettingsViewModel( SettingsAction.PrepareLogbookUpload -> prepareLogbookUpload() SettingsAction.ConfirmLogbookUpload -> confirmLogbookUpload() SettingsAction.DismissLogbookPreview -> dismissLogbookPreview() + SettingsAction.RewriteLogbookCallsigns -> rewriteLogbookCallsigns() SettingsAction.IgnoreLogbookPositionWarning -> ignoreLogbookPositionWarning() SettingsAction.AbandonLogbookForGridFix -> abandonLogbookForGridFix() is SettingsAction.StartLogbookSelection -> startLogbookSelection(action.id) @@ -648,7 +649,7 @@ class SettingsViewModel( val result = lotwUploadRepository.upload(preview.id) val accepted = result is com.rtbishop.look4sat.core.domain.repository.LoTWUploadResult.Accepted if (accepted && lastLogbookUploadIds.isNotEmpty()) { - qsoRepository.markUploaded(lastLogbookUploadIds, preview.grids) + qsoRepository.markUploaded(lastLogbookUploadIds, preview.grids, preview.callsign) lastLogbookUploadIds = emptyList() } val msg = when (result) { @@ -673,6 +674,29 @@ class SettingsViewModel( private fun dismissLogbookPreview() = _uiState.update { it.copy(logbookPreview = null, logbookWavelogPreview = null) } + /** + * Operator chose "rewrite" on the callsign conflict: the refused records get this certificate's + * callsign so the next upload can sign them. The batch is discarded — nothing was uploaded. + */ + private fun rewriteLogbookCallsigns() { + val preview = _uiState.value.logbookPreview ?: return + val ids = preview.callsignConflicts + if (ids.isEmpty()) return + viewModelScope.launch { + _uiState.update { it.copy(logbookUploadBusy = true) } + val changed = qsoRepository.rewriteMyCallsign(ids, preview.callsign) + lotwUploadRepository.discardPreview() + lastLogbookUploadIds = emptyList() + _uiState.update { + it.copy( + logbookUploadBusy = false, + logbookPreview = null, + logbookUploadMessage = "$changed record(s) rewritten to ${preview.callsign} — upload again" + ) + } + } + } + /** Operator chose "ignore" on the position check: keep the prepared preview. */ private fun ignoreLogbookPositionWarning() = _uiState.update { it.copy(logbookPositionWarning = null) }