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" }