diff --git a/core/data/src/main/java/com/rtbishop/look4sat/core/data/lotw/LoTWSigner.kt b/core/data/src/main/java/com/rtbishop/look4sat/core/data/lotw/LoTWSigner.kt index 1d108d6a..6401bbca 100644 --- a/core/data/src/main/java/com/rtbishop/look4sat/core/data/lotw/LoTWSigner.kt +++ b/core/data/src/main/java/com/rtbishop/look4sat/core/data/lotw/LoTWSigner.kt @@ -37,7 +37,12 @@ internal class LoTWSigner(private val config: LoTWConfig) { val call = record.theirCallsign.trim().uppercase(Locale.US) if (record.status != QsoStatus.COMPLETE || !call.matches(Regex("[A-Z0-9]+(/[A-Z0-9]+)*")) || !call.any(Char::isLetter) || !call.any(Char::isDigit)) fail(LoTWProblem.INVALID_CONTACT, call) - if (!record.myCallsign.trim().equals(key.info.callsign, true)) fail(LoTWProblem.CALLSIGN_MISMATCH, call) + // Report the two callsigns the operator has to reconcile (record vs certificate). A generic + // sample — the opposite station's call — says nothing about which side is wrong, which is + // exactly what a "MY callsign does not match the certificate" message must tell. + if (!record.myCallsign.trim().equals(key.info.callsign, true)) { + fail(LoTWProblem.CALLSIGN_MISMATCH, "${record.myCallsign.trim()} ≠ ${key.info.callsign}") + } val date = utc(record.startUtcMillis, "yyyy-MM-dd") // A record dated after "now" gets its own message: it happens when the operator // logged against a pass that had not started yet (out-of-window flow), and "fix the 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 6712b883..2d07c5d6 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 @@ -22,6 +22,7 @@ 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 com.rtbishop.look4sat.core.domain.repository.LoTWUploadResult import java.io.File import kotlinx.coroutines.runBlocking import org.junit.Assert.assertEquals @@ -120,6 +121,55 @@ class LoTWUploadRepositoryPreviewTest { } } + /** Nothing signable at all: the preview still comes back (count 0) with the conflicts listed, + * so the UI can offer the rewrite / use-another-certificate actions instead of a dead end. */ + @Test + fun `fully blocked batch still returns a preview with the conflicts`() { + 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 + val preview = repo.prepare( + listOf( + sampleRecord(start, "BH6RJD", "XX0YY"), + sampleRecord(start + 60_000L, "BG7QBL", "XX0ZZ") + ), + resubmit = false + ) + assertEquals(0, preview.count) + assertEquals(2, preview.unavailableSkipped) + assertEquals(listOf(start, start + 60_000L), preview.callsignConflicts) + assertEquals(2, preview.unavailableReasons[LoTWProblem.CALLSIGN_MISMATCH]) + assertEquals(0, preview.missingCallsign) + // No payload was prepared: confirming this preview must fail as expired, not crash. + assertTrue(repo.upload(preview.id) is LoTWUploadResult.ExpiredPreview) + } + } + + /** The mismatch reason names both callsigns (record vs certificate). The old detail showed the + * opposite station's call, which told the operator nothing about which side to fix. */ + @Test + fun `callsign mismatch detail names the record and the certificate callsign`() { + 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 audit = repo.audit(listOf(sampleRecord(1_787_000_000_000L, "BH6RJD", "XX0YY"))) + assertEquals("XX0YY ≠ BA7OPF", audit.details[LoTWProblem.CALLSIGN_MISMATCH]) + } + } + private fun sampleRecord(start: Long, call: String, myCallsign: String) = QsoRecord( id = start, startUtcMillis = start, diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/logbook/LoTWUploadMessages.kt b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/logbook/LoTWUploadMessages.kt index 8206553e..19087b9a 100644 --- a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/logbook/LoTWUploadMessages.kt +++ b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/logbook/LoTWUploadMessages.kt @@ -18,6 +18,7 @@ package com.rtbishop.look4sat.core.domain.logbook import com.rtbishop.look4sat.core.domain.repository.LoTWProblem +import com.rtbishop.look4sat.core.domain.repository.LoTWUploadAudit /** * Wording for the reasons a QSO cannot be signed for LoTW. @@ -69,3 +70,14 @@ fun unavailableUploadSummary( val detail = parts.joinToString(", ").ifBlank { "check the logbook" } return "$subject — $detail" } + +/** + * True when a batch that has nothing uploadable still has to reach the preview dialog. + * + * The preview is the only place that carries the callsign-conflict actions (rewrite the records, or + * upload them under another certificate). Short-circuiting such a batch into the plain "can't be + * uploaded" message strands exactly the records the operator needs to act on — the message has no + * buttons to get out of, so those QSOs could never be uploaded. + */ +fun LoTWUploadAudit.needsPreviewForConflicts(): Boolean = + pending == 0 && (reasons[LoTWProblem.CALLSIGN_MISMATCH] ?: 0) > 0 diff --git a/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/logbook/LoTWUploadMessagesTest.kt b/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/logbook/LoTWUploadMessagesTest.kt new file mode 100644 index 00000000..930d15a0 --- /dev/null +++ b/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/logbook/LoTWUploadMessagesTest.kt @@ -0,0 +1,59 @@ +/* + * 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.domain.logbook + +import com.rtbishop.look4sat.core.domain.repository.LoTWProblem +import com.rtbishop.look4sat.core.domain.repository.LoTWUploadAudit +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * The preview dialog is the only place carrying the callsign-conflict actions (rewrite / use another + * certificate), so a conflict-only batch must NOT be short-circuited into the plain + * "can't be uploaded" message: that dialog has no buttons to get out of. + */ +class LoTWUploadMessagesTest { + + @Test + fun `conflict-only batch asks for the preview even with nothing pending`() { + assertTrue(audit(pending = 0, mismatch = 107, satellite = 1).needsPreviewForConflicts()) + } + + @Test + fun `blocked batch without conflicts keeps the plain message`() { + assertFalse(audit(pending = 0, mismatch = 0, satellite = 3).needsPreviewForConflicts()) + } + + @Test + fun `batch with something to upload never takes the conflict fallback`() { + assertFalse(audit(pending = 5, mismatch = 2, satellite = 1).needsPreviewForConflicts()) + } + + private fun audit(pending: Int, mismatch: Int, satellite: Int) = LoTWUploadAudit( + total = 108, + pending = pending, + uploaded = 0, + unknown = 0, + unavailable = 108 - pending, + reasons = buildMap { + if (mismatch > 0) put(LoTWProblem.CALLSIGN_MISMATCH, mismatch) + if (satellite > 0) put(LoTWProblem.SATELLITE, satellite) + } + ) +} 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 f0eced3a..ffedd950 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 @@ -431,7 +431,10 @@ private fun UploadPreviewDialog( text = { Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { Text("${preview.callsign} DXCC ${preview.dxcc} Grid ${preview.grid}", fontSize = 13.sp) - Text("${preview.count} QSO(s) · ${ + val range = if (preview.firstUtc.isBlank()) { + // A blocked batch (shown for its conflict actions) has no date range to show. + "${preview.count} QSO(s)" + } else "${preview.count} QSO(s) · ${ java.text.SimpleDateFormat("MM-dd HH:mm", java.util.Locale.US).apply { timeZone = java.util.TimeZone.getTimeZone("UTC") }.format(java.util.Date(preview.firstUtc.let { @@ -447,7 +450,8 @@ private fun UploadPreviewDialog( timeZone = java.util.TimeZone.getTimeZone("UTC") }.parse(it).time }.getOrDefault(System.currentTimeMillis()) })) - }Z", fontSize = 13.sp) + }Z" + Text(range, fontSize = 13.sp) if (preview.skipped > 0 || preview.unknownSkipped > 0 || preview.unavailableSkipped > 0) { val parts = buildList { // skipped counts every record left out of this batch: previously-uploaded @@ -498,7 +502,8 @@ private fun UploadPreviewDialog( } }, confirmButton = { - TextButton(onClick = onConfirm, enabled = !busy) { Text("确认上传") } + // Nothing uploadable (a conflict-only batch): the actions above are the way out. + TextButton(onClick = onConfirm, enabled = !busy && preview.count > 0) { Text("确认上传") } }, dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } 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 ab8f9b50..fc61d2e7 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 @@ -18,6 +18,7 @@ import com.rtbishop.look4sat.core.domain.logbook.QsoRecord import com.rtbishop.look4sat.core.domain.logbook.QsoStatus import com.rtbishop.look4sat.core.domain.logbook.displayMode import com.rtbishop.look4sat.core.domain.logbook.frequencyBand +import com.rtbishop.look4sat.core.domain.logbook.needsPreviewForConflicts import com.rtbishop.look4sat.core.domain.logbook.officialSatelliteName import com.rtbishop.look4sat.core.domain.logbook.satelliteIdentity import com.rtbishop.look4sat.core.domain.logbook.unavailableUploadSummary @@ -286,7 +287,10 @@ class LogViewModel( // LoTW-imported confirmations are the feedback side. val pending = all.filter { !it.lotwConfirmed && !it.lotwUploaded && it.status == QsoStatus.COMPLETE } val audit = lotwUploadRepository.audit(pending) - if (audit.pending == 0) { + // A batch held back only by callsign conflicts still gets the preview: that dialog + // carries the rewrite / use-another-certificate actions. Without this the operator + // gets a dead-end message and those records can never be uploaded. + if (audit.pending == 0 && !audit.needsPreviewForConflicts()) { val msg = when { audit.unavailable > 0 -> unavailableUploadSummary( audit.unavailable, audit.reasons, audit.details, audit.duplicates, audit.incomplete 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 5cdb3e10..72b5a8dd 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 @@ -299,7 +299,9 @@ private fun LogbookUploadPreviewDialog( Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { Text("${preview.callsign} DXCC ${preview.dxcc} Grid ${preview.grid}", fontSize = 13.sp) Text( - "${preview.count} QSO(s) · ${preview.firstUtc} – ${preview.lastUtc}", + // A blocked batch (shown for its conflict actions) has no date range to show. + if (preview.firstUtc.isBlank()) "${preview.count} QSO(s)" + else "${preview.count} QSO(s) · ${preview.firstUtc} – ${preview.lastUtc}", fontSize = 13.sp ) if (preview.skipped > 0 || preview.unknownSkipped > 0 || preview.unavailableSkipped > 0) { @@ -357,7 +359,9 @@ private fun LogbookUploadPreviewDialog( } }, confirmButton = { - TextButton(onClick = onConfirm, enabled = !busy) { + // Nothing uploadable (a conflict-only batch): the actions above are the way out, so the + // upload button stays disabled instead of failing with an expired preview. + TextButton(onClick = onConfirm, enabled = !busy && preview.count > 0) { Text(stringResource(R.string.prefs_logbook_upload_confirm)) } }, 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 a3e74260..db91c155 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 @@ -46,6 +46,7 @@ import com.rtbishop.look4sat.core.domain.repository.WavelogUploadOutcome import com.rtbishop.look4sat.core.domain.repository.WavelogUploadPreview import com.rtbishop.look4sat.core.domain.usecase.IShowToast import com.rtbishop.look4sat.core.domain.utility.VersionComparator +import com.rtbishop.look4sat.core.domain.logbook.needsPreviewForConflicts import com.rtbishop.look4sat.core.domain.logbook.resubmitCandidates import com.rtbishop.look4sat.core.domain.logbook.toConfirmedRecord import com.rtbishop.look4sat.core.domain.logbook.unavailableUploadSummary @@ -576,7 +577,10 @@ class SettingsViewModel( // LoTW-imported confirmations are the feedback side. val pending = all.filter { !it.lotwConfirmed && !it.lotwUploaded && it.status == com.rtbishop.look4sat.core.domain.logbook.QsoStatus.COMPLETE } val audit = lotwUploadRepository.audit(pending) - if (audit.pending == 0) { + // A batch held back only by callsign conflicts still gets the preview: that dialog + // carries the rewrite / use-another-certificate actions. Without this the operator + // gets a dead-end message and those records can never be uploaded. + if (audit.pending == 0 && !audit.needsPreviewForConflicts()) { val msg = when { audit.unavailable > 0 -> com.rtbishop.look4sat.core.domain.logbook.unavailableUploadSummary( audit.unavailable, audit.reasons, audit.details, audit.duplicates, audit.incomplete