diff --git a/core/data/src/main/java/com/rtbishop/look4sat/core/data/lotw/LoTWConfig.kt b/core/data/src/main/java/com/rtbishop/look4sat/core/data/lotw/LoTWConfig.kt
index a3ca551d..431dd86a 100644
--- a/core/data/src/main/java/com/rtbishop/look4sat/core/data/lotw/LoTWConfig.kt
+++ b/core/data/src/main/java/com/rtbishop/look4sat/core/data/lotw/LoTWConfig.kt
@@ -1,6 +1,25 @@
+/*
+ * Look4Sat-BA7OPF. Amateur radio satellite tracker and pass predictor.
+ * Copyright (C) 2026 BA7OPF.
+ * Based on Look4Sat by 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.logbook.QsoRecord
+import com.rtbishop.look4sat.core.domain.logbook.LoTWSatelliteAliases
import com.rtbishop.look4sat.core.domain.logbook.displayMode
import com.rtbishop.look4sat.core.domain.repository.LoTWProblem
import com.rtbishop.look4sat.core.domain.repository.LoTWRegionField
@@ -58,18 +77,23 @@ internal class LoTWConfig(input: InputStream) {
return value
}
+ /** Every satellite name ARRL's config.tq6 knows, sorted: the only names LoTW accepts. */
+ fun satelliteNames(): List = satellites.keys.sorted()
+
+ /**
+ * Resolve a tracker name to the ARRL name, or null when it is not an ARRL satellite.
+ * Exact official name first, then the alias table ("SAUDISAT 1C" -> "SO-50",
+ * "ISS (ZARYA)" -> "ARISS"), then an official name contained in the tracker name.
+ */
+ fun resolveSatellite(name: String): String? = LoTWSatelliteAliases.resolve(name, satellites.keys)
+
fun satellite(name: String, date: String): String {
val normalized = name.trim().uppercase(Locale.US)
- // Look4Sat satellite names come from SatNOGS (e.g. "SO-50 (SaudiOSCAR 50)");
- // the bundled config only knows the official LoTW name ("SO-50"). Exact match
- // first, then accept any satellite whose official name is a substring of the
- // Look4Sat name (covers SO-50, AO-91, IO-86, CAS-7B …). Failing that the
- // contact is unavailable for upload.
- val sat = satellites[normalized]
- ?: satellites.entries.firstOrNull { (official, _) ->
- official.length > 1 && normalized.contains(official)
- }?.value
- ?: fail(LoTWProblem.SATELLITE, normalized)
+ // Tracker names differ from ARRL's: SatNOGS stores "SAUDISAT 1C" for "SO-50" and
+ // "ISS (ZARYA)" for "ARISS". resolveSatellite() checks the exact name, the alias
+ // table and finally substring containment; anything else is not uploadable.
+ val official = resolveSatellite(name) ?: fail(LoTWProblem.SATELLITE, normalized)
+ val sat = satellites.getValue(official)
val first = sat.getAttribute("startDate")
val last = sat.getAttribute("endDate")
if ((first.isNotBlank() && date < first) || (last.isNotBlank() && date > last)) fail(LoTWProblem.SATELLITE, normalized)
diff --git a/core/data/src/main/java/com/rtbishop/look4sat/core/data/lotw/LoTWKeyMaterial.kt b/core/data/src/main/java/com/rtbishop/look4sat/core/data/lotw/LoTWKeyMaterial.kt
index 3c51ddcb..4e057e54 100644
--- a/core/data/src/main/java/com/rtbishop/look4sat/core/data/lotw/LoTWKeyMaterial.kt
+++ b/core/data/src/main/java/com/rtbishop/look4sat/core/data/lotw/LoTWKeyMaterial.kt
@@ -1,3 +1,21 @@
+/*
+ * Look4Sat-BA7OPF. Amateur radio satellite tracker and pass predictor.
+ * Copyright (C) 2026 BA7OPF.
+ * Based on Look4Sat by 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.LoTWCertificate
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 f5d16ef2..a6e54f08 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
@@ -1,3 +1,21 @@
+/*
+ * Look4Sat-BA7OPF. Amateur radio satellite tracker and pass predictor.
+ * Copyright (C) 2026 BA7OPF.
+ * Based on Look4Sat by 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.logbook.QsoRecord
diff --git a/core/data/src/main/java/com/rtbishop/look4sat/core/data/lotw/LoTWStorage.kt b/core/data/src/main/java/com/rtbishop/look4sat/core/data/lotw/LoTWStorage.kt
index 676e24e2..69aa0e22 100644
--- a/core/data/src/main/java/com/rtbishop/look4sat/core/data/lotw/LoTWStorage.kt
+++ b/core/data/src/main/java/com/rtbishop/look4sat/core/data/lotw/LoTWStorage.kt
@@ -1,3 +1,21 @@
+/*
+ * Look4Sat-BA7OPF. Amateur radio satellite tracker and pass predictor.
+ * Copyright (C) 2026 BA7OPF.
+ * Based on Look4Sat by 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 android.content.Context
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 a87df608..42c606ba 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
@@ -1,7 +1,26 @@
+/*
+ * Look4Sat-BA7OPF. Amateur radio satellite tracker and pass predictor.
+ * Copyright (C) 2026 BA7OPF.
+ * Based on Look4Sat by 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 android.content.Context
import com.rtbishop.look4sat.core.domain.logbook.QsoRecord
+import com.rtbishop.look4sat.core.domain.logbook.satelliteIdentity
import com.rtbishop.look4sat.core.domain.logbook.QsoStatus
import com.rtbishop.look4sat.core.domain.repository.ILoTWUploadRepository
import com.rtbishop.look4sat.core.domain.repository.LoTWCertificate
@@ -154,6 +173,24 @@ class LoTWUploadRepository internal constructor(
config().stationMeta(dxcc)
}
+ /** The ARRL satellite catalogue is static for the lifetime of the bundled config.tq6. */
+ private val satelliteNames: List by lazy { config().satelliteNames() }
+
+ override suspend fun satelliteCatalog(): List = withContext(Dispatchers.IO) { satelliteNames }
+
+ /**
+ * What makes two records the same contact for batch de-duplication: the opposite station,
+ * the satellite (resolved to its ARRL identity, so the tracker spelling and the official
+ * name are one) and the minute the QSO started. Frequency and mode are deliberately not
+ * part of the key — the same contact logged twice with slightly different tuning (or with
+ * the frequency fields empty) must still be caught before LoTW rejects the duplicate.
+ */
+ private fun duplicateKey(record: QsoRecord): String = listOf(
+ record.theirCallsign.trim().uppercase(Locale.US),
+ satelliteIdentity(record.satelliteName),
+ (record.startUtcMillis / 60_000L).toString()
+ ).joinToString("|")
+
override suspend fun audit(records: List): LoTWUploadAudit = withContext(Dispatchers.IO) {
mutex.withLock {
val signing = signingContext()
@@ -163,20 +200,34 @@ class LoTWUploadRepository internal constructor(
var uploaded = 0
var unknown = 0
var unavailable = 0
+ var duplicates = 0
+ var incomplete = 0
+ val reasons = mutableMapOf()
+ val details = mutableMapOf()
records.sortedBy { it.startUtcMillis }.forEach { record ->
coroutineContext.ensureActive()
when {
- record.status != QsoStatus.COMPLETE -> unavailable++
+ record.status != QsoStatus.COMPLETE -> {
+ unavailable++
+ incomplete++
+ }
+
record.lotwReceived || record.lotwUploaded -> uploaded++
else -> {
val contact = try {
signing.signer.contact(record, signing.key, signing.location, now())
- } catch (_: LoTWOperationException) {
+ } catch (e: LoTWOperationException) {
unavailable++
+ reasons[e.reason] = (reasons[e.reason] ?: 0) + 1
+ if (e.detail.isNotBlank()) details.putIfAbsent(e.reason, e.detail)
null
}
if (contact != null) when {
- !unique.add(contact.fingerprint) -> unavailable++
+ !unique.add(duplicateKey(contact.record)) -> {
+ unavailable++
+ duplicates++
+ }
+
ledger[contact.fingerprint] == "accepted" -> uploaded++
ledger[contact.fingerprint] == "unknown" -> unknown++
else -> pendingCount++
@@ -184,7 +235,10 @@ class LoTWUploadRepository internal constructor(
}
}
}
- LoTWUploadAudit(records.size, pendingCount, uploaded, unknown, unavailable)
+ LoTWUploadAudit(
+ records.size, pendingCount, uploaded, unknown, unavailable,
+ reasons, details, duplicates, incomplete
+ )
}
}
@@ -197,21 +251,30 @@ class LoTWUploadRepository internal constructor(
var skipped = 0
var unknown = 0
var unavailable = 0
+ var duplicates = 0
val unique = hashSetOf()
+ val reasons = mutableMapOf()
val contacts = records.sortedBy { it.startUtcMillis }.mapNotNull { record ->
coroutineContext.ensureActive()
if (record.status != QsoStatus.COMPLETE || ((record.lotwReceived || record.lotwUploaded) && !resubmit)) { skipped++; return@mapNotNull null }
val contact = try {
signing.signer.contact(record, signing.key, signing.location, now())
- } catch (_: LoTWOperationException) {
+ } catch (e: LoTWOperationException) {
// One un-signable record must not abort the whole batch:
- // skip it, count it, and let the rest upload.
+ // skip it, count the real reason, and let the rest upload.
unavailable++
+ reasons[e.reason] = (reasons[e.reason] ?: 0) + 1
return@mapNotNull null
}
val previous = ledger[contact.fingerprint]
when {
- (previous == "accepted" && !resubmit) || !unique.add(contact.fingerprint) -> { skipped++; null }
+ (previous == "accepted" && !resubmit) -> { skipped++; null }
+ !unique.add(duplicateKey(contact.record)) -> {
+ skipped++
+ duplicates++
+ null
+ }
+
previous == "unknown" && !resubmit -> { skipped++; unknown++; null }
else -> contact
}
@@ -224,6 +287,8 @@ class LoTWUploadRepository internal constructor(
contacts.map { "${utc(it.record.startUtcMillis, "MM-dd HH:mm")} ${it.record.theirCallsign} ${it.fields["MODE"]} ${it.fields["SAT_NAME"].orEmpty()}" },
unknown,
unavailable,
+ reasons.toMap(),
+ duplicates,
contacts.map { it.record.id }
)
if (contacts.isNotEmpty()) pending = Pending(
diff --git a/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/LoTWRepository.kt b/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/LoTWRepository.kt
index 3b69c512..2c5354aa 100644
--- a/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/LoTWRepository.kt
+++ b/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/LoTWRepository.kt
@@ -322,9 +322,9 @@ class LoTWRepository : ILoTWRepository {
line.startsWith("
mode = adifValue(line)
line.startsWith("
- bandUp = adifValue(line).uppercase()
- line.startsWith("
bandDown = adifValue(line).uppercase()
+ line.startsWith("
+ bandUp = adifValue(line).uppercase()
line.startsWith("
dxcc = adifValue(line).toIntOrNull()
line.startsWith("
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 170f414f..63f46619 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
@@ -16,9 +16,13 @@ import com.rtbishop.look4sat.core.domain.logbook.AdifImportResult
import com.rtbishop.look4sat.core.domain.logbook.IQsoRepository
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.sameConfirmedContact
+import com.rtbishop.look4sat.core.domain.logbook.satelliteIdentity
+import com.rtbishop.look4sat.core.domain.logbook.splitConfirmationPairs
import com.rtbishop.look4sat.core.domain.logbook.withConfirmation
import com.rtbishop.look4sat.core.domain.logbook.confirmationLookupKey
+import com.rtbishop.look4sat.core.domain.logbook.officialSatelliteName
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
@@ -40,18 +44,33 @@ class QsoRepository(
override suspend fun save(record: QsoRecord): Long = withContext(dispatcher) {
importMutex.withLock {
val previous = if (record.id != 0L) dao.find(record.id)?.toDomain() else null
- val saved = if (previous?.lotwConfirmed == true && sameConfirmedContact(record, previous)) {
- record.withConfirmation(previous)
- } else if (previous?.lotwConfirmed == true) record.copy(
- lotwConfirmed = false, lotwQslDate = "", vuccGrids = emptyList(),
- dxcc = null, country = "", cqZone = null, region = ""
- ) else record
- val received = if (previous != null && (
- stableQsoKey(record) != stableQsoKey(previous) || record.myGrid != previous.myGrid ||
- record.rxFrequencyHz != previous.rxFrequencyHz || record.band != previous.band ||
- record.rxBand != previous.rxBand || record.propagationMode != previous.propagationMode
- )) false else saved.lotwReceived
- dao.save(saved.copy(lotwReceived = received).toEntity())
+ // Editing a record that was already uploaded or confirmed must not rewrite it in
+ // place: that contact already exists on LoTW under the old values. The original row
+ // keeps its upload/confirmation state, and the edited content is saved as a NEW
+ // record (without any LoTW state, so it can be uploaded again).
+ if (previous != null && (previous.lotwUploaded || previous.lotwConfirmed) &&
+ !record.sameEditableContentAs(previous)
+ ) {
+ dao.save(record.copy(
+ id = 0L,
+ lotwUploaded = false, lotwConfirmed = false, lotwReceived = false,
+ lotwQslDate = "", vuccGrids = emptyList(),
+ dxcc = null, country = "", cqZone = null, region = ""
+ ).toEntity())
+ } else {
+ val saved = if (previous?.lotwConfirmed == true && sameConfirmedContact(record, previous)) {
+ record.withConfirmation(previous)
+ } else if (previous?.lotwConfirmed == true) record.copy(
+ lotwConfirmed = false, lotwQslDate = "", vuccGrids = emptyList(),
+ dxcc = null, country = "", cqZone = null, region = ""
+ ) else record
+ val received = if (previous != null && (
+ stableQsoKey(record) != stableQsoKey(previous) || record.myGrid != previous.myGrid ||
+ record.rxFrequencyHz != previous.rxFrequencyHz || record.band != previous.band ||
+ record.rxBand != previous.rxBand || record.propagationMode != previous.propagationMode
+ )) false else saved.lotwReceived
+ dao.save(saved.copy(lotwReceived = received).toEntity())
+ }
}
}
@@ -86,14 +105,42 @@ class QsoRepository(
importMutex.withLock { mergeRecords(records, fromLoTW = true) }
}
+ override suspend fun consolidateConfirmations(): Int = withContext(dispatcher) {
+ importMutex.withLock {
+ val working = dao.getAll().map(QsoEntity::toDomain).toMutableList()
+ val consolidation = consolidateSplitRows(working)
+ val renames = rewriteOfficialNames(working)
+ if (consolidation.merged.isEmpty() && consolidation.redundant.isEmpty() && renames.isEmpty()) {
+ return@withLock 0
+ }
+ val toSave = LinkedHashMap(consolidation.merged).apply { putAll(renames) }
+ dao.saveBatch(toSave.values.map(QsoRecord::toEntity))
+ consolidation.redundant.forEach { dao.delete(it.id) }
+ consolidation.redundant.size
+ }
+ }
+
private suspend fun mergeRecords(records: List, fromLoTW: Boolean = false): AdifImportResult {
val working = dao.getAll().map(QsoEntity::toDomain).toMutableList()
+ // Consolidate first: confirmations synced before the identity fix sit as their own
+ // rows (the tracker name and a mirrored band direction both failed the comparison),
+ // and left in place they would make the lookup below ambiguous between the operator's
+ // own row and the stale duplicate.
+ val consolidation = consolidateSplitRows(working)
+ // Rows imported under a tracking-source name join the ARRL name here, so what is stored
+ // is the identity both sides of a match resolve to (the lookup below is built on it).
+ val renames = rewriteOfficialNames(working)
val lookup = working.indices.groupBy { working[it].confirmationLookupKey() }
.mapValues { it.value.toMutableList() }.toMutableMap()
- val knownKeys = working.mapTo(mutableSetOf(), ::stableQsoKey)
- val changes = linkedMapOf()
+ val keyIndex = working.indices.groupBy { stableQsoKey(working[it]) }
+ .mapValues { it.value.toMutableList() }.toMutableMap()
+ val knownKeys = keyIndex.keys.toMutableSet()
+ val changes = linkedMapOf().apply {
+ putAll(consolidation.merged)
+ putAll(renames)
+ }
var imported = 0
- var updated = 0
+ var updated = consolidation.merged.size
var skipped = 0
records.forEach { remote ->
if (!fromLoTW && !remote.lotwConfirmed && stableQsoKey(remote) in knownKeys) { skipped++; return@forEach }
@@ -102,10 +149,27 @@ class QsoRepository(
val exact = candidates.filter { working[it].startUtcMillis == remote.startUtcMillis }
val match = exact.singleOrNull() ?: candidates.singleOrNull()
if (match == null) {
+ // A confirmation downloaded by the pre-fix parser sits as its own row with the
+ // band direction read mirrored (the downlink was stored as the uplink). It has no
+ // local partner (so nothing folds it) and its dedupe key equals the fresh report,
+ // so it would otherwise be skipped forever, keeping the wrong band on display.
+ // When the bands are exactly mirrored, adopt the report's direction instead.
+ val stale = if (fromLoTW) keyIndex[stableQsoKey(remote)]?.firstOrNull()
+ ?.takeIf { index -> working[index].lotwConfirmed &&
+ working[index].band.equals(remote.rxBand, true) &&
+ working[index].rxBand.equals(remote.band, true) } else null
+ if (stale != null) {
+ val fixed = working[stale].copy(band = remote.band, rxBand = remote.rxBand)
+ working[stale] = fixed
+ changes[stale] = fixed
+ updated++
+ return@forEach
+ }
if (stableQsoKey(remote) in knownKeys) { skipped++; return@forEach }
- val added = remote.copy(id = 0L)
+ val added = remote.copy(id = 0L, satelliteName = officialSatelliteName(remote.satelliteName))
changes[working.size] = added
lookup.getOrPut(added.confirmationLookupKey()) { mutableListOf() }.add(working.size)
+ keyIndex.getOrPut(stableQsoKey(added)) { mutableListOf() }.add(working.size)
knownKeys += stableQsoKey(added)
working += added
imported++
@@ -120,8 +184,60 @@ class QsoRepository(
}
}
dao.saveBatch(changes.values.map(QsoRecord::toEntity))
+ consolidation.redundant.forEach { dao.delete(it.id) }
return AdifImportResult(imported, skipped, updated)
}
+
+ /** Result of folding already-split rows back together. */
+ private data class Consolidation(
+ /** index in the stored list -> the record to save (local row + its confirmation). */
+ val merged: Map,
+ /** imported confirmation rows that are now part of a local record. */
+ val redundant: List
+ )
+
+ /**
+ * Rewrites the names records were imported under into their ARRL names ("SAUDISAT 1C" ->
+ * "SO-50"), so the logbook, the ADIF export and the signed record all carry one name.
+ * Names the alias table does not know (recycled placeholders, satellites ARRL does not
+ * list) are kept exactly as they are. [working] is updated in place; the rewritten records
+ * are returned for the caller to save.
+ */
+ private fun rewriteOfficialNames(working: MutableList): Map {
+ val renamed = mutableMapOf()
+ working.indices.forEach { index ->
+ val record = working[index]
+ val official = officialSatelliteName(record.satelliteName)
+ if (official != record.satelliteName) {
+ val updated = record.copy(satelliteName = official)
+ working[index] = updated
+ renamed[index] = updated
+ }
+ }
+ return renamed
+ }
+
+ /**
+ * Folds confirmation rows that were imported as separate QSOs back into the local
+ * record they belong to (see [splitConfirmationPairs]). [working] is updated in
+ * place; the redundant rows are returned for the caller to delete.
+ */
+ private fun consolidateSplitRows(working: MutableList): Consolidation {
+ val merged = mutableMapOf()
+ val redundant = mutableListOf()
+ // Snapshot the pairs first: the list is rewritten as pairs are applied.
+ splitConfirmationPairs(working.toList()).forEach { pair ->
+ val local = working[pair.localIndex]
+ val confirmation = working[pair.confirmationIndex]
+ val folded = local.withConfirmation(confirmation)
+ if (folded != local) {
+ working[pair.localIndex] = folded
+ merged[pair.localIndex] = folded
+ }
+ redundant += confirmation
+ }
+ return Consolidation(merged, redundant)
+ }
}
private fun QsoEntity.toDomain() = QsoRecord(
@@ -202,5 +318,29 @@ internal fun stableQsoKey(record: QsoRecord): String = listOf(
record.txFrequencyHz?.toString().orEmpty(),
record.mode.trim().uppercase(Locale.US),
record.submode.trim().uppercase(Locale.US),
- record.satelliteName.trim().uppercase(Locale.US)
+ // The identity, not the spelling: a row stored as "SO-50" is the same QSO as one imported
+ // as "SAUDISAT 1C", so re-importing an older export does not duplicate it.
+ satelliteIdentity(record.satelliteName)
).joinToString("|")
+
+/**
+ * Whether two rows carry the same operator-editable content.
+ *
+ * Minutes are the granularity for time: the edit dialog edits whole minutes (and rounds the
+ * stored seconds away when saving), so a seconds-only difference must not count as an edit.
+ * The satellite is compared by its ARRL identity, the mode by its display label — both sides
+ * of the same contact written differently (tracker vs official name, MFSK vs FT4) are the same
+ * content. Used to decide whether saving an already-uploaded/confirmed row should create a new
+ * record or leave the row alone.
+ */
+private fun QsoRecord.sameEditableContentAs(other: QsoRecord): Boolean =
+ theirCallsign.trim().equals(other.theirCallsign.trim(), true) &&
+ startUtcMillis / 60_000L == other.startUtcMillis / 60_000L &&
+ txFrequencyHz == other.txFrequencyHz &&
+ rxFrequencyHz == other.rxFrequencyHz &&
+ displayMode == other.displayMode &&
+ satelliteIdentity(satelliteName) == satelliteIdentity(other.satelliteName) &&
+ sentReport.trim() == other.sentReport.trim() &&
+ receivedReport.trim() == other.receivedReport.trim() &&
+ theirGrid.trim().uppercase(Locale.US) == other.theirGrid.trim().uppercase(Locale.US) &&
+ comment == other.comment
diff --git a/core/data/src/test/java/com/rtbishop/look4sat/core/data/lotw/LoTWConfigTest.kt b/core/data/src/test/java/com/rtbishop/look4sat/core/data/lotw/LoTWConfigTest.kt
index 647e93d8..8434b62b 100644
--- a/core/data/src/test/java/com/rtbishop/look4sat/core/data/lotw/LoTWConfigTest.kt
+++ b/core/data/src/test/java/com/rtbishop/look4sat/core/data/lotw/LoTWConfigTest.kt
@@ -9,6 +9,8 @@
*/
package com.rtbishop.look4sat.core.data.lotw
+import com.rtbishop.look4sat.core.domain.logbook.LoTWSatelliteAliases
+import com.rtbishop.look4sat.core.domain.repository.LoTWOperationException
import com.rtbishop.look4sat.core.domain.repository.LoTWZonePair
import java.io.File
import org.junit.Assert.assertEquals
@@ -75,4 +77,50 @@ class LoTWConfigTest {
assertNull(meta.regionField)
assertTrue(meta.countryZones.isEmpty())
}
+
+ @Test
+ fun `tracker catalogue names resolve to the ARRL satellite`() {
+ // The names the TLE sources store vs the single name ARRL knows for that satellite.
+ assertEquals("SO-50", config.resolveSatellite("SAUDISAT 1C"))
+ assertEquals("ARISS", config.resolveSatellite("ISS (ZARYA)"))
+ assertEquals("PO-101", config.resolveSatellite("DIWATA 2B"))
+ assertEquals("AO-91", config.resolveSatellite("FOX-1B"))
+ assertEquals("IO-86", config.resolveSatellite("LAPAN-A2"))
+ assertEquals("RS-44", config.resolveSatellite("DOSAAF-85"))
+ assertEquals("BO-102", config.resolveSatellite("CAS-7B"))
+ assertEquals("SO-50", config.resolveSatellite("SO-50"))
+ }
+
+ @Test
+ fun `satellite signing accepts tracker names and keeps ARRL spelling`() {
+ assertEquals("SO-50", config.satellite("SAUDISAT 1C", "2026-09-27"))
+ assertEquals("ARISS", config.satellite("ISS (ZARYA)", "2026-09-27"))
+ assertEquals("SO-50", config.satellite("SO-50 (SaudiOSCAR 50)", "2026-09-27"))
+ }
+
+ @Test
+ fun `satellite signing rejects names ARRL does not know`() {
+ // Placeholder designations are deliberately unmapped: signing them would name the
+ // wrong object, so the record must stay un-uploadable.
+ listOf("OBJECT AY", "MARINA", "NOT A SATELLITE").forEach { name ->
+ val error = runCatching { config.satellite(name, "2026-09-27") }.exceptionOrNull()
+ assertTrue("$name should be rejected", error is LoTWOperationException)
+ }
+ }
+
+ @Test
+ fun `satellite signing enforces the ARRL service dates`() {
+ // SO-50 is listed from 2002-12-20: a QSO before that cannot be signed.
+ val error = runCatching { config.satellite("SAUDISAT 1C", "2002-01-01") }.exceptionOrNull()
+ assertTrue(error is LoTWOperationException)
+ assertEquals("SO-50", config.satellite("SAUDISAT 1C", "2003-01-01"))
+ }
+
+ @Test
+ fun `every alias target is a real ARRL satellite name`() {
+ val catalogue = config.satelliteNames().toSet()
+ assertTrue("ARRL catalogue is unexpectedly small", catalogue.size > 100)
+ val unknown = LoTWSatelliteAliases.table.filterValues { it !in catalogue }
+ assertEquals("alias targets missing from config.tq6: $unknown", emptyMap(), unknown)
+ }
}
diff --git a/core/data/src/test/java/com/rtbishop/look4sat/core/data/repository/LoTWRepositoryTest.kt b/core/data/src/test/java/com/rtbishop/look4sat/core/data/repository/LoTWRepositoryTest.kt
index 44ee18d5..7001a956 100644
--- a/core/data/src/test/java/com/rtbishop/look4sat/core/data/repository/LoTWRepositoryTest.kt
+++ b/core/data/src/test/java/com/rtbishop/look4sat/core/data/repository/LoTWRepositoryTest.kt
@@ -1,3 +1,21 @@
+/*
+ * Look4Sat-BA7OPF. Amateur radio satellite tracker and pass predictor.
+ * Copyright (C) 2026 BA7OPF.
+ * Based on Look4Sat by 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.repository
import com.rtbishop.look4sat.core.domain.repository.LoTWResult
@@ -112,9 +130,12 @@ class LoTWRepositoryTest {
assertEquals("A50QO", qso.call)
assertEquals("FO-29", qso.satName)
assertEquals("CW", qso.mode)
- assertEquals("2M", qso.bandUp)
- assertEquals("70CM", qso.bandDown)
- assertEquals("V/U", qso.bandLabel)
+ // ADIF BAND is the transmitted (uplink) band, BAND_RX the received (downlink) one.
+ // Confirmed against the live report of the own uploads: SO-50 QSOs arrive as
+ // BAND=2M/FREQ=145.85000 + BAND_RX=70CM/FREQ_RX=436.80500, i.e. BAND=uplink.
+ assertEquals("70CM", qso.bandUp)
+ assertEquals("2M", qso.bandDown)
+ assertEquals("U/V", qso.bandLabel)
assertEquals(2026, java.time.Instant.ofEpochMilli(qso.epochMs).atZone(java.time.ZoneOffset.UTC).year)
}
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
new file mode 100644
index 00000000..989547d6
--- /dev/null
+++ b/core/data/src/test/java/com/rtbishop/look4sat/core/data/repository/QsoRepositoryTest.kt
@@ -0,0 +1,292 @@
+/*
+ * 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.
+ */
+package com.rtbishop.look4sat.core.data.repository
+
+import com.rtbishop.look4sat.core.data.database.QsoDao
+import com.rtbishop.look4sat.core.data.database.entity.QsoEntity
+import com.rtbishop.look4sat.core.domain.logbook.QsoRecord
+import com.rtbishop.look4sat.core.domain.logbook.QsoStatus
+import com.rtbishop.look4sat.core.domain.logbook.toConfirmedRecord
+import com.rtbishop.look4sat.core.domain.model.GridQso
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.flowOf
+import kotlinx.coroutines.runBlocking
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertNotEquals
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+/**
+ * The logbook split reported 2026-09-28: a QSO uploaded from the app and the confirmation LoTW
+ * reported for it ended up as two rows, because the record keeps the tracking-source name
+ * ("SAUDISAT 1C") and the uplink band while the report carries the ARRL name ("SO-50") — and,
+ * before the direction fix, the mirrored band pair.
+ */
+class QsoRepositoryTest {
+
+ private val dao = FakeQsoDao()
+ private val repository = QsoRepository(dao, Dispatchers.Unconfined)
+
+ /** 2026-09-16 07:45Z, an SO-50 contact of the user's own report. */
+ private val qsoStart = 1_789_544_700_000L
+
+ @Test
+ fun mergeLoTW_confirmsTheUploadedRecordInsteadOfAddingASecondRow() = runBlocking {
+ val uploadedId = dao.save(loggedInApp("SAUDISAT 1C", lotwUploaded = true).toEntity()) // 145.850 -> 2M
+
+ repository.mergeLoTW(listOf(reportConfirmation(satName = "SO-50")))
+
+ val rows = dao.getAll()
+ assertEquals(1, rows.size)
+ assertEquals(uploadedId, rows.first().id)
+ assertTrue(rows.first().lotwConfirmed)
+ // The row is also renamed to the ARRL spelling while it is merged.
+ assertEquals("SO-50", rows.first().satelliteName)
+ assertEquals(145_850_000L, rows.first().txFrequencyHz)
+ }
+
+ @Test
+ fun mergeLoTW_foldsRowsSplitByTheOldNameAndBandMismatch() = runBlocking {
+ val uploadedId = dao.save(loggedInApp("SAUDISAT 1C", lotwUploaded = true).toEntity())
+ // Row imported before the fix: ARRL name and the mirrored band pair (BAND_RX read as
+ // the uplink), so it never folded into the local record.
+ dao.save(
+ reportConfirmation(satName = "SO-50").copy(band = "70CM", rxBand = "2M").toEntity()
+ )
+
+ repository.mergeLoTW(listOf(reportConfirmation(satName = "SO-50")))
+
+ val rows = dao.getAll()
+ assertEquals(1, rows.size)
+ assertEquals(uploadedId, rows.first().id)
+ assertTrue(rows.first().lotwConfirmed)
+ // The operator's own frequencies survive the consolidation.
+ assertEquals(145_850_000L, rows.first().txFrequencyHz)
+ assertEquals(436_795_000L, rows.first().rxFrequencyHz)
+ }
+
+ @Test
+ fun mergeLoTW_keepsAnotherContactOfTheSamePassApart() = runBlocking {
+ dao.save(loggedInApp("SAUDISAT 1C", lotwUploaded = true).toEntity())
+ val otherOperator = reportConfirmation(satName = "SO-50").copy(theirCallsign = "BG5JVM")
+
+ repository.mergeLoTW(listOf(reportConfirmation(satName = "SO-50"), otherOperator))
+
+ val rows = dao.getAll()
+ assertEquals(2, rows.size)
+ // The local row is confirmed by its own report entry; the second operator's contact
+ // is a separate QSO and stays a separate, separately confirmed row.
+ assertEquals(setOf("BG5JSB", "BG5JVM"), rows.map { it.theirCallsign }.toSet())
+ assertTrue(rows.all { it.lotwConfirmed })
+ }
+
+ @Test
+ fun consolidateConfirmations_repairsAnAlreadySplitLogbook() = runBlocking {
+ val uploadedId = dao.save(loggedInApp("SAUDISAT 1C", lotwUploaded = true).toEntity())
+ // Row imported before the fix: ARRL name + mirrored band pair.
+ dao.save(reportConfirmation(satName = "SO-50").copy(band = "70CM", rxBand = "2M").toEntity())
+
+ assertEquals(1, repository.consolidateConfirmations())
+
+ val rows = dao.getAll()
+ assertEquals(1, rows.size)
+ assertEquals(uploadedId, rows.first().id)
+ assertTrue(rows.first().lotwConfirmed)
+ // Nothing left to fold on a second pass.
+ assertEquals(0, repository.consolidateConfirmations())
+ }
+
+ @Test
+ fun consolidateConfirmations_renamesRowsImportedUnderATrackerName() = runBlocking {
+ // A record logged before names were normalised at write time.
+ val id = dao.save(loggedInApp("SAUDISAT 1C", lotwUploaded = true).toEntity())
+
+ repository.consolidateConfirmations()
+
+ val row = dao.getAll().single()
+ assertEquals(id, row.id)
+ assertEquals("SO-50", row.satelliteName)
+ // Upload state and the operator's own frequencies survive the rename.
+ assertTrue(row.lotwUploaded)
+ assertEquals(145_850_000L, row.txFrequencyHz)
+ // A satellite ARRL does not list keeps whatever the tracker published.
+ dao.save(loggedInApp("FOO-1", lotwUploaded = false).toEntity())
+ repository.consolidateConfirmations()
+ assertEquals(setOf("SO-50", "FOO-1"), dao.getAll().map { it.satelliteName }.toSet())
+ }
+
+ @Test
+ fun mergeLoTW_fixesBandDirectionOfAStaleConfirmationRow() = runBlocking {
+ // A confirmation downloaded by the pre-fix parser: ARRL name but the band direction
+ // mirrored (downlink stored as the uplink), and no local row to fold it into, so it
+ // sits as its own QSL row.
+ val staleId = dao.save(reportConfirmation(satName = "SO-50").copy(band = "70CM", rxBand = "2M").toEntity())
+
+ repository.mergeLoTW(listOf(reportConfirmation(satName = "SO-50")))
+
+ val rows = dao.getAll()
+ assertEquals(1, rows.size)
+ assertEquals(staleId, rows.first().id)
+ assertTrue(rows.first().lotwConfirmed)
+ // The stale row adopts the report's (uplink-first) direction instead of being skipped
+ // forever by the dedupe key.
+ assertEquals("2M", rows.first().band)
+ assertEquals("70CM", rows.first().rxBand)
+ }
+
+ @Test
+ fun save_editingAnUploadedRecordCreatesANewRecord() = runBlocking {
+ val originalId = repository.save(loggedInApp("SAUDISAT 1C", lotwUploaded = true))
+ val edited = loggedInApp("SAUDISAT 1C", lotwUploaded = true).copy(id = originalId, theirCallsign = "BG5JVM")
+
+ val newId = repository.save(edited)
+
+ assertNotEquals(originalId, newId)
+ val rows = dao.getAll()
+ assertEquals(2, rows.size)
+ // The original keeps its identity and upload state, untouched.
+ val original = rows.first { it.id == originalId }
+ assertEquals("BG5JSB", original.theirCallsign)
+ assertTrue(original.lotwUploaded)
+ // The edited content is a fresh, uploadable record.
+ val created = rows.first { it.id == newId }
+ assertEquals("BG5JVM", created.theirCallsign)
+ assertFalse(created.lotwUploaded)
+ assertFalse(created.lotwConfirmed)
+ }
+
+ @Test
+ fun save_editingAnUnuploadedRecordUpdatesInPlace() = runBlocking {
+ val id = repository.save(loggedInApp("SAUDISAT 1C", lotwUploaded = false))
+ repository.save(loggedInApp("SAUDISAT 1C", lotwUploaded = false).copy(id = id, theirCallsign = "BG5JVM"))
+
+ val rows = dao.getAll()
+ assertEquals(1, rows.size)
+ assertEquals("BG5JVM", rows.first().theirCallsign)
+ }
+
+ @Test
+ fun save_editingAnUploadedRecordWithoutChangesKeepsOneRow() = runBlocking {
+ val id = repository.save(loggedInApp("SAUDISAT 1C", lotwUploaded = true))
+ repository.save(loggedInApp("SAUDISAT 1C", lotwUploaded = true).copy(id = id))
+
+ assertEquals(1, dao.getAll().size)
+ }
+
+ @Test
+ fun save_editingAnUploadedRecordWithOnlySecondsChangedKeepsOneRow() = runBlocking {
+ val id = repository.save(loggedInApp("SAUDISAT 1C", lotwUploaded = true))
+ // The edit dialog edits whole minutes and rounds the seconds away on save, so a
+ // seconds-only difference must not be treated as an edit (no new record).
+ repository.save(loggedInApp("SAUDISAT 1C", lotwUploaded = true).copy(id = id, startUtcMillis = qsoStart + 10_000L))
+
+ assertEquals(1, dao.getAll().size)
+ }
+
+ /** A record as the log page creates it: tracker name, repeater pair, uplink band. */
+ private fun loggedInApp(satelliteName: String, lotwUploaded: Boolean) = QsoRecord(
+ startUtcMillis = qsoStart,
+ theirCallsign = "BG5JSB",
+ myCallsign = "BA7OPF",
+ txFrequencyHz = 145_850_000L,
+ rxFrequencyHz = 436_795_000L,
+ band = "2M",
+ rxBand = "70CM",
+ mode = "FM",
+ satelliteName = satelliteName,
+ propagationMode = "SAT",
+ status = QsoStatus.COMPLETE,
+ lotwUploaded = lotwUploaded
+ )
+
+ /** A confirmation as the report parser + [toConfirmedRecord] build it (BAND carries the uplink). */
+ private fun reportConfirmation(satName: String) = GridQso(
+ call = "BG5JSB",
+ epochMs = qsoStart,
+ satName = satName,
+ mode = "FM",
+ bandUp = "2M",
+ bandDown = "70CM",
+ dxcc = 318,
+ country = "China",
+ cqz = 24,
+ state = "GD",
+ myGrid = "OL62",
+ myGrids = setOf("OL62", "OL63")
+ ).toConfirmedRecord("BA7OPF")
+
+ private fun QsoRecord.toEntity() = QsoEntity(
+ id = id,
+ startUtcMillis = startUtcMillis,
+ endUtcMillis = endUtcMillis,
+ theirCallsign = theirCallsign,
+ myCallsign = myCallsign,
+ theirGrid = theirGrid,
+ myGrid = myGrid,
+ sentReport = sentReport,
+ receivedReport = receivedReport,
+ txFrequencyHz = txFrequencyHz,
+ rxFrequencyHz = rxFrequencyHz,
+ band = band,
+ rxBand = rxBand,
+ mode = mode,
+ submode = submode,
+ satelliteName = satelliteName,
+ transponderName = transponderName,
+ satelliteMode = satelliteMode,
+ passAosUtcMillis = passAosUtcMillis,
+ automatic = automatic,
+ status = status.name,
+ dedupeKey = listOf(startUtcMillis, theirCallsign, myCallsign, txFrequencyHz, mode, submode, satelliteName)
+ .joinToString("|"),
+ propagationMode = propagationMode,
+ lotwConfirmed = lotwConfirmed,
+ lotwUploaded = lotwUploaded,
+ lotwReceived = lotwReceived,
+ lotwQslDate = lotwQslDate,
+ vuccGrids = vuccGrids.joinToString(","),
+ dxcc = dxcc,
+ country = country,
+ cqZone = cqZone,
+ region = region,
+ comment = comment
+ )
+
+ private class FakeQsoDao : QsoDao {
+ private val rows = linkedMapOf()
+ private var nextId = 1L
+
+ override fun observeAll(): Flow> = flowOf(rows.values.toList())
+
+ override suspend fun find(id: Long): QsoEntity? = rows[id]
+
+ override suspend fun getAll(): List = rows.values.toList()
+
+ override suspend fun getDedupeKeys(): List = rows.values.map { it.dedupeKey }
+
+ override suspend fun save(record: QsoEntity): Long {
+ val id = if (record.id == 0L) nextId++ else record.id
+ rows[id] = record.copy(id = id)
+ return id
+ }
+
+ override suspend fun importRecords(records: List): List = records.map { save(it) }
+
+ override suspend fun delete(id: Long) {
+ rows.remove(id)
+ }
+
+ override suspend fun markUploaded(ids: List) {
+ ids.forEach { id -> rows[id]?.let { rows[id] = it.copy(lotwUploaded = true) } }
+ }
+ }
+}
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 44dec151..ec81c0c1 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,4 +22,12 @@ interface IQsoRepository {
suspend fun importAdi(content: String): AdifImportResult
suspend fun mergeConfirmed(records: List): AdifImportResult
suspend fun mergeLoTW(records: List): AdifImportResult
+
+ /**
+ * Folds LoTW confirmations that were imported as their own rows back into the local
+ * record they belong to (see [com.rtbishop.look4sat.core.domain.logbook.splitConfirmationPairs]).
+ * Confirmations merged before the tracker-name/band identity fix split every contact in
+ * two; this repairs the existing logbook. Returns the number of merged rows.
+ */
+ suspend fun consolidateConfirmations(): Int
}
diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/logbook/LoTWReportBridge.kt b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/logbook/LoTWReportBridge.kt
index 99dd9f46..8e3af0c5 100644
--- a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/logbook/LoTWReportBridge.kt
+++ b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/logbook/LoTWReportBridge.kt
@@ -1,3 +1,21 @@
+/*
+ * Look4Sat-BA7OPF. Amateur radio satellite tracker and pass predictor.
+ * Copyright (C) 2026 BA7OPF.
+ * Based on Look4Sat by 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.model.GridQso
diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/logbook/LoTWSatelliteAliases.kt b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/logbook/LoTWSatelliteAliases.kt
new file mode 100644
index 00000000..c04f96ff
--- /dev/null
+++ b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/logbook/LoTWSatelliteAliases.kt
@@ -0,0 +1,148 @@
+/*
+ * 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 java.util.Locale
+
+/**
+ * Satellite names of the ARRL LoTW catalogue and the tracker names that map onto them.
+ *
+ * ARRL's config.tq6 knows every satellite by exactly ONE name ("SO-50", "ARISS", "PO-101",
+ * "BO-102"), while the TLE sources the tracker uses carry their own catalogue names: SatNOGS
+ * lists SO-50 as "SAUDISAT 1C", the ISS as "ISS (ZARYA)", PO-101 as "DIWATA 2B" and AO-123 as
+ * "OBJECT AY". Without this mapping a signed QSO on those satellites is rejected as an unknown
+ * satellite.
+ *
+ * Keys are tracker names normalised by [normalize] (upper case, A-Z0-9 only); values are the
+ * exact config.tq6 satellite names. Generated 2026-09-27 from:
+ * - SatNOGS DB satellites (`name` + `names` alias list) joined to config.tq6 by exact alias,
+ * - live SatNOGS 3LE names and Celestrak amateur names joined to the same NORAD id,
+ * - designator tokens of the config.tq6 satellite descriptions (e.g. CAS-7B -> BO-102),
+ * - the ISS family, which SatNOGS does not list under its ARRL name "ARISS".
+ * Placeholder names ("OBJECT AY" and friends) are deliberately NOT mapped: they are recycled
+ * between objects and would sign the wrong satellite. LoTWConfigTest asserts that every target
+ * here is a real config.tq6 name.
+ */
+object LoTWSatelliteAliases {
+
+ val table: Map = mapOf(
+ "AMSATOSCAR7" to "AO-7", "AO123" to "AO-123", "AO16" to "AO-16",
+ "AO27" to "AO-27", "AO7" to "AO-7", "AO73" to "AO-73",
+ "AO85" to "AO-85", "AO91" to "AO-91", "AO92" to "AO-92",
+ "ARISSAT1" to "KEDR", "ASRTU1" to "AO-123", "ASRTUFRIENDSHIP" to "AO-123",
+ "BJ2CR" to "AO-123", "BREEZEKMRB" to "RS-44", "BRICSAT2" to "NO-103",
+ "BRICSATP" to "NO-83", "BY701" to "BY70-1", "CAMSAT" to "FO-118",
+ "CAS10" to "HO-119", "CAS2F" to "XW-2F", "CAS2T" to "CAS-2T",
+ "CAS3A" to "XW-2A", "CAS3B" to "XW-2B", "CAS3C" to "XW-2C",
+ "CAS3CBJ1SD" to "XW-2C", "CAS3D" to "XW-2D", "CAS3E" to "XW-2E",
+ "CAS3F" to "XW-2F", "CAS4A" to "CAS-4A", "CAS4B" to "CAS-4B",
+ "CAS5A" to "FO-118", "CAS6" to "TO-108", "CAS7B" to "BO-102",
+ "CAS9" to "HO-113", "CZ11RB" to "CAS-2T", "DELFIC3" to "DO-64",
+ "DIWATA2" to "PO-101", "DIWATA2B" to "PO-101", "DO64" to "DO-64",
+ "DOSAAF85" to "RS-44", "DRUZHBAATURK" to "AO-123", "EO79" to "EO-79",
+ "EO88" to "EO-88", "ESHAIL2" to "QO-100", "EUROPEANOSCAR79" to "EO-79",
+ "EYESAT1" to "AO-27", "EYESATA" to "AO-27", "FENGTAIOSCAR118" to "FO-118",
+ "FO118" to "FO-118", "FO29" to "FO-29", "FOX1" to "AO-85",
+ "FOX1A" to "AO-85", "FOX1B" to "AO-91", "FOX1D" to "AO-92",
+ "FRESCO" to "LO-87", "FUNCUBE1" to "AO-73", "FUNCUBE2" to "UKUBE1",
+ "FUNCUBE3" to "EO-79", "FUNCUBE5" to "EO-88", "GREENCUBE" to "IO-117",
+ "HADESD" to "SO-121", "HADESICM" to "SO-125", "HADESR" to "SO-124",
+ "HAIL2" to "QO-100", "HAMSAT" to "VO-52", "HO113" to "HO-113",
+ "HO119" to "HO-119", "HOPE4" to "HO-119", "HYDRA1" to "SO-121",
+ "INSPIRESAT7" to "INSPR7", "IO117" to "IO-117", "IO86" to "IO-86",
+ "ISS" to "ARISS", "ISSZARYA" to "ARISS", "JAS2" to "FO-29",
+ "JINNIUZUO1" to "TAURUS", "JO97" to "JO-97", "JORDANOSCAR97" to "JO-97",
+ "JY1SAT" to "JO-97", "LAPANA2" to "IO-86", "LILACSAT1" to "LO-90",
+ "LILACSAT2" to "CAS-3H", "LITUANICASAT1" to "LO-78", "LO19" to "LO-19",
+ "LO87" to "LO-87", "LUSAT" to "LO-19", "LUSEX" to "LO-87",
+ "MAYA3" to "MAYA-3", "MAYA4" to "MAYA-4", "MESAT1" to "MO-122",
+ "MESAT1OSCAR" to "MO-122", "MESAT1OSCAR122" to "MO-122", "MIRSAT1" to "MO-112",
+ "MO122" to "MO-122", "MTCUBE2" to "IO-117", "NA1SS" to "ARISS",
+ "NAYIF1" to "EO-88", "NEWSAT1" to "LO-87", "NO103" to "NO-103",
+ "NO104" to "NO-104", "NO44" to "NO-44", "NO83" to "NO-83",
+ "NO84" to "NO-84", "NUSAT1" to "LO-87", "ORARI" to "IO-86",
+ "OSCAR16PACSAT" to "AO-16", "OSCAR19LUSAT" to "LO-19", "OSCAR64" to "DO-64",
+ "OSCAR7" to "AO-7", "PACSAT" to "AO-16", "PARKINSONSAT" to "NO-84",
+ "PCSAT" to "NO-44", "PHILLIPINESOSCAR101" to "PO-101", "PO101" to "PO-101",
+ "PSAT" to "NO-84", "PSAT2" to "NO-104", "QB50P1" to "EO-79",
+ "QO100" to "QO-100", "RADFXSAT" to "AO-91", "RADIOROSTO" to "RS-15",
+ "RS0ISS" to "ARISS", "RS14" to "AO-21", "RS15" to "RS-15",
+ "RS44" to "RS-44", "RS64S" to "AO-123", "SAUDISAT1C" to "SO-50",
+ "SO121" to "SO-121", "SO124" to "SO-124", "SO125" to "SO-125",
+ "SO50" to "SO-50", "SOLUTUSNANOSATTELITE" to "SONATE", "SONATE2" to "SONATE",
+ "TAURUS1" to "TAURUS", "TEVEL21" to "TEV2-1", "TEVEL22" to "TEV2-2",
+ "TEVEL23" to "TEV2-3", "TEVEL24" to "TEV2-4", "TEVEL25" to "TEV2-5",
+ "TEVEL26" to "TEV2-6", "TEVEL27" to "TEV2-7", "TEVEL28" to "TEV2-8",
+ "TEVEL29" to "TEV2-9", "USAT1" to "LO-87", "USNAP1" to "NO-103",
+ "VO52" to "VO-52", "W3ADO" to "NO-44", "XW2A" to "XW-2A",
+ "XW2B" to "XW-2B", "XW2C" to "XW-2C", "XW2D" to "XW-2D",
+ "XW2E" to "XW-2E", "XW2F" to "XW-2F", "XW3" to "HO-113",
+ "XW3CAS9" to "HO-113", "XW4" to "HO-119", "YB0X" to "IO-86",
+ "ZARYA" to "ARISS",
+
+ )
+
+ /**
+ * Tracker name -> ARRL name. An exact table hit wins; otherwise the longest alias key
+ * contained in the name matches, so a name carrying an extra suffix ("SAUDISAT 1C
+ * (SO-50)") still resolves. Keys shorter than 5 characters never match loosely.
+ */
+ fun lookup(name: String): String? {
+ val normalized = normalize(name)
+ table[normalized]?.let { return it }
+ return table.entries
+ .filter { it.key.length >= 5 && normalized.contains(it.key) }
+ .maxByOrNull { it.key.length }
+ ?.value
+ }
+
+ /** Upper case, everything that is not A-Z0-9 removed ("ISS (ZARYA)" -> "ISSZARYA"). */
+ fun normalize(name: String): String = name.uppercase(Locale.US).filter { it.isLetterOrDigit() }
+
+ /**
+ * Resolve any tracker name against the ARRL catalogue: exact official name, then an alias
+ * from [table], then an official name contained in the tracker name. Returns the official
+ * name, or null when the tracker name is not an ARRL satellite at all.
+ */
+ fun resolve(name: String, officialNames: Collection): String? {
+ val trimmed = name.trim()
+ if (trimmed.isEmpty()) return null
+ val normalized = trimmed.uppercase(Locale.US)
+ officialNames.firstOrNull { it.equals(trimmed, true) }?.let { return it }
+ lookup(trimmed)?.let { alias -> officialNames.firstOrNull { it.equals(alias, true) }?.let { return it } }
+ return officialNames.firstOrNull { it.length > 1 && normalized.contains(it) }
+ }
+
+ /**
+ * Catalogue candidates for a partly typed name. Alias keys are matched too, so the
+ * operator's own spelling ("diwata", "saudisat 1c", "ao9") reaches the ARRL name even
+ * when the two share no characters.
+ */
+ fun suggestions(query: String, officialNames: Collection, limit: Int = 6): List {
+ val normalized = normalize(query)
+ if (normalized.isEmpty()) return emptyList()
+ val fromAliases = table.entries
+ .filter { it.key.contains(normalized) || normalized.contains(it.key) }
+ .map { it.value }
+ val fromNames = officialNames.filter { normalize(it).contains(normalized) }
+ return (fromAliases + fromNames + listOfNotNull(lookup(query)))
+ .distinct()
+ .filter { candidate -> officialNames.any { it.equals(candidate, true) } }
+ .take(limit)
+ }
+}
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
new file mode 100644
index 00000000..7ca9d509
--- /dev/null
+++ b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/logbook/LoTWUploadMessages.kt
@@ -0,0 +1,70 @@
+/*
+ * 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
+
+/**
+ * Wording for the reasons a QSO cannot be signed for LoTW.
+ *
+ * The logbook used to report every one of these as "invalid call/date", which sent the operator
+ * looking at the callsign and date even when the real cause was a missing frequency (no BAND) or
+ * a satellite name ARRL does not know.
+ */
+fun LoTWProblem.label(): String = when (this) {
+ LoTWProblem.BAND -> "missing frequency/band"
+ LoTWProblem.SATELLITE -> "satellite name not in ARRL's list"
+ LoTWProblem.MODE -> "mode not accepted by LoTW"
+ LoTWProblem.QSO_DATE -> "date outside the certificate"
+ LoTWProblem.CALLSIGN_MISMATCH -> "MY callsign does not match the certificate"
+ LoTWProblem.INVALID_CONTACT -> "invalid callsign/record"
+ LoTWProblem.STATION_GRID -> "station grid missing"
+ LoTWProblem.STATION_REGION -> "station region missing"
+ LoTWProblem.STATION_ZONE -> "station zones missing"
+ LoTWProblem.STATION_IOTA -> "station IOTA missing"
+ LoTWProblem.TOO_MANY_CONTACTS -> "too many contacts in one batch"
+ LoTWProblem.EMPTY_SELECTION -> "nothing selected"
+ else -> name.lowercase().replace('_', ' ')
+}
+
+/**
+ * One-line explanation of why records were left out of an upload, e.g.
+ * "3 QSO(s) can't be uploaded — 2× missing frequency/band, 1× satellite name not in ARRL's list (SAUDISAT 1C)".
+ * Returns an empty string when nothing was skipped.
+ */
+fun unavailableUploadSummary(
+ total: Int,
+ reasons: Map = emptyMap(),
+ details: Map = emptyMap(),
+ duplicates: Int = 0,
+ incomplete: Int = 0
+): String {
+ if (total <= 0) return ""
+ val parts = reasons.entries
+ .sortedByDescending { it.value }
+ .map { (problem, count) ->
+ val detail = details[problem]?.takeIf { it.isNotBlank() }?.let { " ($it)" }.orEmpty()
+ "$count× ${problem.label()}$detail"
+ }
+ .toMutableList()
+ if (duplicates > 0) parts += "$duplicates× duplicate of another record in the batch"
+ if (incomplete > 0) parts += "$incomplete× record not completed"
+ val subject = if (total == 1) "1 QSO can't be uploaded" else "$total QSO(s) can't be uploaded"
+ val detail = parts.joinToString(", ").ifBlank { "check the logbook" }
+ return "$subject — $detail"
+}
diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/logbook/QsoConfirmationMerge.kt b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/logbook/QsoConfirmationMerge.kt
index 95a7bb65..ebce6de2 100644
--- a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/logbook/QsoConfirmationMerge.kt
+++ b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/logbook/QsoConfirmationMerge.kt
@@ -1,26 +1,134 @@
+/*
+ * Look4Sat-BA7OPF. Amateur radio satellite tracker and pass predictor.
+ * Copyright (C) 2026 BA7OPF.
+ * Based on Look4Sat by 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 java.util.Locale
import kotlin.math.abs
-/** LoTW official name vs SatNOGS name ("SO-50 (SaudiOSCAR 50)"): compare the
- * parenthetical-free prefix so local records and downloaded confirmations match. */
+/** LoTW official name vs tracking-source name ("SO-50 (SaudiOSCAR 50)"): compare the
+ * parenthetical-free prefix. Kept as the last-resort identity for names the alias
+ * table does not know (see [satelliteIdentity]). */
fun satMatchKey(name: String): String = name.substringBefore('(').trim().uppercase(Locale.US)
+/**
+ * The satellite identity both sides of a match resolve to.
+ *
+ * A local record keeps whatever the tracking source published ("SAUDISAT 1C",
+ * "ISS (ZARYA)", "DIWATA 2B", "JAS 2") while the LoTW report always carries the ARRL
+ * name ("SO-50", "ARISS", "PO-101", "FO-29"). Comparing the raw names made the two
+ * sides of the SAME contact unequal, so a downloaded confirmation was stored as a
+ * second, separate QSO instead of confirming the uploaded one.
+ *
+ * Both sides are therefore resolved through [LoTWSatelliteAliases], which maps the
+ * tracker names onto the ARRL names and every ARRL name onto itself. Names the table
+ * does not know (e.g. the recycled "OBJECT xx" placeholders) keep falling back to the
+ * parenthetical-free prefix, i.e. they simply never match anything else.
+ */
+fun satelliteIdentity(name: String): String {
+ val trimmed = name.trim()
+ if (trimmed.isEmpty()) return ""
+ val prefix = satMatchKey(trimmed)
+ return LoTWSatelliteAliases.lookup(trimmed)
+ ?: LoTWSatelliteAliases.lookup(prefix)
+ ?: prefix
+}
+
+/**
+ * The name a QSO is stored under: the ARRL name whenever the tracking source's name maps onto
+ * one, otherwise the name as published.
+ *
+ * Records used to keep the tracker's spelling ("SAUDISAT 1C", "ISS (ZARYA)", "OBJECT AY") while
+ * every signed ADIF carries the ARRL name, so the logbook showed a different satellite than the
+ * one LoTW knows and only the upload path resolved the two. Storing the ARRL name keeps one name
+ * for the logbook, the ADIF export and the signature. Names the alias table does not know are
+ * left exactly as they are: satellites ARRL does not list have no official name to store, and the
+ * recycled "OBJECT xx" placeholders must never be mapped onto a real satellite.
+ */
+fun officialSatelliteName(name: String, officialNames: Collection = emptyList()): String {
+ val trimmed = name.trim()
+ if (trimmed.isEmpty()) return ""
+ LoTWSatelliteAliases.resolve(trimmed, officialNames)?.let { return it }
+ val prefix = satMatchKey(trimmed)
+ return LoTWSatelliteAliases.lookup(trimmed) ?: LoTWSatelliteAliases.lookup(prefix) ?: trimmed
+}
+
+/**
+ * Everything about a contact except its bands: the same opposite station, the same
+ * satellite, the same mode and less than a minute apart. LoTW reports the minute, so
+ * the time window is what makes a match unambiguous.
+ */
+fun sameContactIdentity(local: QsoRecord, remote: QsoRecord): Boolean {
+ val localSat = satelliteIdentity(local.satelliteName)
+ return localSat.isNotBlank() &&
+ localSat == satelliteIdentity(remote.satelliteName) &&
+ local.theirCallsign.trim().equals(remote.theirCallsign.trim(), true) &&
+ (local.myCallsign.isBlank() || remote.myCallsign.isBlank() || local.myCallsign.equals(remote.myCallsign, true)) &&
+ local.isSatellite == remote.isSatellite &&
+ local.displayMode == remote.displayMode &&
+ abs(local.startUtcMillis - remote.startUtcMillis) < 60_000L
+}
+
+/** Bands agree when both sides name one and they are the same; a missing band is not a mismatch. */
+private fun sameBandOrBlank(one: String, other: String): Boolean =
+ one.isBlank() || other.isBlank() || one.equals(other, true)
+
/** LoTW omits frequency and may round time to a minute. Only merge an unambiguous contact. */
fun sameConfirmedContact(local: QsoRecord, remote: QsoRecord): Boolean =
- local.theirCallsign.trim().equals(remote.theirCallsign.trim(), true) &&
- (local.myCallsign.isBlank() || remote.myCallsign.isBlank() || local.myCallsign.equals(remote.myCallsign, true)) &&
- satMatchKey(local.satelliteName) == satMatchKey(remote.satelliteName) &&
- local.isSatellite == remote.isSatellite &&
- local.displayMode == remote.displayMode &&
- (local.band.isBlank() || remote.band.isBlank() || local.band.equals(remote.band, true)) &&
- abs(local.startUtcMillis - remote.startUtcMillis) < 60_000L
+ sameContactIdentity(local, remote) && sameBandOrBlank(local.band, remote.band)
fun QsoRecord.confirmationLookupKey(): String = listOf(
- theirCallsign.trim().uppercase(Locale.US), satMatchKey(satelliteName), displayMode
+ theirCallsign.trim().uppercase(Locale.US), satelliteIdentity(satelliteName), displayMode
).joinToString("|")
+/** A local row and the confirmation row that belongs to it, as indices into one list. */
+data class SplitConfirmationPair(val localIndex: Int, val confirmationIndex: Int)
+
+/**
+ * Rows that a confirmation should have been folded into but was not, so they can be
+ * consolidated back into one.
+ *
+ * Confirmations merged before the identity fix landed as their own rows: the tracker
+ * name vs ARRL name comparison failed, and the band direction of the report was read
+ * mirrored (BAND_RX was taken as the uplink), so even satellites with matching names
+ * were stored twice. The pair is only accepted when the contact identity matches and
+ * the bands are either identical or exactly mirrored — the signature of that second
+ * mismatch — which keeps unrelated QSOs of the same operator and minute apart.
+ */
+fun splitConfirmationPairs(records: List): List {
+ val pendingIndices = records.indices.filter { !records[it].lotwConfirmed }
+ val taken = mutableSetOf()
+ return records.indices.filter { records[it].lotwConfirmed }.mapNotNull { confirmationIndex ->
+ val confirmation = records[confirmationIndex]
+ val localIndex = pendingIndices.firstOrNull { index ->
+ index !in taken && sameContactIdentity(records[index], confirmation) &&
+ bandsMatchOrMirror(records[index], confirmation)
+ } ?: return@mapNotNull null
+ taken += localIndex
+ SplitConfirmationPair(localIndex, confirmationIndex)
+ }
+}
+
+/** Same band on both sides, or the mirrored pair the pre-fix parser produced. */
+private fun bandsMatchOrMirror(local: QsoRecord, confirmation: QsoRecord): Boolean =
+ (sameBandOrBlank(local.band, confirmation.band) && sameBandOrBlank(local.rxBand, confirmation.rxBand)) ||
+ (sameBandOrBlank(local.band, confirmation.rxBand) && sameBandOrBlank(local.rxBand, confirmation.band))
+
fun QsoRecord.withConfirmation(confirmed: QsoRecord): QsoRecord = copy(
myCallsign = myCallsign.ifBlank { confirmed.myCallsign },
theirGrid = confirmed.theirGrid.ifBlank { theirGrid },
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 e851d5b0..005c3881 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
@@ -1,3 +1,21 @@
+/*
+ * Look4Sat-BA7OPF. Amateur radio satellite tracker and pass predictor.
+ * Copyright (C) 2026 BA7OPF.
+ * Based on Look4Sat by 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.repository
import com.rtbishop.look4sat.core.domain.logbook.QsoRecord
@@ -11,6 +29,9 @@ interface ILoTWUploadRepository {
suspend fun removeCertificate()
/** Region field (State/Province/Prefecture…) and national CQZ/ITUZ map for a DXCC entity. */
suspend fun stationMeta(dxcc: Int): LoTWStationMeta
+ /** Every satellite name ARRL accepts (config.tq6), sorted. Only these names may be
+ * signed, so the logbook UI validates/picks satellite names from this list. */
+ suspend fun satelliteCatalog(): List
suspend fun audit(records: List): LoTWUploadAudit
suspend fun prepare(records: List, resubmit: Boolean): LoTWUploadPreview
suspend fun upload(previewId: String): LoTWUploadResult
@@ -69,18 +90,30 @@ data class LoTWUploadPreview(
val unknownSkipped: Int = 0,
/** Un-signable records (invalid call/date/…) skipped instead of aborting the batch. */
val unavailableSkipped: Int = 0,
+ /** Why those records were un-signable, so the operator sees the real cause. */
+ val unavailableReasons: Map = emptyMap(),
+ /** Records dropped because the same contact already appears earlier in the batch. */
+ val duplicateSkipped: Int = 0,
/** Ids of the records that actually made it into this TQ8 batch. Only these
* may be marked "uploaded" after an accepted POST — never the full candidate list. */
val submittedIds: List = emptyList()
)
-/** Local comparison against downloaded LoTW receipt flags and this app's durable upload receipts. */
+/** Why the un-uploadable records of a selection cannot be signed, and what was left out. */
data class LoTWUploadAudit(
val total: Int,
val pending: Int,
val uploaded: Int,
val unknown: Int,
- val unavailable: Int
+ val unavailable: Int,
+ /** Signing failures counted per reason (see [com.rtbishop.look4sat.core.domain.logbook.label]). */
+ val reasons: Map = emptyMap(),
+ /** First offending value per reason, e.g. SATELLITE -> "SAUDISAT 1C". */
+ val details: Map = emptyMap(),
+ /** Records dropped as duplicates of an earlier record in the same selection. */
+ val duplicates: Int = 0,
+ /** Records that are not marked complete, so they are not uploadable at all. */
+ val incomplete: Int = 0
)
sealed interface LoTWUploadResult {
diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/utility/VoiceRepeater.kt b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/utility/VoiceRepeater.kt
new file mode 100644
index 00000000..891f3291
--- /dev/null
+++ b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/utility/VoiceRepeater.kt
@@ -0,0 +1,53 @@
+/*
+ * 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.utility
+
+import com.rtbishop.look4sat.core.domain.model.SatRadio
+import kotlin.math.abs
+
+/** Nominal uplink frequency: the middle of the passband when one is published. */
+fun SatRadio.uplinkHz(): Long? = uplinkLow?.let { low -> uplinkHigh?.let { high -> (low + high) / 2 } ?: low }
+
+/** Nominal downlink frequency: the middle of the passband when one is published. */
+fun SatRadio.downlinkHz(): Long? = downlinkLow?.let { low -> downlinkHigh?.let { high -> (low + high) / 2 } ?: low }
+
+/**
+ * The FM voice repeater of a satellite, if it has one.
+ *
+ * A voice repeater is the single-frequency FM transceiver the operator actually
+ * talks through (SatNOGS type "Transceiver", e.g. SO-50 "Mode V/U FM Voice
+ * CTCSS 67.0 Hz"). Beacons and telemetry carry no uplink, so requiring BOTH an
+ * uplink and a downlink already filters them out; the FM mode check removes
+ * AFSK/APRS and linear entries.
+ *
+ * Ranking when several FM duplex entries exist (ISS is the awkward case, it also
+ * lists crew V/V channels):
+ * 1. entries described as a repeater / voice channel,
+ * 2. amateur service entries,
+ * 3. cross-band pairs (a V/U repeater over a same-band V/V downlink).
+ */
+fun List.voiceRepeater(): SatRadio? = asSequence()
+ .filter { it.isAlive }
+ .filter { it.uplinkHz() != null && it.downlinkHz() != null }
+ .filter { it.uplinkMode.equals("FM", true) || it.downlinkMode.equals("FM", true) }
+ .sortedWith(
+ compareByDescending { it.info.contains("repeater", true) || it.info.contains("voice", true) }
+ .thenByDescending { it.service.equals("Amateur", true) }
+ .thenByDescending { abs((it.downlinkHz() ?: 0L) - (it.uplinkHz() ?: 0L)) }
+ )
+ .firstOrNull()
diff --git a/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/LoTWSatelliteAliasesTest.kt b/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/LoTWSatelliteAliasesTest.kt
new file mode 100644
index 00000000..45390742
--- /dev/null
+++ b/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/LoTWSatelliteAliasesTest.kt
@@ -0,0 +1,78 @@
+/*
+ * Look4Sat-BA7OPF. Amateur radio satellite tracker and pass predictor.
+ * Copyright (C) 2026 BA7OPF.
+ * Based on Look4Sat by 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
+
+import com.rtbishop.look4sat.core.domain.logbook.LoTWSatelliteAliases
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+/** Alias table that maps tracker catalogue names onto the names ARRL's config.tq6 uses. */
+class LoTWSatelliteAliasesTest {
+
+ private val catalogue = listOf("SO-50", "ARISS", "PO-101", "BO-102", "AO-91", "RS-44", "IO-117")
+
+ @Test
+ fun `lookup maps SatNOGS catalogue names to ARRL names`() {
+ assertEquals("SO-50", LoTWSatelliteAliases.lookup("SAUDISAT 1C"))
+ assertEquals("ARISS", LoTWSatelliteAliases.lookup("ISS (ZARYA)"))
+ assertEquals("PO-101", LoTWSatelliteAliases.lookup("DIWATA 2B"))
+ assertEquals("AO-91", LoTWSatelliteAliases.lookup("FOX-1B"))
+ assertEquals("RS-44", LoTWSatelliteAliases.lookup("DOSAAF-85"))
+ assertEquals("IO-117", LoTWSatelliteAliases.lookup("GREENCUBE"))
+ }
+
+ @Test
+ fun `normalize strips separators and case`() {
+ assertEquals("ISSZARYA", LoTWSatelliteAliases.normalize("iss (zarya)"))
+ assertEquals("SAUDISAT1C", LoTWSatelliteAliases.normalize("SaudiSat-1C"))
+ }
+
+ @Test
+ fun `lookup ignores unknown and placeholder names`() {
+ assertNull(LoTWSatelliteAliases.lookup("NOT A SATELLITE"))
+ // Recycled temporary designations must never resolve: they would sign the wrong object.
+ assertNull(LoTWSatelliteAliases.lookup("OBJECT AY"))
+ }
+
+ @Test
+ fun `resolve accepts ARRL names, aliases and suffixed tracker names`() {
+ assertEquals("SO-50", LoTWSatelliteAliases.resolve("SO-50", catalogue))
+ assertEquals("SO-50", LoTWSatelliteAliases.resolve("saudisat 1c", catalogue))
+ assertEquals("SO-50", LoTWSatelliteAliases.resolve("SAUDISAT 1C (SO-50)", catalogue))
+ assertEquals("ARISS", LoTWSatelliteAliases.resolve("ISS (ZARYA)", catalogue))
+ assertEquals("AO-91", LoTWSatelliteAliases.resolve("AO-91 (RadFxSat)", catalogue))
+ }
+
+ @Test
+ fun `resolve rejects names that are not ARRL satellites`() {
+ assertNull(LoTWSatelliteAliases.resolve("", catalogue))
+ assertNull(LoTWSatelliteAliases.resolve("MARINA", catalogue))
+ assertNull(LoTWSatelliteAliases.resolve("OBJECT AY", catalogue))
+ }
+
+ @Test
+ fun `suggestions find the ARRL name from a tracker spelling`() {
+ assertEquals(listOf("SO-50"), LoTWSatelliteAliases.suggestions("saudisat 1c", catalogue))
+ assertTrue(LoTWSatelliteAliases.suggestions("ao9", catalogue).contains("AO-91"))
+ assertEquals(listOf("PO-101"), LoTWSatelliteAliases.suggestions("diwata", catalogue))
+ assertTrue(LoTWSatelliteAliases.suggestions("", catalogue).isEmpty())
+ }
+}
diff --git a/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/VoiceRepeaterTest.kt b/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/VoiceRepeaterTest.kt
new file mode 100644
index 00000000..d60638fd
--- /dev/null
+++ b/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/VoiceRepeaterTest.kt
@@ -0,0 +1,140 @@
+/*
+ * Look4Sat-BA7OPF. Amateur radio satellite tracker and pass predictor.
+ * Copyright (C) 2026 BA7OPF.
+ * Based on Look4Sat by 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
+
+import com.rtbishop.look4sat.core.domain.model.SatRadio
+import com.rtbishop.look4sat.core.domain.utility.downlinkHz
+import com.rtbishop.look4sat.core.domain.utility.uplinkHz
+import com.rtbishop.look4sat.core.domain.utility.voiceRepeater
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNull
+import org.junit.Test
+
+/** Fixtures are the real SatNOGS transceiver rows of the three hardcoded AMSAT Live FM
+ * satellites (fetched 2026-09-27), so the selection rules are tested against live data. */
+class VoiceRepeaterTest {
+
+ private fun radio(
+ uuid: String,
+ info: String,
+ up: Long? = null,
+ upHigh: Long? = null,
+ down: Long? = null,
+ downHigh: Long? = null,
+ upMode: String? = null,
+ downMode: String? = null,
+ alive: Boolean = true,
+ service: String? = null,
+ catnum: Int = 12345
+ ) = SatRadio(
+ uuid = uuid, info = info, isAlive = alive,
+ downlinkLow = down, downlinkHigh = downHigh, downlinkMode = downMode,
+ uplinkLow = up, uplinkHigh = upHigh, uplinkMode = upMode,
+ isInverted = false, catnum = catnum, service = service
+ )
+
+ // SO-50 (27607)
+ private val so50Tlm = radio("so50-tlm", "Mode V TLM", down = 149_025_000L, downMode = "FM")
+ private val so50Voice = radio(
+ "so50-voice", "Mode V/U FM Voice CTCSS 67.0 Hz",
+ up = 145_850_000L, down = 436_795_000L, upMode = "FM", downMode = "FM", catnum = 27607
+ )
+
+ // ISS (25544): a voice repeater plus two crew V/V channels and an APRS transceiver
+ private val issCrew2 = radio(
+ "iss-crew2", "Mode V/V FM (crew R2+3)",
+ up = 144_490_000L, down = 145_800_000L, upMode = "FM", downMode = "FM", catnum = 25544
+ )
+ private val issCrew1 = radio(
+ "iss-crew1", "Mode V/V FM (crew R1)",
+ up = 145_200_000L, down = 145_800_000L, upMode = "FM", downMode = "FM", catnum = 25544
+ )
+ private val issVoice = radio(
+ "iss-voice", "Mode V/U FM - Voice Repeater CTCSS 67.0 Hz",
+ up = 145_990_000L, down = 437_800_000L, upMode = "FM", downMode = "FM",
+ service = "Amateur", catnum = 25544
+ )
+ private val issAprs = radio(
+ "iss-aprs", "Mode V APRS",
+ up = 145_825_000L, down = 145_825_000L, upMode = "AFSK", downMode = "AFSK", catnum = 25544
+ )
+
+ // AO-123 / ASRTU-1 (61781)
+ private val ao123Voice = radio(
+ "ao123-voice", "Mode V/U - FM Transceiver",
+ up = 145_850_000L, down = 435_400_000L, upMode = "FM", downMode = "FM",
+ service = "Amateur", catnum = 61781
+ )
+
+ @Test
+ fun voiceRepeater_picksSo50VoicePair() {
+ val repeater = listOf(so50Tlm, so50Voice).voiceRepeater()
+ assertEquals("so50-voice", repeater?.uuid)
+ assertEquals(145_850_000L, repeater?.uplinkHz())
+ assertEquals(436_795_000L, repeater?.downlinkHz())
+ }
+
+ @Test
+ fun voiceRepeater_prefersVoiceChannelOverCrewChannels() {
+ val transmitterList = listOf(issCrew2, issAprs, issVoice, issCrew1)
+ // Order must not matter - ranking decides.
+ assertEquals("iss-voice", transmitterList.voiceRepeater()?.uuid)
+ assertEquals("iss-voice", transmitterList.reversed().voiceRepeater()?.uuid)
+ }
+
+ @Test
+ fun voiceRepeater_picksAo123Transceiver() {
+ val repeater = listOf(ao123Voice).voiceRepeater()
+ assertEquals(145_850_000L, repeater?.uplinkHz())
+ assertEquals(435_400_000L, repeater?.downlinkHz())
+ }
+
+ @Test
+ fun voiceRepeater_ignoresBeaconsWithoutUplink() {
+ assertNull(listOf(so50Tlm).voiceRepeater())
+ }
+
+ @Test
+ fun voiceRepeater_ignoresDeadEntries() {
+ val dead = so50Voice.copy(uuid = "dead", isAlive = false)
+ assertNull(listOf(dead).voiceRepeater())
+ }
+
+ @Test
+ fun voiceRepeater_ignoresLinearTransponders() {
+ val linear = radio(
+ "lin", "Linear Transponder",
+ up = 145_900_000L, upHigh = 145_950_000L, down = 435_600_000L, downHigh = 435_650_000L,
+ upMode = "LSB", downMode = "USB"
+ )
+ assertNull(listOf(linear).voiceRepeater())
+ }
+
+ @Test
+ fun uplinkHz_usesPassbandMiddleWhenPresent() {
+ val wide = so50Voice.copy(uuid = "wide", uplinkLow = 145_840_000L, uplinkHigh = 145_860_000L)
+ assertEquals(145_850_000L, wide.uplinkHz())
+ assertEquals(145_850_000L, so50Voice.uplinkHz())
+ }
+
+ @Test
+ fun downlinkHz_isNullWhenNoDownlink() {
+ assertNull(so50Voice.copy(downlinkLow = null).downlinkHz())
+ }
+}
diff --git a/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/logbook/LogbookMergeTest.kt b/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/logbook/LogbookMergeTest.kt
index 6a38c38f..bb120a45 100644
--- a/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/logbook/LogbookMergeTest.kt
+++ b/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/logbook/LogbookMergeTest.kt
@@ -12,6 +12,7 @@ package com.rtbishop.look4sat.core.domain.logbook
import com.rtbishop.look4sat.core.domain.model.GridQso
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
+import org.junit.Assert.assertNotEquals
import org.junit.Assert.assertTrue
import org.junit.Test
@@ -133,4 +134,140 @@ class LogbookMergeTest {
assertEquals("FM", staleFm.displayMode)
assertEquals("CW", staleCw.displayMode)
}
+
+ // region logbook split (tracker name / band direction, 2026-09-28)
+
+ @Test
+ fun satelliteIdentity_mapsTrackerNamesOntoArlNames() {
+ // Local records keep the tracking-source name, LoTW reports the ARRL name; both
+ // must land on one identity or every confirmation becomes a second row.
+ assertEquals("SO-50", satelliteIdentity("SAUDISAT 1C"))
+ assertEquals("SO-50", satelliteIdentity("SO-50"))
+ assertEquals("ARISS", satelliteIdentity("ISS (ZARYA)"))
+ assertEquals("ARISS", satelliteIdentity("ARISS"))
+ assertEquals("PO-101", satelliteIdentity("DIWATA 2B"))
+ assertEquals("FO-29", satelliteIdentity("JAS 2"))
+ }
+
+ @Test
+ fun satelliteIdentity_isSymmetricForEveryAliasEntry() {
+ // Guards the whole table: an entry whose target does not resolve back to the same
+ // identity would split that satellite's contacts again.
+ LoTWSatelliteAliases.table.forEach { (tracker, official) ->
+ assertEquals(official, satelliteIdentity(tracker), satelliteIdentity(official))
+ }
+ }
+
+ @Test
+ fun satelliteIdentity_keepsRecycledPlaceholdersUnmatched() {
+ // "OBJECT xx" names are reused between objects and deliberately unmapped, so they
+ // must never resolve onto a real ARRL satellite.
+ assertEquals("OBJECT AY", satelliteIdentity("OBJECT AY"))
+ assertNotEquals(satelliteIdentity("AO-123"), satelliteIdentity("OBJECT AY"))
+ }
+
+ @Test
+ fun sameConfirmedContact_matchesTrackerNameAgainstArlName() {
+ // The reported bug: SO-50 logged in the app (tracker name, 145.850 uplink) never
+ // picked up the confirmation LoTW reported for it.
+ val uploaded = loggedRecord("SAUDISAT 1C", uplinkHz = 145_850_000L, downlinkHz = 436_795_000L)
+ val reported = reportedRecord("SO-50", band = "2M", rxBand = "70CM")
+ assertTrue(sameConfirmedContact(uploaded, reported))
+ }
+
+ @Test
+ fun sameConfirmedContact_stillRejectsOtherContactOrMinute() {
+ val uploaded = loggedRecord("SAUDISAT 1C", uplinkHz = 145_850_000L, downlinkHz = 436_795_000L)
+ val otherOperator = reportedRecord("SO-50", band = "2M", rxBand = "70CM").copy(theirCallsign = "BG5JVM")
+ val anotherMinute = reportedRecord("SO-50", band = "2M", rxBand = "70CM")
+ .copy(startUtcMillis = uploaded.startUtcMillis + 90_000L)
+ assertFalse(sameConfirmedContact(uploaded, otherOperator))
+ assertFalse(sameConfirmedContact(uploaded, anotherMinute))
+ }
+
+ @Test
+ fun splitConfirmationPairs_foldsRowsSplitByTheOldMismatch() {
+ // Both pre-fix symptoms in one pair: the imported row keeps the ARRL name and the
+ // mirrored band direction the old parser produced (band=downlink).
+ val uploaded = loggedRecord("SAUDISAT 1C", 145_850_000L, 436_795_000L)
+ .copy(id = 1L, lotwUploaded = true)
+ val imported = reportedRecord("SO-50", band = "70CM", rxBand = "2M").copy(id = 2L)
+ val pairs = splitConfirmationPairs(listOf(uploaded, imported))
+ assertEquals(1, pairs.size)
+ assertEquals(0, pairs.first().localIndex)
+ assertEquals(1, pairs.first().confirmationIndex)
+ }
+
+ @Test
+ fun splitConfirmationPairs_keepsUnrelatedRowsApart() {
+ val uploaded = loggedRecord("SAUDISAT 1C", 145_850_000L, 436_795_000L)
+ .copy(id = 1L, lotwUploaded = true)
+ // Another operator on another satellite in the same minute must not be folded in.
+ val otherOperator = reportedRecord("SO-50", band = "70CM", rxBand = "2M")
+ .copy(id = 2L, theirCallsign = "BG5JVM")
+ // A contact on a satellite the local record does not name at all stays separate.
+ val otherSatellite = reportedRecord("AO-91", band = "70CM", rxBand = "2M").copy(id = 3L)
+ assertTrue(splitConfirmationPairs(listOf(uploaded, otherOperator, otherSatellite)).isEmpty())
+ }
+
+ /** A record as the log page creates it: tracker name, uplink/downlink bands, no flags. */
+ @Test
+ fun officialSatelliteName_storesTheArlName() {
+ assertEquals("SO-50", officialSatelliteName("SAUDISAT 1C"))
+ assertEquals("ARISS", officialSatelliteName("ISS (ZARYA)"))
+ assertEquals("PO-101", officialSatelliteName("DIWATA 2B"))
+ assertEquals("FO-29", officialSatelliteName("JAS 2"))
+ // Already-ARRL names are left alone (the rewrite is idempotent).
+ assertEquals("SO-50", officialSatelliteName("SO-50"))
+ assertEquals("AO-123", officialSatelliteName("AO-123"))
+ // Nothing to map onto: a satellite ARRL does not list, a recycled placeholder, a blank.
+ assertEquals("FOO-1", officialSatelliteName("FOO-1"))
+ assertEquals("OBJECT AY", officialSatelliteName("OBJECT AY"))
+ assertEquals("", officialSatelliteName(" "))
+ }
+
+ @Test
+ fun officialSatelliteName_usesTheCatalogueWhenItKnowsTheNameTheTrackerCarries() {
+ val catalogue = listOf("BO-102", "SO-50")
+ // "CAS-7B" resolves through the alias table, the description-only name through the catalogue.
+ assertEquals("BO-102", officialSatelliteName("CAS-7B", catalogue))
+ assertEquals("BO-102", officialSatelliteName("CAS-7B (BO-102)", catalogue))
+ assertEquals("SO-50", officialSatelliteName("SAUDISAT 1C", catalogue))
+ }
+
+ private fun loggedRecord(satelliteName: String, uplinkHz: Long, downlinkHz: Long) = QsoRecord(
+ startUtcMillis = REPORTED_START,
+ theirCallsign = "BG5JSB",
+ myCallsign = "BA7OPF",
+ txFrequencyHz = uplinkHz,
+ rxFrequencyHz = downlinkHz,
+ band = frequencyBand(uplinkHz),
+ rxBand = frequencyBand(downlinkHz),
+ mode = "FM",
+ satelliteName = satelliteName,
+ propagationMode = "SAT",
+ status = QsoStatus.COMPLETE,
+ lotwUploaded = true
+ )
+
+ /** A record as a LoTW confirmation carries it: ARRL name, report bands, confirmed. */
+ private fun reportedRecord(satelliteName: String, band: String, rxBand: String) = QsoRecord(
+ startUtcMillis = REPORTED_START,
+ theirCallsign = "BG5JSB",
+ myCallsign = "BA7OPF",
+ band = band,
+ rxBand = rxBand,
+ mode = "FM",
+ satelliteName = satelliteName,
+ propagationMode = "SAT",
+ status = QsoStatus.COMPLETE,
+ lotwConfirmed = true
+ )
+
+ private companion object {
+ /** 2026-09-16 07:45Z — an SO-50 pass of the user's real report. */
+ const val REPORTED_START = 1_789_544_700_000L
+ }
+
+ // endregion
}
diff --git a/core/presentation/src/main/java/com/rtbishop/look4sat/core/presentation/QsoEditDialog.kt b/core/presentation/src/main/java/com/rtbishop/look4sat/core/presentation/QsoEditDialog.kt
new file mode 100644
index 00000000..94daa9a4
--- /dev/null
+++ b/core/presentation/src/main/java/com/rtbishop/look4sat/core/presentation/QsoEditDialog.kt
@@ -0,0 +1,386 @@
+/*
+ * 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.presentation
+
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.heightIn
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.FilterChip
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.input.KeyboardCapitalization
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import com.rtbishop.look4sat.core.domain.logbook.LoTWSatelliteAliases
+import com.rtbishop.look4sat.core.domain.logbook.QsoRecord
+import com.rtbishop.look4sat.core.domain.logbook.displayMode
+import com.rtbishop.look4sat.core.domain.logbook.frequencyBand
+import java.math.BigDecimal
+import java.text.SimpleDateFormat
+import java.util.Date
+import java.util.Locale
+import java.util.TimeZone
+import kotlin.math.roundToLong
+
+private const val DATE_PATTERN = "yyyy-MM-dd"
+private const val TIME_PATTERN = "HH:mm:ss"
+
+/**
+ * Edit every field of a recorded QSO (the record list rows in the radar Log tab and
+ * the logbook dialog both open this). Frequencies are entered in MHz and the band is
+ * re-derived from them, because that derivation is what LoTW validates: a record with
+ * no uplink frequency has no BAND and cannot be signed.
+ *
+ * The satellite name is restricted to ARRL's catalogue ([satelliteCandidates], from
+ * config.tq6): typing shows fuzzy suggestions, the picker lists the whole catalogue, and
+ * a name that resolves to no ARRL satellite cannot be saved. A tracker name such as
+ * "SAUDISAT 1C" is accepted and stored as the ARRL name it resolves to ("SO-50").
+ */
+@Composable
+fun QsoEditDialog(
+ record: QsoRecord,
+ satelliteCandidates: List = emptyList(),
+ onDismiss: () -> Unit,
+ onSave: (QsoRecord) -> Unit
+) {
+ val utc = remember { TimeZone.getTimeZone("UTC") }
+ fun formatDate(millis: Long) = SimpleDateFormat(DATE_PATTERN, Locale.US).apply { timeZone = utc }.format(Date(millis))
+ fun formatTime(millis: Long) = SimpleDateFormat(TIME_PATTERN, Locale.US).apply { timeZone = utc }.format(Date(millis))
+
+ var callsign by remember(record.id) { mutableStateOf(record.theirCallsign.trim()) }
+ var dateText by remember(record.id) { mutableStateOf(formatDate(record.startUtcMillis)) }
+ var timeText by remember(record.id) { mutableStateOf(formatTime(record.startUtcMillis)) }
+ var txText by remember(record.id) { mutableStateOf(formatMhz(record.txFrequencyHz)) }
+ var rxText by remember(record.id) { mutableStateOf(formatMhz(record.rxFrequencyHz)) }
+ var modeText by remember(record.id) { mutableStateOf(record.displayMode) }
+ var satelliteText by remember(record.id) { mutableStateOf(record.satelliteName) }
+ var pickerOpen by remember(record.id) { mutableStateOf(false) }
+ var sentText by remember(record.id) { mutableStateOf(record.sentReport) }
+ var receivedText by remember(record.id) { mutableStateOf(record.receivedReport) }
+ var gridText by remember(record.id) { mutableStateOf(record.theirGrid) }
+ var commentText by remember(record.id) { mutableStateOf(record.comment) }
+ var error by remember(record.id) { mutableStateOf("") }
+
+ // ARRL name the typed tracker name resolves to ("SAUDISAT 1C" -> "SO-50"), if any.
+ val officialSatellite = remember(satelliteText, satelliteCandidates) {
+ LoTWSatelliteAliases.resolve(satelliteText, satelliteCandidates)
+ }
+ val satelliteSuggestions = remember(satelliteText, satelliteCandidates) {
+ LoTWSatelliteAliases.suggestions(satelliteText, satelliteCandidates)
+ }
+
+ val txHz = parseMhz(txText)
+ val rxHz = parseMhz(rxText)
+ val bandText = listOf(frequencyBand(txHz), frequencyBand(rxHz)).filter { it.isNotBlank() }.distinct()
+ .joinToString(" / ")
+
+ val errCallsign = stringResource(R.string.qso_edit_err_callsign)
+ val errDateTime = stringResource(R.string.qso_edit_err_datetime)
+ val errFrequency = stringResource(R.string.qso_edit_err_frequency)
+ val errSatellite = stringResource(R.string.qso_edit_err_satellite)
+
+ AlertDialog(
+ onDismissRequest = onDismiss,
+ shape = sheetDialogShape(),
+ containerColor = sheetDialogContainerColor(),
+ title = { SheetDialogTitle(stringResource(R.string.qso_edit_title)) },
+ text = {
+ Column(
+ modifier = Modifier.fillMaxWidth().heightIn(max = 460.dp).verticalScroll(rememberScrollState()),
+ verticalArrangement = Arrangement.spacedBy(6.dp)
+ ) {
+ if (record.lotwUploaded || record.lotwConfirmed) {
+ Text(
+ text = stringResource(R.string.qso_edit_uploaded_notice),
+ fontSize = 12.sp,
+ color = MaterialTheme.colorScheme.primary,
+ style = MaterialTheme.typography.bodySmall
+ )
+ }
+ EditField(stringResource(R.string.qso_edit_callsign), callsign, KeyboardCapitalization.Characters) {
+ callsign = it.uppercase(Locale.US)
+ }
+ Row(horizontalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.fillMaxWidth()) {
+ EditField(stringResource(R.string.qso_edit_date), dateText, KeyboardCapitalization.None, Modifier.weight(1f)) {
+ dateText = it
+ }
+ EditField(stringResource(R.string.qso_edit_time), timeText, KeyboardCapitalization.None, Modifier.weight(1f)) {
+ timeText = it
+ }
+ }
+ Row(horizontalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.fillMaxWidth()) {
+ EditField(stringResource(R.string.qso_edit_tx), txText, KeyboardCapitalization.None, Modifier.weight(1f)) {
+ txText = it
+ }
+ EditField(stringResource(R.string.qso_edit_rx), rxText, KeyboardCapitalization.None, Modifier.weight(1f)) {
+ rxText = it
+ }
+ }
+ Text(
+ text = stringResource(R.string.qso_edit_band, bandText.ifBlank { "—" }),
+ fontSize = 12.sp,
+ color = if (txHz == null) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ EditField(stringResource(R.string.qso_edit_mode), modeText, KeyboardCapitalization.Characters) {
+ modeText = it.uppercase(Locale.US)
+ }
+ Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
+ EditField(
+ label = stringResource(R.string.qso_edit_satellite),
+ value = satelliteText,
+ capitalization = KeyboardCapitalization.Characters,
+ modifier = Modifier.weight(1f)
+ ) { satelliteText = it.uppercase(Locale.US) }
+ TextButton(
+ onClick = { pickerOpen = true },
+ enabled = satelliteCandidates.isNotEmpty()
+ ) { Text(stringResource(R.string.qso_edit_satellite_pick), fontSize = 12.sp) }
+ }
+ when {
+ // Resolves to an ARRL satellite under a different name: say so, the
+ // ARRL name is what gets stored and signed.
+ officialSatellite != null && !officialSatellite.equals(satelliteText.trim(), true) ->
+ Text(
+ text = stringResource(R.string.qso_edit_satellite_official, officialSatellite),
+ fontSize = 11.sp,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+
+ // Not an ARRL satellite at all: offer the closest catalogue names.
+ satelliteText.isNotBlank() && officialSatellite == null -> {
+ Text(
+ text = stringResource(R.string.qso_edit_err_satellite),
+ fontSize = 11.sp,
+ color = MaterialTheme.colorScheme.error
+ )
+ if (satelliteSuggestions.isNotEmpty()) {
+ Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
+ satelliteSuggestions.take(4).forEach { candidate ->
+ FilterChip(
+ selected = false,
+ onClick = { satelliteText = candidate },
+ label = { Text(candidate, fontSize = 11.sp) }
+ )
+ }
+ }
+ }
+ }
+
+ else -> Text(
+ text = stringResource(R.string.qso_edit_hint_satellite),
+ fontSize = 11.sp,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+ Row(horizontalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.fillMaxWidth()) {
+ EditField(stringResource(R.string.qso_edit_sent), sentText, KeyboardCapitalization.Characters, Modifier.weight(1f)) {
+ sentText = it
+ }
+ EditField(stringResource(R.string.qso_edit_received), receivedText, KeyboardCapitalization.Characters, Modifier.weight(1f)) {
+ receivedText = it
+ }
+ }
+ Row(horizontalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.fillMaxWidth()) {
+ EditField(stringResource(R.string.qso_edit_grid), gridText, KeyboardCapitalization.Characters, Modifier.weight(1f)) {
+ gridText = it.uppercase(Locale.US)
+ }
+ EditField(stringResource(R.string.qso_edit_comment), commentText, KeyboardCapitalization.None, Modifier.weight(1f)) {
+ commentText = it
+ }
+ }
+ if (txHz == null) {
+ Text(
+ text = stringResource(R.string.qso_edit_warn_no_freq),
+ fontSize = 12.sp,
+ color = MaterialTheme.colorScheme.error
+ )
+ }
+ if (error.isNotBlank()) {
+ Text(text = error, fontSize = 12.sp, color = MaterialTheme.colorScheme.error)
+ }
+ }
+ },
+ confirmButton = {
+ TextButton(onClick = {
+ val millis = parseUtc(dateText, timeText, utc)
+ val badFrequency = (txText.isNotBlank() && txHz == null) || (rxText.isNotBlank() && rxHz == null)
+ when {
+ callsign.isBlank() -> error = errCallsign
+ millis == null -> error = errDateTime
+ badFrequency -> error = errFrequency
+ // Only ARRL satellite names may be stored: a record that cannot be
+ // resolved here would be rejected at upload time anyway.
+ satelliteCandidates.isNotEmpty() && officialSatellite == null ->
+ error = errSatellite
+
+ else -> {
+ val mode = modeText.trim().uppercase(Locale.US).ifBlank { "FM" }
+ val satellite = officialSatellite ?: satelliteText.trim()
+ onSave(
+ record.copy(
+ theirCallsign = callsign.trim().uppercase(Locale.US),
+ startUtcMillis = millis,
+ endUtcMillis = record.endUtcMillis?.let { millis },
+ theirGrid = gridText.trim().uppercase(Locale.US),
+ sentReport = sentText.trim(),
+ receivedReport = receivedText.trim(),
+ txFrequencyHz = txHz,
+ rxFrequencyHz = rxHz,
+ band = frequencyBand(txHz),
+ rxBand = frequencyBand(rxHz),
+ mode = if (mode == "FT4") "MFSK" else mode,
+ submode = if (mode == "FT4") "FT4" else "",
+ satelliteName = satellite,
+ propagationMode = if (satellite.isBlank()) record.propagationMode else "SAT",
+ comment = commentText.trim()
+ )
+ )
+ }
+ }
+ }) { Text(stringResource(R.string.qso_edit_save)) }
+ },
+ dismissButton = {
+ TextButton(onClick = onDismiss) { Text(stringResource(R.string.qso_edit_cancel)) }
+ }
+ )
+
+ if (pickerOpen) {
+ SatellitePickerDialog(
+ candidates = satelliteCandidates,
+ onDismiss = { pickerOpen = false },
+ onPick = {
+ satelliteText = it
+ pickerOpen = false
+ }
+ )
+ }
+}
+
+/**
+ * Full ARRL satellite catalogue with a fuzzy filter: typing "saud" or "saudisat 1c" narrows the
+ * list down to "SO-50", so a tracker name can always be turned into the name LoTW accepts.
+ */
+@Composable
+private fun SatellitePickerDialog(
+ candidates: List,
+ onDismiss: () -> Unit,
+ onPick: (String) -> Unit
+) {
+ var query by remember { mutableStateOf("") }
+ val matches = remember(query, candidates) { filteredCandidates(query, candidates) }
+ AlertDialog(
+ onDismissRequest = onDismiss,
+ shape = sheetDialogShape(),
+ containerColor = sheetDialogContainerColor(),
+ title = { SheetDialogTitle(stringResource(R.string.qso_edit_satellite_pick_title)) },
+ text = {
+ Column(modifier = Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(6.dp)) {
+ OutlinedTextField(
+ value = query,
+ onValueChange = { query = it.uppercase(Locale.US) },
+ label = { Text(stringResource(R.string.qso_edit_satellite_search), fontSize = 12.sp) },
+ singleLine = true,
+ textStyle = MaterialTheme.typography.bodyMedium,
+ modifier = Modifier.fillMaxWidth()
+ )
+ LazyColumn(
+ modifier = Modifier.fillMaxWidth().heightIn(max = 320.dp),
+ verticalArrangement = Arrangement.spacedBy(2.dp)
+ ) {
+ items(matches, key = { it }) { name ->
+ Text(
+ text = name,
+ fontSize = 14.sp,
+ modifier = Modifier
+ .fillMaxWidth()
+ .clickable { onPick(name) }
+ .padding(horizontal = 4.dp, vertical = 8.dp)
+ )
+ }
+ }
+ }
+ },
+ confirmButton = {
+ TextButton(onClick = onDismiss) { Text(stringResource(R.string.qso_edit_cancel)) }
+ }
+ )
+}
+
+/** Catalogue filtered by the fuzzy query; the whole list when nothing matches or the query is blank. */
+internal fun filteredCandidates(query: String, candidates: List): List {
+ if (query.isBlank()) return candidates
+ val matches = LoTWSatelliteAliases.suggestions(query, candidates, limit = 500)
+ return matches.ifEmpty { candidates }
+}
+
+@Composable
+private fun EditField(
+ label: String,
+ value: String,
+ capitalization: KeyboardCapitalization,
+ modifier: Modifier = Modifier.fillMaxWidth(),
+ onValueChange: (String) -> Unit
+) {
+ OutlinedTextField(
+ value = value,
+ onValueChange = onValueChange,
+ label = { Text(label, fontSize = 12.sp) },
+ singleLine = true,
+ textStyle = MaterialTheme.typography.bodyMedium,
+ keyboardOptions = KeyboardOptions(capitalization = capitalization),
+ modifier = modifier.padding(vertical = 1.dp)
+ )
+}
+
+/** "436.795" (MHz) -> 436_795_000 Hz. Null when blank or unparsable. */
+internal fun parseMhz(text: String): Long? = text.trim()
+ .takeIf { it.isNotEmpty() }
+ ?.toDoubleOrNull()
+ ?.let { (it * 1_000_000.0).roundToLong() }
+
+/** Hz -> "436.795" (MHz), trailing zeros trimmed. */
+internal fun formatMhz(hz: Long?): String = hz
+ ?.let { BigDecimal.valueOf(it, 6).stripTrailingZeros().toPlainString() }
+ .orEmpty()
+
+internal fun parseUtc(date: String, time: String, zone: TimeZone): Long? = runCatching {
+ SimpleDateFormat("$DATE_PATTERN $TIME_PATTERN", Locale.US).apply {
+ timeZone = zone
+ isLenient = false
+ }.parse("${date.trim()} ${time.trim()}")?.time
+}.getOrNull()
diff --git a/core/presentation/src/main/java/com/rtbishop/look4sat/core/presentation/SheetDialogStyle.kt b/core/presentation/src/main/java/com/rtbishop/look4sat/core/presentation/SheetDialogStyle.kt
new file mode 100644
index 00000000..0670b8b6
--- /dev/null
+++ b/core/presentation/src/main/java/com/rtbishop/look4sat/core/presentation/SheetDialogStyle.kt
@@ -0,0 +1,58 @@
+/*
+ * 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.presentation
+
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.Shape
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.sp
+
+/**
+ * Styling of the app's sheet dialogs ([DialogShell] / [SharedDialog] / [InfoDialog], all built on
+ * ModalBottomSheet). AlertDialog-based dialogs (logbook, QSO editor, upload preview) use these so
+ * their container colour, corner radius and title look identical to the bottom sheets.
+ */
+
+/**
+ * Container colour of the app's bottom sheets. Equal to `BottomSheetDefaults.ContainerColor`
+ * (Material3 resolves that token to `ColorScheme.surfaceContainerLow`) — spelled out here so the
+ * dialogs don't have to opt into the experimental bottom-sheet API.
+ */
+@Composable
+fun sheetDialogContainerColor(): Color = MaterialTheme.colorScheme.surfaceContainerLow
+
+/** Corner radius of the app's bottom sheets (`MaterialTheme.shapes.medium`, 12 dp). */
+@Composable
+fun sheetDialogShape(): Shape = MaterialTheme.shapes.medium
+
+/** Title row of the app's sheet dialogs: 16 sp medium, primary colour, single line. */
+@Composable
+fun SheetDialogTitle(text: String) {
+ Text(
+ text = text,
+ fontSize = 16.sp,
+ fontWeight = FontWeight.Medium,
+ color = MaterialTheme.colorScheme.primary,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis
+ )
+}
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 bb9b16bf..d226024f 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
@@ -31,7 +31,9 @@ import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
@@ -46,13 +48,21 @@ import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.rtbishop.look4sat.core.domain.logbook.QsoRecord
import com.rtbishop.look4sat.core.domain.logbook.displayMode
+import com.rtbishop.look4sat.core.domain.logbook.satelliteIdentity
+import com.rtbishop.look4sat.core.domain.logbook.unavailableUploadSummary
import com.rtbishop.look4sat.core.presentation.SwipeController
import com.rtbishop.look4sat.core.presentation.SwipeRevealRow
import com.rtbishop.look4sat.core.presentation.rememberSwipeController
import com.rtbishop.look4sat.core.domain.utility.DopplerFrequencyCalculator
+import com.rtbishop.look4sat.core.domain.source.Sources
+import com.rtbishop.look4sat.core.domain.utility.downlinkHz
+import com.rtbishop.look4sat.core.domain.utility.uplinkHz
+import com.rtbishop.look4sat.core.domain.utility.voiceRepeater
import com.rtbishop.look4sat.core.presentation.EmptyListCard
-
-private const val LOG_WINDOW_MS = 24 * 3_600_000L
+import com.rtbishop.look4sat.core.presentation.QsoEditDialog
+import com.rtbishop.look4sat.core.presentation.SheetDialogTitle
+import com.rtbishop.look4sat.core.presentation.sheetDialogContainerColor
+import com.rtbishop.look4sat.core.presentation.sheetDialogShape
@Composable
fun LogPage(
@@ -63,6 +73,8 @@ fun LogPage(
val logUiState by logViewModel.uiState.collectAsStateWithLifecycle()
val records by logViewModel.records.collectAsStateWithLifecycle(initialValue = emptyList())
val swipeController = rememberSwipeController()
+ // Record currently open in the edit dialog (null when closed).
+ var editTarget by remember { mutableStateOf(null) }
val selectedRadio = remember(uiState.transceivers.transmitters, uiState.transceivers.selectedUuid) {
uiState.transceivers.transmitters.firstOrNull { it.uuid == uiState.transceivers.selectedUuid }
@@ -73,25 +85,44 @@ fun LogPage(
val isLinear = uiState.transceivers.transmitters.any(DopplerFrequencyCalculator::isNamedLinearTransponder)
val catnum = uiState.currentPass?.catNum ?: selectedRadio?.catnum ?: 0
val satName = uiState.currentPass?.name?.trim().orEmpty()
+ // The satellite's FM voice repeater — the single-frequency transceiver the
+ // contact is actually made through. SatNOGS publishes its nominal pair, so a
+ // contact can never be logged without a frequency (which LoTW rejects as a
+ // missing BAND).
+ val voiceRepeater = remember(uiState.transceivers.transmitters) {
+ uiState.transceivers.transmitters.voiceRepeater()
+ }
+ // AMSAT Live FM satellites are always logged on that fixed nominal pair,
+ // even when the calculator happens to be tuned to something else.
+ val useRepeater = catnum in Sources.amSatFmCatnums
LaunchedEffect(catnum) {
if (catnum != 0) logViewModel.selectSatellite(catnum)
}
- val txHz = uiState.calculatorTxHz ?: remember(selectedRadio) {
- selectedRadio?.uplinkLow?.let { low ->
- selectedRadio.uplinkHigh?.let { high -> (low + high) / 2 } ?: low
- }
+ val selectedTxHz = remember(selectedRadio) { selectedRadio?.uplinkHz() }
+ val selectedRxHz = remember(selectedRadio) { selectedRadio?.downlinkHz() }
+ val repeaterTxHz = voiceRepeater?.uplinkHz()
+ val repeaterRxHz = voiceRepeater?.downlinkHz()
+ val txHz = if (useRepeater) {
+ repeaterTxHz ?: uiState.calculatorTxHz ?: selectedTxHz
+ } else {
+ uiState.calculatorTxHz ?: repeaterTxHz ?: selectedTxHz
}
- val rxHz = uiState.calculatorRxHz ?: remember(selectedRadio) {
- selectedRadio?.downlinkLow?.let { low ->
- selectedRadio.downlinkHigh?.let { high -> (low + high) / 2 } ?: low
- }
+ val rxHz = if (useRepeater) {
+ repeaterRxHz ?: uiState.calculatorRxHz ?: selectedRxHz
+ } else {
+ uiState.calculatorRxHz ?: repeaterRxHz ?: selectedRxHz
}
val mode = if (isLinear) logUiState.selectedMode.ifBlank { "CW" } else "FM"
- val now = System.currentTimeMillis()
+ val satIdentity = remember(satName) { satelliteIdentity(satName) }
+ val passWindow = remember(uiState.currentPass) {
+ uiState.currentPass?.let { it.aosTime..it.losTime }
+ }
val recent = records
- .filter { it.satelliteName.trim().equals(satName, true) && it.startUtcMillis > now - LOG_WINDOW_MS }
+ .filter { satelliteIdentity(it.satelliteName) == satIdentity }
+ // Only contacts inside the current pass window: the log page is the "this pass" sheet.
+ .filter { passWindow?.contains(it.startUtcMillis) == true }
.sortedByDescending { it.startUtcMillis }
val maxElev = uiState.currentPass?.maxElevation ?: 0.0
@@ -135,7 +166,12 @@ fun LogPage(
} else {
LazyColumn(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
items(recent, key = { it.id }) { record ->
- LogRecordRow(record, swipeController, onDelete = { logViewModel.delete(record.id) })
+ LogRecordRow(
+ record = record,
+ swipeController = swipeController,
+ onDelete = { logViewModel.delete(record.id) },
+ onClick = { editTarget = record }
+ )
}
}
}
@@ -193,17 +229,36 @@ fun LogPage(
if (logUiState.message.isNotBlank()) {
AlertDialog(
onDismissRequest = logViewModel::clearMessage,
- title = { Text("LoTW Upload") },
+ shape = sheetDialogShape(),
+ containerColor = sheetDialogContainerColor(),
+ title = { SheetDialogTitle("LoTW Upload") },
text = { Text(logUiState.message) },
confirmButton = {
TextButton(onClick = logViewModel::clearMessage) { Text("OK") }
}
)
}
+
+ editTarget?.let { target ->
+ QsoEditDialog(
+ record = target,
+ satelliteCandidates = logUiState.satelliteCatalog,
+ onDismiss = { editTarget = null },
+ onSave = { updated ->
+ logViewModel.updateRecord(updated)
+ editTarget = null
+ }
+ )
+ }
}
@Composable
-private fun LogRecordRow(record: QsoRecord, swipeController: SwipeController, onDelete: () -> Unit) {
+private fun LogRecordRow(
+ record: QsoRecord,
+ swipeController: SwipeController,
+ onDelete: () -> Unit,
+ onClick: () -> Unit
+) {
val time = remember(record.startUtcMillis) {
java.text.SimpleDateFormat("HH:mm'Z'", java.util.Locale.US).apply {
timeZone = java.util.TimeZone.getTimeZone("UTC")
@@ -217,7 +272,10 @@ private fun LogRecordRow(record: QsoRecord, swipeController: SwipeController, on
modifier = Modifier.fillMaxWidth()
) {
Row(
- modifier = Modifier.fillMaxWidth().padding(horizontal = 4.dp, vertical = 2.dp),
+ modifier = Modifier
+ .fillMaxWidth()
+ .clickable { onClick() }
+ .padding(horizontal = 4.dp, vertical = 2.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
@@ -227,14 +285,14 @@ private fun LogRecordRow(record: QsoRecord, swipeController: SwipeController, on
text = record.theirCallsign,
fontSize = 14.sp,
fontFamily = FontFamily.Monospace,
- color = Color(0xFFFFE082),
+ color = MaterialTheme.colorScheme.primary,
maxLines = 1
)
Text(text = " ${record.displayMode}", fontSize = 14.sp, maxLines = 1)
}
when {
- record.lotwConfirmed -> Text("QSL", fontSize = 12.sp, color = Color(0xFFFFE082), fontFamily = FontFamily.Monospace)
- record.lotwUploaded -> Text("UP", fontSize = 12.sp, color = Color(0xFFFFE082), fontFamily = FontFamily.Monospace)
+ record.lotwConfirmed -> Text("QSL", fontSize = 12.sp, color = MaterialTheme.colorScheme.primary, fontFamily = FontFamily.Monospace)
+ record.lotwUploaded -> Text("UP", fontSize = 12.sp, color = MaterialTheme.colorScheme.primary, fontFamily = FontFamily.Monospace)
else -> Text("", fontSize = 12.sp)
}
}
@@ -250,7 +308,9 @@ private fun PostDialog(
) {
AlertDialog(
onDismissRequest = onDismiss,
- title = { Text("通联记录") },
+ shape = sheetDialogShape(),
+ containerColor = sheetDialogContainerColor(),
+ title = { SheetDialogTitle("通联记录") },
text = {
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
Text(text, fontSize = 13.sp)
@@ -273,7 +333,9 @@ private fun UploadPreviewDialog(
) {
AlertDialog(
onDismissRequest = onDismiss,
- title = { Text("上传到 LoTW") },
+ shape = sheetDialogShape(),
+ containerColor = sheetDialogContainerColor(),
+ title = { SheetDialogTitle("上传到 LoTW") },
text = {
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text("${preview.callsign} DXCC ${preview.dxcc} Grid ${preview.grid}", fontSize = 13.sp)
@@ -296,9 +358,21 @@ private fun UploadPreviewDialog(
}Z", fontSize = 13.sp)
if (preview.skipped > 0 || preview.unknownSkipped > 0 || preview.unavailableSkipped > 0) {
val parts = buildList {
- if (preview.skipped > 0) add("${preview.skipped} already uploaded/duplicate")
+ // skipped counts every record left out of this batch: previously-uploaded
+ // ones, batch duplicates and unknown-outcome ones. Show them separately so
+ // "10 already uploaded" (historical records) is not read as this upload
+ // being rejected as a duplicate.
+ val alreadyUploaded = preview.skipped - preview.duplicateSkipped - preview.unknownSkipped
+ if (alreadyUploaded > 0) add("$alreadyUploaded already uploaded")
+ if (preview.duplicateSkipped > 0) add("${preview.duplicateSkipped} duplicate")
if (preview.unknownSkipped > 0) add("${preview.unknownSkipped} unknown result")
- if (preview.unavailableSkipped > 0) add("${preview.unavailableSkipped} un-uploadable")
+ if (preview.unavailableSkipped > 0) add(
+ unavailableUploadSummary(
+ preview.unavailableSkipped,
+ preview.unavailableReasons,
+ duplicates = preview.duplicateSkipped
+ )
+ )
}
Text(parts.joinToString(" · "), fontSize = 12.sp, color = MaterialTheme.colorScheme.error)
}
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 1c5fbb85..e318d0fc 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,9 @@ 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.officialSatelliteName
+import com.rtbishop.look4sat.core.domain.logbook.satelliteIdentity
+import com.rtbishop.look4sat.core.domain.logbook.unavailableUploadSummary
import com.rtbishop.look4sat.core.domain.repository.ILoTWUploadRepository
import com.rtbishop.look4sat.core.domain.repository.IMainContainer
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
@@ -43,6 +46,8 @@ data class LogUiState(
val certificateCallsign: String = "",
/** Primary grid of the LoTW upload station location ("" when none). */
val stationGrid: String = "",
+ /** ARRL satellite names (config.tq6) — the only names a record may be signed with. */
+ val satelliteCatalog: List = emptyList(),
val preview: LoTWUploadPreview? = null,
/** User-facing upload / record message ("" when none). */
val message: String = "",
@@ -56,6 +61,8 @@ class LogViewModel(
private val settingsRepo: ISettingsRepo
) : ViewModel() {
+ /** Records shown on the radar log page: all records of the current pass window, including
+ * already-uploaded (UP) and confirmed (QSL) ones. Window filtering happens in LogPage. */
val records: Flow> = qsoRepository.records
private val _uiState = MutableStateFlow(LogUiState())
@@ -69,10 +76,12 @@ class LogViewModel(
viewModelScope.launch {
val cert = lotwUploadRepository.certificate()
val station = lotwUploadRepository.station()
+ val catalog = runCatching { lotwUploadRepository.satelliteCatalog() }.getOrDefault(emptyList())
_uiState.update {
it.copy(
certificateCallsign = cert?.callsign.orEmpty(),
- stationGrid = station?.grid.orEmpty()
+ stationGrid = station?.grid.orEmpty(),
+ satelliteCatalog = catalog
)
}
}
@@ -96,20 +105,27 @@ class LogViewModel(
val call = _uiState.value.callsignInput.trim()
if (call.isEmpty()) return
val normalizedMode = mode.ifBlank { "FM" }.uppercase(Locale.US)
- val now = System.currentTimeMillis()
+ // Whole minutes (seconds zeroed): matches the edit dialog's HH:mm granularity and the
+ // minute-resolution of LoTW reports, so re-saving an unchanged record never looks edited.
+ val now = System.currentTimeMillis() / 60_000L * 60_000L
+ // kHz granularity (3 decimals in MHz) — enough for operating and for the ADIF export.
+ val tx = txHz?.let { (it + 500L) / 1000L * 1000L }
+ val rx = rxHz?.let { (it + 500L) / 1000L * 1000L }
val record = QsoRecord(
startUtcMillis = now,
endUtcMillis = now,
theirCallsign = call,
myCallsign = _uiState.value.certificateCallsign,
myGrid = myGrid.take(6).uppercase(Locale.US),
- txFrequencyHz = txHz,
- rxFrequencyHz = rxHz,
- band = frequencyBand(txHz),
- rxBand = frequencyBand(rxHz),
+ txFrequencyHz = tx,
+ rxFrequencyHz = rx,
+ band = frequencyBand(tx),
+ rxBand = frequencyBand(rx),
mode = if (normalizedMode == "FT4") "MFSK" else normalizedMode,
submode = normalizedMode.takeIf { it == "FT4" }.orEmpty(),
- satelliteName = satName,
+ // Stored under the ARRL name (the tracker's "SAUDISAT 1C" is logged as "SO-50"):
+ // one name for the logbook, the ADIF export and the signed record.
+ satelliteName = officialSatelliteName(satName, _uiState.value.satelliteCatalog),
satelliteMode = normalizedMode,
status = QsoStatus.COMPLETE,
propagationMode = "SAT"
@@ -120,16 +136,25 @@ class LogViewModel(
fun delete(id: Long) = viewModelScope.launch { qsoRepository.delete(id) }
+ /**
+ * Persist an edited record (frequency/callsign/time/…). The repository keeps
+ * the LoTW confirmation state consistent when the contact identity changes.
+ */
+ fun updateRecord(record: QsoRecord) = viewModelScope.launch { qsoRepository.save(record) }
+
/** English social post from the recorded QSOs of the current satellite (last 24h). */
fun generatePost(satName: String, maxElev: Double) {
viewModelScope.launch {
val now = System.currentTimeMillis()
+ // Records carry the ARRL name while the pass carries the tracker's name, so the
+ // operator's own contacts are collected by identity, not by the raw name.
+ val identity = satelliteIdentity(satName)
val list = qsoRepository.records.first()
- .filter { it.satelliteName.trim().equals(satName.trim(), true) }
+ .filter { satelliteIdentity(it.satelliteName) == identity }
.filter { it.startUtcMillis > now - 24 * 3_600_000L }
.sortedBy { it.startUtcMillis }
if (list.isEmpty()) return@launch
- val shortName = satName.substringBefore('(').trim().uppercase(Locale.US)
+ val shortName = officialSatelliteName(satName).substringBefore('(').trim().uppercase(Locale.US)
val utc = SimpleDateFormat("yyyyMMdd|HH:mm'Z'", Locale.US).apply {
timeZone = TimeZone.getTimeZone("UTC")
}
@@ -140,7 +165,7 @@ class LogViewModel(
.joinToString("\n")
val currentGrid = settingsRepo.stationPosition.value.qthLocator.take(6)
val logLine = "📍 Log: ${_uiState.value.stationGrid.take(4)} | Current: $currentGrid"
- val viaLine = "via Look4Sat — TNX de ${_uiState.value.certificateCallsign}"
+ val viaLine = "via Look4Sat-BA7OPF — TNX de ${_uiState.value.certificateCallsign}"
val tag = "#HamRadio #SatelliteQSO #" + shortName.filter { it.isLetterOrDigit() }
val text = listOf(firstLine, elLine, modeLines, "", logLine, viaLine, tag).joinToString("\n")
_uiState.update { it.copy(postText = text) }
@@ -157,11 +182,14 @@ class LogViewModel(
val all = qsoRepository.records.first()
// Only local (non-confirmed) records are candidates for upload;
// LoTW-imported confirmations are the feedback side.
- val pending = all.filter { !it.lotwConfirmed && it.status == QsoStatus.COMPLETE }
+ val pending = all.filter { !it.lotwConfirmed && !it.lotwUploaded && it.status == QsoStatus.COMPLETE }
val audit = lotwUploadRepository.audit(pending)
if (audit.pending == 0) {
val msg = when {
- audit.unavailable > 0 -> "${audit.unavailable} QSO(s) can't be uploaded (invalid call/date — check the logbook)"
+ audit.unavailable > 0 -> unavailableUploadSummary(
+ audit.unavailable, audit.reasons, audit.details, audit.duplicates, audit.incomplete
+ )
+
audit.unknown > 0 -> "${audit.unknown} QSO(s) had an unknown upload result — not retried automatically"
else -> "No pending QSOs to upload"
}
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 7af71fb7..24816127 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
@@ -26,7 +26,10 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
@@ -38,7 +41,12 @@ import androidx.compose.ui.unit.sp
import com.rtbishop.look4sat.core.domain.logbook.QsoRecord
import com.rtbishop.look4sat.core.domain.logbook.displayMode
import com.rtbishop.look4sat.core.domain.logbook.frequencyBand
+import com.rtbishop.look4sat.core.domain.logbook.unavailableUploadSummary
import com.rtbishop.look4sat.core.presentation.LocalSpacing
+import com.rtbishop.look4sat.core.presentation.QsoEditDialog
+import com.rtbishop.look4sat.core.presentation.SheetDialogTitle
+import com.rtbishop.look4sat.core.presentation.sheetDialogContainerColor
+import com.rtbishop.look4sat.core.presentation.sheetDialogShape
import com.rtbishop.look4sat.core.presentation.R
import com.rtbishop.look4sat.core.presentation.SharedDialog
import com.rtbishop.look4sat.core.presentation.SwipeController
@@ -70,17 +78,21 @@ fun LogbookCard(recordCount: Int, showLogbookDialog: () -> Unit) {
@Composable
fun LogbookDialog(
records: List,
+ satelliteCandidates: List,
uploadBusy: Boolean,
uploadMessage: String,
preview: com.rtbishop.look4sat.core.domain.repository.LoTWUploadPreview?,
onDismiss: () -> Unit,
onDelete: (Long) -> Unit,
+ onEdit: (QsoRecord) -> Unit,
onUpload: () -> Unit,
onConfirmUpload: () -> Unit,
onDismissPreview: () -> Unit,
onDismissMessage: () -> Unit
) {
val swipeController = rememberSwipeController()
+ // Record currently open in the edit dialog (null when closed).
+ var editTarget by remember { mutableStateOf(null) }
SharedDialog(
title = stringResource(R.string.prefs_logbook_title),
onDismissRequest = onDismiss,
@@ -101,7 +113,12 @@ fun LogbookDialog(
verticalArrangement = Arrangement.spacedBy(2.dp)
) {
items(records, key = { it.id }) { record ->
- LogbookRow(record, swipeController, onDelete = { onDelete(record.id) })
+ LogbookRow(
+ record = record,
+ swipeController = swipeController,
+ onDelete = { onDelete(record.id) },
+ onClick = { editTarget = record }
+ )
}
}
}
@@ -119,13 +136,27 @@ fun LogbookDialog(
if (uploadMessage.isNotBlank()) {
AlertDialog(
onDismissRequest = onDismissMessage,
- title = { Text("LoTW Upload") },
+ shape = sheetDialogShape(),
+ containerColor = sheetDialogContainerColor(),
+ title = { SheetDialogTitle("LoTW Upload") },
text = { Text(uploadMessage) },
confirmButton = {
TextButton(onClick = onDismissMessage) { Text("OK") }
}
)
}
+
+ editTarget?.let { target ->
+ QsoEditDialog(
+ record = target,
+ satelliteCandidates = satelliteCandidates,
+ onDismiss = { editTarget = null },
+ onSave = { updated ->
+ onEdit(updated)
+ editTarget = null
+ }
+ )
+ }
}
@Composable
@@ -137,7 +168,9 @@ private fun LogbookUploadPreviewDialog(
) {
AlertDialog(
onDismissRequest = onDismiss,
- title = { Text(stringResource(R.string.prefs_logbook_upload_title)) },
+ shape = sheetDialogShape(),
+ containerColor = sheetDialogContainerColor(),
+ title = { SheetDialogTitle(stringResource(R.string.prefs_logbook_upload_title)) },
text = {
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text("${preview.callsign} DXCC ${preview.dxcc} Grid ${preview.grid}", fontSize = 13.sp)
@@ -147,9 +180,17 @@ private fun LogbookUploadPreviewDialog(
)
if (preview.skipped > 0 || preview.unknownSkipped > 0 || preview.unavailableSkipped > 0) {
val parts = buildList {
- if (preview.skipped > 0) add("${preview.skipped} already uploaded/duplicate")
+ val alreadyUploaded = preview.skipped - preview.duplicateSkipped - preview.unknownSkipped
+ if (alreadyUploaded > 0) add("$alreadyUploaded already uploaded")
+ if (preview.duplicateSkipped > 0) add("${preview.duplicateSkipped} duplicate")
if (preview.unknownSkipped > 0) add("${preview.unknownSkipped} unknown result")
- if (preview.unavailableSkipped > 0) add("${preview.unavailableSkipped} un-uploadable")
+ if (preview.unavailableSkipped > 0) add(
+ unavailableUploadSummary(
+ preview.unavailableSkipped,
+ preview.unavailableReasons,
+ duplicates = preview.duplicateSkipped
+ )
+ )
}
Text(parts.joinToString(" · "), fontSize = 12.sp, color = MaterialTheme.colorScheme.error)
}
@@ -168,7 +209,12 @@ private fun LogbookUploadPreviewDialog(
}
@Composable
-private fun LogbookRow(record: QsoRecord, swipeController: SwipeController, onDelete: () -> Unit) {
+private fun LogbookRow(
+ record: QsoRecord,
+ swipeController: SwipeController,
+ onDelete: () -> Unit,
+ onClick: () -> Unit
+) {
val time = remember(record.startUtcMillis) {
SimpleDateFormat("MM-dd HH:mm'Z'", Locale.US).apply {
timeZone = TimeZone.getTimeZone("UTC")
@@ -188,6 +234,7 @@ private fun LogbookRow(record: QsoRecord, swipeController: SwipeController, onDe
Column(
modifier = Modifier
.fillMaxWidth()
+ .clickable { onClick() }
.padding(horizontal = 4.dp, vertical = 8.dp)
) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
@@ -195,15 +242,15 @@ private fun LogbookRow(record: QsoRecord, swipeController: SwipeController, onDe
text = record.theirCallsign,
style = MaterialTheme.typography.titleMedium,
fontFamily = FontFamily.Monospace,
- color = Color(0xFFFFE082),
+ color = MaterialTheme.colorScheme.primary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f)
)
if (record.lotwConfirmed) {
- Text(text = "QSL", fontSize = 13.sp, color = Color(0xFFFFE082), fontFamily = FontFamily.Monospace)
+ Text(text = "QSL", fontSize = 13.sp, color = MaterialTheme.colorScheme.primary, fontFamily = FontFamily.Monospace)
} else if (record.lotwUploaded) {
- Text(text = "UP", fontSize = 13.sp, color = Color(0xFFFFE082), fontFamily = FontFamily.Monospace)
+ Text(text = "UP", fontSize = 13.sp, color = MaterialTheme.colorScheme.primary, fontFamily = FontFamily.Monospace)
}
}
Text(
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 09b54652..70904fcb 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
@@ -163,6 +163,11 @@ class SettingsViewModel(
_uiState.update { it.copy(logbookRecords = records) }
}
}
+ // ARRL satellite names for the logbook edit dialog's satellite picker.
+ viewModelScope.launch {
+ val catalog = runCatching { lotwUploadRepository.satelliteCatalog() }.getOrDefault(emptyList())
+ _uiState.update { it.copy(satelliteCatalog = catalog) }
+ }
// Load the LoTW upload certificate + station once at startup so the
// settings card reflects the real state on first frame (previously it
// stayed "not imported" until the config dialog was opened).
@@ -197,7 +202,15 @@ class SettingsViewModel(
is SettingsAction.SetCompassOffset -> settingsRepo.updateOtherSettings {
it.copy(compassOffsetDegrees = action.degrees.coerceIn(-180f, 180f))
}
- is SettingsAction.ToggleLightTheme -> settingsRepo.updateOtherSettings { it.copy(stateOfLightTheme = action.value) }
+ is SettingsAction.ToggleLightTheme -> settingsRepo.updateOtherSettings {
+ // The red night filter is a dark-screen aid; turning the light theme on
+ // switches it off so the UI isn't red-on-white. Turning the light theme
+ // off leaves the filter as the user set it (off).
+ it.copy(
+ stateOfLightTheme = action.value,
+ stateOfNightMode = if (action.value) false else it.stateOfNightMode
+ )
+ }
is SettingsAction.ToggleNightMode -> settingsRepo.updateOtherSettings { it.copy(stateOfNightMode = action.value) }
is SettingsAction.UpdateMapSettings -> settingsRepo.updateOtherSettings {
it.copy(mapSource = action.mapSource, tiandituKey = action.tiandituKey)
@@ -216,6 +229,7 @@ class SettingsViewModel(
// Logbook
SettingsAction.RefreshLogbook -> refreshLogbook()
is SettingsAction.DeleteLogbookRecord -> viewModelScope.launch { qsoRepository.delete(action.id) }
+ is SettingsAction.UpdateLogbookRecord -> viewModelScope.launch { qsoRepository.save(action.record) }
SettingsAction.PrepareLogbookUpload -> prepareLogbookUpload()
SettingsAction.ConfirmLogbookUpload -> confirmLogbookUpload()
SettingsAction.DismissLogbookPreview -> dismissLogbookPreview()
@@ -364,11 +378,14 @@ class SettingsViewModel(
val all = qsoRepository.records.first()
// Only local (non-confirmed) records are candidates for upload;
// LoTW-imported confirmations are the feedback side.
- val pending = all.filter { !it.lotwConfirmed && it.status == com.rtbishop.look4sat.core.domain.logbook.QsoStatus.COMPLETE }
+ 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) {
val msg = when {
- audit.unavailable > 0 -> "${audit.unavailable} QSO(s) can't be uploaded (invalid call/date — check the logbook)"
+ audit.unavailable > 0 -> com.rtbishop.look4sat.core.domain.logbook.unavailableUploadSummary(
+ audit.unavailable, audit.reasons, audit.details, audit.duplicates, audit.incomplete
+ )
+
audit.unknown > 0 -> "${audit.unknown} QSO(s) had an unknown upload result — not retried automatically"
else -> "No pending QSOs to upload"
}