mirror of
https://github.com/atsunatsu/Look4Sat.git
synced 2026-10-02 03:15:37 +00:00
feat(logbook): LoTW 日志本官方名统一、上传候选集、编辑已传记录新建、记录分钟/kHz 规范化
This commit is contained in:
1 parent
1871c05002
commit
5acf71b2a6
26 files changed
+2145
-98
No files matched your search
@@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
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<String> = 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)
|
||||
|
||||
@@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.rtbishop.look4sat.core.data.lotw
|
||||
|
||||
import com.rtbishop.look4sat.core.domain.repository.LoTWCertificate
|
||||
|
||||
@@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.rtbishop.look4sat.core.data.lotw
|
||||
|
||||
import com.rtbishop.look4sat.core.domain.logbook.QsoRecord
|
||||
|
||||
@@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.rtbishop.look4sat.core.data.lotw
|
||||
|
||||
import android.content.Context
|
||||
|
||||
+72
-7
@@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
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<String> by lazy { config().satelliteNames() }
|
||||
|
||||
override suspend fun satelliteCatalog(): List<String> = 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<QsoRecord>): 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<LoTWProblem, Int>()
|
||||
val details = mutableMapOf<LoTWProblem, String>()
|
||||
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<String>()
|
||||
val reasons = mutableMapOf<LoTWProblem, Int>()
|
||||
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(
|
||||
|
||||
+2
-2
@@ -322,9 +322,9 @@ class LoTWRepository : ILoTWRepository {
|
||||
line.startsWith("<MODE:") ->
|
||||
mode = adifValue(line)
|
||||
line.startsWith("<BAND_RX:") ->
|
||||
bandUp = adifValue(line).uppercase()
|
||||
line.startsWith("<BAND:") && !line.startsWith("<BAND_RX:") ->
|
||||
bandDown = adifValue(line).uppercase()
|
||||
line.startsWith("<BAND:") && !line.startsWith("<BAND_RX:") ->
|
||||
bandUp = adifValue(line).uppercase()
|
||||
line.startsWith("<DXCC:") ->
|
||||
dxcc = adifValue(line).toIntOrNull()
|
||||
line.startsWith("<COUNTRY:") ->
|
||||
|
||||
+157
-17
@@ -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<QsoRecord>, 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<Int, QsoRecord>()
|
||||
val keyIndex = working.indices.groupBy { stableQsoKey(working[it]) }
|
||||
.mapValues { it.value.toMutableList() }.toMutableMap()
|
||||
val knownKeys = keyIndex.keys.toMutableSet()
|
||||
val changes = linkedMapOf<Int, QsoRecord>().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<Int, QsoRecord>,
|
||||
/** imported confirmation rows that are now part of a local record. */
|
||||
val redundant: List<QsoRecord>
|
||||
)
|
||||
|
||||
/**
|
||||
* 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<QsoRecord>): Map<Int, QsoRecord> {
|
||||
val renamed = mutableMapOf<Int, QsoRecord>()
|
||||
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<QsoRecord>): Consolidation {
|
||||
val merged = mutableMapOf<Int, QsoRecord>()
|
||||
val redundant = mutableListOf<QsoRecord>()
|
||||
// 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
|
||||
@@ -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<String, String>(), unknown)
|
||||
}
|
||||
}
|
||||
+24
-3
@@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
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)
|
||||
}
|
||||
|
||||
|
||||
+292
@@ -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<Long, QsoEntity>()
|
||||
private var nextId = 1L
|
||||
|
||||
override fun observeAll(): Flow<List<QsoEntity>> = flowOf(rows.values.toList())
|
||||
|
||||
override suspend fun find(id: Long): QsoEntity? = rows[id]
|
||||
|
||||
override suspend fun getAll(): List<QsoEntity> = rows.values.toList()
|
||||
|
||||
override suspend fun getDedupeKeys(): List<String> = 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<QsoEntity>): List<Long> = records.map { save(it) }
|
||||
|
||||
override suspend fun delete(id: Long) {
|
||||
rows.remove(id)
|
||||
}
|
||||
|
||||
override suspend fun markUploaded(ids: List<Long>) {
|
||||
ids.forEach { id -> rows[id]?.let { rows[id] = it.copy(lotwUploaded = true) } }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -22,4 +22,12 @@ interface IQsoRepository {
|
||||
suspend fun importAdi(content: String): AdifImportResult
|
||||
suspend fun mergeConfirmed(records: List<QsoRecord>): AdifImportResult
|
||||
suspend fun mergeLoTW(records: List<QsoRecord>): 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
|
||||
}
|
||||
+18
@@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.rtbishop.look4sat.core.domain.logbook
|
||||
|
||||
import com.rtbishop.look4sat.core.domain.model.GridQso
|
||||
|
||||
+148
@@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
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<String, String> = 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>): 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<String>, limit: Int = 6): List<String> {
|
||||
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)
|
||||
}
|
||||
}
|
||||
+70
@@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
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<LoTWProblem, Int> = emptyMap(),
|
||||
details: Map<LoTWProblem, String> = 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"
|
||||
}
|
||||
+118
-10
@@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
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<String> = 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<QsoRecord>): List<SplitConfirmationPair> {
|
||||
val pendingIndices = records.indices.filter { !records[it].lotwConfirmed }
|
||||
val taken = mutableSetOf<Int>()
|
||||
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 },
|
||||
|
||||
+35
-2
@@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
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<String>
|
||||
suspend fun audit(records: List<QsoRecord>): LoTWUploadAudit
|
||||
suspend fun prepare(records: List<QsoRecord>, 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<LoTWProblem, Int> = 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<Long> = 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<LoTWProblem, Int> = emptyMap(),
|
||||
/** First offending value per reason, e.g. SATELLITE -> "SAUDISAT 1C". */
|
||||
val details: Map<LoTWProblem, String> = 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 {
|
||||
|
||||
@@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
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<SatRadio>.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<SatRadio> { 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()
|
||||
+78
@@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
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())
|
||||
}
|
||||
}
|
||||
@@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
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())
|
||||
}
|
||||
}
|
||||
+137
@@ -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
|
||||
}
|
||||
+386
@@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
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<String> = 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<String>,
|
||||
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<String>): List<String> {
|
||||
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()
|
||||
+58
@@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
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
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user