mirror of
https://github.com/atsunatsu/Look4Sat.git
synced 2026-10-09 05:47:28 +00:00
fix(lotw): 空呼号记录按证书上传并回填,呼号冲突可选改写或换证书
证书装好之前记录的 QSO 自己呼号存空,签名时被判 CALLSIGN_MISMATCH,整批上传失败。 现在这类记录改用当前证书呼号签名,不再拒签:预览提示「其中 N 条记录没有呼号,将按证书 XXX 上传」,上传成功后仅把这些记录的空呼号回填为证书呼号(只填空值,经 save() 写入以 保持去重键一致,不动时间/频率/模式/对方呼号/上传状态与确认状态)。 自己呼号非空且与证书不同的记录仍拒签(不冒签他人记录),但预览给出两个选择: 「改写为 XXX」把冲突记录的自己呼号改成当前证书呼号后即可重传;「换一张证书」在设置页 关掉日志本并打开 LoTW 配置导入另一张证书(雷达页提示去设置页导入)。 新增单测:空呼号签名与计数、冲突记录 id、markUploaded 只填空值、rewriteMyCallsign 只改 冲突记录;并修正测试夹具 MemStorage.read 返回共享数组(被调用方 fill(0) 后破坏已存证书, 与文件存储行为不一致)导致的问题。
This commit is contained in:
14 files changed
+289
-17
No files matched your search
+20
-3
@@ -267,16 +267,29 @@ class LoTWUploadRepository internal constructor(
|
|||||||
var duplicates = 0
|
var duplicates = 0
|
||||||
val unique = hashSetOf<String>()
|
val unique = hashSetOf<String>()
|
||||||
val reasons = mutableMapOf<LoTWProblem, Int>()
|
val reasons = mutableMapOf<LoTWProblem, Int>()
|
||||||
|
val certificateCallsign = signing.key.info.callsign
|
||||||
|
val substitutedIds = mutableSetOf<Long>()
|
||||||
|
val callsignConflicts = mutableListOf<Long>()
|
||||||
val contacts = records.sortedBy { it.startUtcMillis }.mapNotNull { record ->
|
val contacts = records.sortedBy { it.startUtcMillis }.mapNotNull { record ->
|
||||||
coroutineContext.ensureActive()
|
coroutineContext.ensureActive()
|
||||||
if (record.status != QsoStatus.COMPLETE || ((record.lotwReceived || record.lotwUploaded) && !resubmit)) { skipped++; return@mapNotNull null }
|
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 {
|
val contact = try {
|
||||||
signing.signer.contact(record, signing.key, signing.location, now())
|
signing.signer.contact(signable, signing.key, signing.location, now())
|
||||||
} catch (e: LoTWOperationException) {
|
} catch (e: LoTWOperationException) {
|
||||||
// One un-signable record must not abort the whole batch:
|
// One un-signable record must not abort the whole batch:
|
||||||
// skip it, count the real reason, and let the rest upload.
|
// skip it, count the real reason, and let the rest upload.
|
||||||
unavailable++
|
unavailable++
|
||||||
reasons[e.reason] = (reasons[e.reason] ?: 0) + 1
|
reasons[e.reason] = (reasons[e.reason] ?: 0) + 1
|
||||||
|
if (e.reason == LoTWProblem.CALLSIGN_MISMATCH) callsignConflicts += record.id
|
||||||
return@mapNotNull null
|
return@mapNotNull null
|
||||||
}
|
}
|
||||||
val previous = ledger[contact.fingerprint]
|
val previous = ledger[contact.fingerprint]
|
||||||
@@ -299,6 +312,7 @@ class LoTWUploadRepository internal constructor(
|
|||||||
signing.location["MY_VUCC_GRIDS"]?.split(',')
|
signing.location["MY_VUCC_GRIDS"]?.split(',')
|
||||||
?.map(String::trim)?.filter(String::isNotBlank)?.let(::addAll)
|
?.map(String::trim)?.filter(String::isNotBlank)?.let(::addAll)
|
||||||
}.distinct()
|
}.distinct()
|
||||||
|
val submittedIds = contacts.map { it.record.id }
|
||||||
val preview = LoTWUploadPreview(
|
val preview = LoTWUploadPreview(
|
||||||
UUID.randomUUID().toString(), signing.key.info.callsign, signing.key.info.dxcc, signing.location.getValue("GRIDSQUARE"),
|
UUID.randomUUID().toString(), signing.key.info.callsign, signing.key.info.dxcc, signing.location.getValue("GRIDSQUARE"),
|
||||||
contacts.size, skipped,
|
contacts.size, skipped,
|
||||||
@@ -309,9 +323,12 @@ class LoTWUploadRepository internal constructor(
|
|||||||
unavailable,
|
unavailable,
|
||||||
reasons.toMap(),
|
reasons.toMap(),
|
||||||
duplicates,
|
duplicates,
|
||||||
contacts.map { it.record.id },
|
submittedIds,
|
||||||
locationGrids,
|
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(
|
if (contacts.isNotEmpty()) pending = Pending(
|
||||||
preview,
|
preview,
|
||||||
|
|||||||
+26
-5
@@ -82,18 +82,39 @@ class QsoRepository(
|
|||||||
|
|
||||||
override suspend fun delete(id: Long) = withContext(dispatcher) { importMutex.withLock { dao.delete(id) } }
|
override suspend fun delete(id: Long) = withContext(dispatcher) { importMutex.withLock { dao.delete(id) } }
|
||||||
|
|
||||||
override suspend fun markUploaded(ids: List<Long>, grids: List<String>) = withContext(dispatcher) {
|
override suspend fun markUploaded(ids: List<Long>, grids: List<String>, certificateCallsign: String) = withContext(dispatcher) {
|
||||||
if (ids.isEmpty()) return@withContext
|
if (ids.isEmpty()) return@withContext
|
||||||
dao.markUploaded(ids)
|
dao.markUploaded(ids)
|
||||||
// Stamp the station grid set this batch went out under (a line/corner prefill from
|
// 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.
|
// 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) }
|
val normalized = grids.map { it.trim().uppercase(Locale.US).take(4) }
|
||||||
.filter { it.length >= 4 }.distinct().sorted()
|
.filter { it.length >= 4 }.distinct().sorted()
|
||||||
if (normalized.isEmpty()) return@withContext
|
if (normalized.isNotEmpty()) {
|
||||||
val stamped = ids.mapNotNull { id ->
|
val stamped = ids.mapNotNull { id ->
|
||||||
dao.find(id)?.toDomain()?.takeIf { it.vuccGrids != normalized }?.copy(vuccGrids = normalized)
|
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<Long>, 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<Long>, stationId: String) = withContext(dispatcher) {
|
override suspend fun markWavelogUploaded(ids: List<Long>, stationId: String) = withContext(dispatcher) {
|
||||||
|
|||||||
+58
-1
@@ -17,8 +17,12 @@
|
|||||||
*/
|
*/
|
||||||
package com.rtbishop.look4sat.core.data.lotw
|
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.LoTWOperationException
|
||||||
import com.rtbishop.look4sat.core.domain.repository.LoTWProblem
|
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 kotlinx.coroutines.runBlocking
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
import org.junit.Assert.assertNull
|
import org.junit.Assert.assertNull
|
||||||
@@ -39,7 +43,9 @@ class LoTWUploadRepositoryPreviewTest {
|
|||||||
|
|
||||||
private class MemStorage : LoTWStorage {
|
private class MemStorage : LoTWStorage {
|
||||||
val files = mutableMapOf<String, ByteArray>()
|
val files = mutableMapOf<String, ByteArray>()
|
||||||
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 write(name: String, data: ByteArray) { files[name] = data.copyOf() }
|
||||||
override fun delete(name: String) { files.remove(name) }
|
override fun delete(name: String) { files.remove(name) }
|
||||||
}
|
}
|
||||||
@@ -79,4 +85,55 @@ class LoTWUploadRepositoryPreviewTest {
|
|||||||
assertTrue("nothing written", storage.files.isEmpty())
|
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
|
||||||
|
)
|
||||||
}
|
}
|
||||||
+30
@@ -295,6 +295,36 @@ class QsoRepositoryTest {
|
|||||||
assertEquals(1, dao.getAll().size)
|
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
|
@Test
|
||||||
fun markWavelogUploaded_setsTheFlagAndTheStationStamp() = runBlocking {
|
fun markWavelogUploaded_setsTheFlagAndTheStationStamp() = runBlocking {
|
||||||
val id = dao.save(loggedInApp("SO-50", lotwUploaded = false).toEntity())
|
val id = dao.save(loggedInApp("SO-50", lotwUploaded = false).toEntity())
|
||||||
|
|||||||
+8
-1
@@ -22,7 +22,14 @@ interface IQsoRepository {
|
|||||||
* signed with (1–4 gridsquares: inside a grid / on a line / on a corner), so the
|
* 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.
|
* logbook row can show which grids the QSO went out under.
|
||||||
*/
|
*/
|
||||||
suspend fun markUploaded(ids: List<Long>, grids: List<String> = emptyList())
|
suspend fun markUploaded(ids: List<Long>, grids: List<String> = 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<Long>, callsign: String): Int
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Marks the batch as uploaded to Wavelog (independent of the LoTW upload state) and
|
* Marks the batch as uploaded to Wavelog (independent of the LoTW upload state) and
|
||||||
|
|||||||
+7
-1
@@ -107,7 +107,13 @@ data class LoTWUploadPreview(
|
|||||||
val grids: List<String> = emptyList(),
|
val grids: List<String> = emptyList(),
|
||||||
/** True when this batch is a resubmit: already-uploaded/confirmed records were allowed
|
/** 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. */
|
* 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<Long> = emptyList()
|
||||||
)
|
)
|
||||||
|
|
||||||
/** Pre-upload location check: the freshest known position grid is not covered by the
|
/** Pre-upload location check: the freshest known position grid is not covered by the
|
||||||
|
|||||||
@@ -216,6 +216,10 @@
|
|||||||
<string name="prefs_logbook_selected">已选 %1$d 条</string>
|
<string name="prefs_logbook_selected">已选 %1$d 条</string>
|
||||||
<string name="prefs_logbook_select_hint">长按记录行可多选重传</string>
|
<string name="prefs_logbook_select_hint">长按记录行可多选重传</string>
|
||||||
<string name="prefs_logbook_resubmit_note">已上传记录将按当前台址就地更新;已被奖项使用的旧网格可能被拒绝。</string>
|
<string name="prefs_logbook_resubmit_note">已上传记录将按当前台址就地更新;已被奖项使用的旧网格可能被拒绝。</string>
|
||||||
|
<string name="prefs_logbook_callsign_missing">其中 %1$d 条记录没有呼号,将按证书 %2$s 上传。</string>
|
||||||
|
<string name="prefs_logbook_callsign_different">%1$d 条记录登记的不是证书 %2$s 的呼号。</string>
|
||||||
|
<string name="prefs_logbook_callsign_rewrite">改写为 %1$s</string>
|
||||||
|
<string name="prefs_logbook_callsign_switch">换一张证书</string>
|
||||||
<string name="prefs_logbook_station_label">台址</string>
|
<string name="prefs_logbook_station_label">台址</string>
|
||||||
<string name="prefs_logbook_station_all">全部</string>
|
<string name="prefs_logbook_station_all">全部</string>
|
||||||
<string name="prefs_logbook_upload_wavelog_title">上传到 Wavelog</string>
|
<string name="prefs_logbook_upload_wavelog_title">上传到 Wavelog</string>
|
||||||
|
|||||||
@@ -245,6 +245,10 @@
|
|||||||
<string name="prefs_logbook_selected">%1$d selected</string>
|
<string name="prefs_logbook_selected">%1$d selected</string>
|
||||||
<string name="prefs_logbook_select_hint">Long-press a row to re-upload corrected records</string>
|
<string name="prefs_logbook_select_hint">Long-press a row to re-upload corrected records</string>
|
||||||
<string name="prefs_logbook_resubmit_note">Existing contacts will be updated in place — a grid already used for an award may be rejected.</string>
|
<string name="prefs_logbook_resubmit_note">Existing contacts will be updated in place — a grid already used for an award may be rejected.</string>
|
||||||
|
<string name="prefs_logbook_callsign_missing">%1$d record(s) have no callsign — signed with %2$s from the certificate.</string>
|
||||||
|
<string name="prefs_logbook_callsign_different">%1$d record(s) name another callsign than the certificate %2$s.</string>
|
||||||
|
<string name="prefs_logbook_callsign_rewrite">Rewrite with %1$s</string>
|
||||||
|
<string name="prefs_logbook_callsign_switch">Use another certificate</string>
|
||||||
<string name="prefs_logbook_station_label">Station</string>
|
<string name="prefs_logbook_station_label">Station</string>
|
||||||
<string name="prefs_logbook_station_all">All</string>
|
<string name="prefs_logbook_station_all">All</string>
|
||||||
<string name="prefs_logbook_upload_wavelog_title">Upload to Wavelog</string>
|
<string name="prefs_logbook_upload_wavelog_title">Upload to Wavelog</string>
|
||||||
|
|||||||
@@ -262,7 +262,9 @@ fun LogPage(
|
|||||||
preview = preview,
|
preview = preview,
|
||||||
busy = logUiState.busy,
|
busy = logUiState.busy,
|
||||||
onConfirm = logViewModel::confirmUpload,
|
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,
|
preview: com.rtbishop.look4sat.core.domain.repository.LoTWUploadPreview,
|
||||||
busy: Boolean,
|
busy: Boolean,
|
||||||
onConfirm: () -> Unit,
|
onConfirm: () -> Unit,
|
||||||
onDismiss: () -> Unit
|
onDismiss: () -> Unit,
|
||||||
|
onRewriteCallsign: () -> Unit,
|
||||||
|
onSwitchCertificate: () -> Unit
|
||||||
) {
|
) {
|
||||||
AlertDialog(
|
AlertDialog(
|
||||||
onDismissRequest = onDismiss,
|
onDismissRequest = onDismiss,
|
||||||
@@ -464,6 +468,32 @@ private fun UploadPreviewDialog(
|
|||||||
}
|
}
|
||||||
Text(parts.joinToString(" · "), fontSize = 12.sp, color = MaterialTheme.colorScheme.error)
|
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)
|
Text(preview.contacts.joinToString("\n") { it }, fontSize = 12.sp, maxLines = 8)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -343,7 +343,7 @@ class LogViewModel(
|
|||||||
if (result is LoTWUploadResult.Accepted && lastUploadedIds.isNotEmpty()) {
|
if (result is LoTWUploadResult.Accepted && lastUploadedIds.isNotEmpty()) {
|
||||||
// Mark the submitted QSOs as uploaded (distinct from confirmed) and stamp
|
// Mark the submitted QSOs as uploaded (distinct from confirmed) and stamp
|
||||||
// the station grids this batch went out under.
|
// the station grids this batch went out under.
|
||||||
qsoRepository.markUploaded(lastUploadedIds, preview.grids)
|
qsoRepository.markUploaded(lastUploadedIds, preview.grids, preview.callsign)
|
||||||
lastUploadedIds = emptyList()
|
lastUploadedIds = emptyList()
|
||||||
}
|
}
|
||||||
val msg = when (result) {
|
val msg = when (result) {
|
||||||
@@ -368,6 +368,34 @@ class LogViewModel(
|
|||||||
|
|
||||||
fun dismissPreview() = _uiState.update { it.copy(preview = null) }
|
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) }
|
fun dismissWavelogPreview() = _uiState.update { it.copy(wavelogPreview = null) }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+36
-2
@@ -103,6 +103,10 @@ fun LogbookDialog(
|
|||||||
onDismissMessage: () -> Unit,
|
onDismissMessage: () -> Unit,
|
||||||
onIgnorePositionWarning: () -> Unit = {},
|
onIgnorePositionWarning: () -> Unit = {},
|
||||||
onFixGrid: (List<String>) -> Unit = {},
|
onFixGrid: (List<String>) -> 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,
|
selectionMode: Boolean = false,
|
||||||
selectedIds: Set<Long> = emptySet(),
|
selectedIds: Set<Long> = emptySet(),
|
||||||
onStartSelection: (Long) -> Unit = {},
|
onStartSelection: (Long) -> Unit = {},
|
||||||
@@ -246,7 +250,9 @@ fun LogbookDialog(
|
|||||||
preview = preview,
|
preview = preview,
|
||||||
busy = uploadBusy,
|
busy = uploadBusy,
|
||||||
onConfirm = onConfirmUpload,
|
onConfirm = onConfirmUpload,
|
||||||
onDismiss = onDismissPreview
|
onDismiss = onDismissPreview,
|
||||||
|
onRewriteCallsign = onRewriteCallsign,
|
||||||
|
onSwitchCertificate = onSwitchCertificate
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (uploadMessage.isNotBlank()) {
|
if (uploadMessage.isNotBlank()) {
|
||||||
@@ -280,7 +286,9 @@ private fun LogbookUploadPreviewDialog(
|
|||||||
preview: com.rtbishop.look4sat.core.domain.repository.LoTWUploadPreview,
|
preview: com.rtbishop.look4sat.core.domain.repository.LoTWUploadPreview,
|
||||||
busy: Boolean,
|
busy: Boolean,
|
||||||
onConfirm: () -> Unit,
|
onConfirm: () -> Unit,
|
||||||
onDismiss: () -> Unit
|
onDismiss: () -> Unit,
|
||||||
|
onRewriteCallsign: () -> Unit,
|
||||||
|
onSwitchCertificate: () -> Unit
|
||||||
) {
|
) {
|
||||||
AlertDialog(
|
AlertDialog(
|
||||||
onDismissRequest = onDismiss,
|
onDismissRequest = onDismiss,
|
||||||
@@ -319,6 +327,32 @@ private fun LogbookUploadPreviewDialog(
|
|||||||
color = MaterialTheme.colorScheme.primary
|
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)
|
Text(preview.contacts.joinToString("\n") { it }, fontSize = 12.sp, maxLines = 8)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
+9
@@ -308,6 +308,15 @@ private fun SettingsScreen(
|
|||||||
onConfirmUpload = { onAction(SettingsAction.ConfirmLogbookUpload) },
|
onConfirmUpload = { onAction(SettingsAction.ConfirmLogbookUpload) },
|
||||||
onDismissPreview = { onAction(SettingsAction.DismissLogbookPreview) },
|
onDismissPreview = { onAction(SettingsAction.DismissLogbookPreview) },
|
||||||
onDismissMessage = { onAction(SettingsAction.ClearLogbookMessage) },
|
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) },
|
onIgnorePositionWarning = { onAction(SettingsAction.IgnoreLogbookPositionWarning) },
|
||||||
onFixGrid = { grids ->
|
onFixGrid = { grids ->
|
||||||
onAction(SettingsAction.AbandonLogbookForGridFix)
|
onAction(SettingsAction.AbandonLogbookForGridFix)
|
||||||
|
|||||||
@@ -193,6 +193,7 @@ sealed interface SettingsAction {
|
|||||||
data object PrepareLogbookUpload : SettingsAction
|
data object PrepareLogbookUpload : SettingsAction
|
||||||
data object ConfirmLogbookUpload : SettingsAction
|
data object ConfirmLogbookUpload : SettingsAction
|
||||||
data object DismissLogbookPreview : SettingsAction
|
data object DismissLogbookPreview : SettingsAction
|
||||||
|
data object RewriteLogbookCallsigns : SettingsAction
|
||||||
/** The operator acknowledged the position mismatch and wants to upload anyway. */
|
/** The operator acknowledged the position mismatch and wants to upload anyway. */
|
||||||
data object IgnoreLogbookPositionWarning : SettingsAction
|
data object IgnoreLogbookPositionWarning : SettingsAction
|
||||||
/** The operator chose to fix the station location first; the preview is discarded. */
|
/** The operator chose to fix the station location first; the preview is discarded. */
|
||||||
|
|||||||
+25
-1
@@ -257,6 +257,7 @@ class SettingsViewModel(
|
|||||||
SettingsAction.PrepareLogbookUpload -> prepareLogbookUpload()
|
SettingsAction.PrepareLogbookUpload -> prepareLogbookUpload()
|
||||||
SettingsAction.ConfirmLogbookUpload -> confirmLogbookUpload()
|
SettingsAction.ConfirmLogbookUpload -> confirmLogbookUpload()
|
||||||
SettingsAction.DismissLogbookPreview -> dismissLogbookPreview()
|
SettingsAction.DismissLogbookPreview -> dismissLogbookPreview()
|
||||||
|
SettingsAction.RewriteLogbookCallsigns -> rewriteLogbookCallsigns()
|
||||||
SettingsAction.IgnoreLogbookPositionWarning -> ignoreLogbookPositionWarning()
|
SettingsAction.IgnoreLogbookPositionWarning -> ignoreLogbookPositionWarning()
|
||||||
SettingsAction.AbandonLogbookForGridFix -> abandonLogbookForGridFix()
|
SettingsAction.AbandonLogbookForGridFix -> abandonLogbookForGridFix()
|
||||||
is SettingsAction.StartLogbookSelection -> startLogbookSelection(action.id)
|
is SettingsAction.StartLogbookSelection -> startLogbookSelection(action.id)
|
||||||
@@ -648,7 +649,7 @@ class SettingsViewModel(
|
|||||||
val result = lotwUploadRepository.upload(preview.id)
|
val result = lotwUploadRepository.upload(preview.id)
|
||||||
val accepted = result is com.rtbishop.look4sat.core.domain.repository.LoTWUploadResult.Accepted
|
val accepted = result is com.rtbishop.look4sat.core.domain.repository.LoTWUploadResult.Accepted
|
||||||
if (accepted && lastLogbookUploadIds.isNotEmpty()) {
|
if (accepted && lastLogbookUploadIds.isNotEmpty()) {
|
||||||
qsoRepository.markUploaded(lastLogbookUploadIds, preview.grids)
|
qsoRepository.markUploaded(lastLogbookUploadIds, preview.grids, preview.callsign)
|
||||||
lastLogbookUploadIds = emptyList()
|
lastLogbookUploadIds = emptyList()
|
||||||
}
|
}
|
||||||
val msg = when (result) {
|
val msg = when (result) {
|
||||||
@@ -673,6 +674,29 @@ class SettingsViewModel(
|
|||||||
|
|
||||||
private fun dismissLogbookPreview() = _uiState.update { it.copy(logbookPreview = null, logbookWavelogPreview = null) }
|
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. */
|
/** Operator chose "ignore" on the position check: keep the prepared preview. */
|
||||||
private fun ignoreLogbookPositionWarning() = _uiState.update { it.copy(logbookPositionWarning = null) }
|
private fun ignoreLogbookPositionWarning() = _uiState.update { it.copy(logbookPositionWarning = null) }
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user