feat: LoTW upload + logbook (port from Iu-yang1's fork)

- Port TQ8 signing/upload pipeline (core/data/lotw: LoTWConfig, LoTWKeyMaterial,
  LoTWSigner, LoTWStorage, LoTWUploadRepository) with bundled ARRL config.tq6
- Multi-grid station locations: primary grid in GRIDSQUARE + full set in
  MY_VUCC_GRIDS (unsigned tSTATION extra, IOTA precedent); signer matches
  myGrid against the whole grid set
- Satellite-name normalization: SatNOGS names ("SO-50 (SaudiOSCAR 50)") resolve
  to official LoTW names by exact-then-substring match
- Logbook storage: separate QsoDatabase (version 1, no migration on existing
  installs), QsoRepository with dedupe/confirmation merge; LoTW sync (manual +
  auto) feeds confirmations into the logbook and marks local uploads confirmed
- Radar Log tab: single-callsign recording, per-satellite mode preset
  (CW/SSB/FT4), English social post generation (copy/share), LoTW upload with
  one-shot preview confirmation + receipts ledger
- Settings: 日志本 section (records + confirmation state, delete on tap) above
  LoTW config; LoTW upload config (TrustedQSL .p12 import + password, station
  grid list incl. IOTA/CQZ/ITUZ)
- i18n strings (en/zh)
This commit is contained in:
atsunatsu committed 2026-09-25 22:37:20 +08:00
1 parent d33629f015
commit 8e549cd43c
36 files changed
+2696 -4

No files matched your search

@@ -0,0 +1,163 @@
/*
* 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.domain.logbook
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import java.util.TimeZone
/** Small ADI codec for the logbook's actual field set (ADIF 3.1.7). */
object AdifCodec {
private val utc = TimeZone.getTimeZone("UTC")
fun encode(records: List<QsoRecord>): String = buildString {
append(field("ADIF_VER", "3.1.7"))
append(field("PROGRAMID", "Look4Sat"))
append("<EOH>\r\n")
records.forEach { record ->
val startDate = format(record.startUtcMillis, "yyyyMMdd")
val startTime = format(record.startUtcMillis, "HHmmss")
append(field("QSO_DATE", startDate))
append(field("TIME_ON", startTime))
record.endUtcMillis?.let {
append(field("QSO_DATE_OFF", format(it, "yyyyMMdd")))
append(field("TIME_OFF", format(it, "HHmmss")))
}
append(field("CALL", record.theirCallsign))
append(field("STATION_CALLSIGN", record.myCallsign))
appendOptional("MODE", record.mode)
appendOptional("SUBMODE", record.submode)
appendOptional("GRIDSQUARE", record.theirGrid)
appendOptional("MY_GRIDSQUARE", record.myGrid)
appendOptional("RST_SENT", record.sentReport)
appendOptional("RST_RCVD", record.receivedReport)
record.txFrequencyHz?.let { append(field("FREQ", hzToMhz(it))) }
record.rxFrequencyHz?.let { append(field("FREQ_RX", hzToMhz(it))) }
appendOptional("BAND", record.band)
appendOptional("BAND_RX", record.rxBand)
appendOptional("PROP_MODE", record.propagationMode.ifBlank { if (record.satelliteName.isNotBlank()) "SAT" else "" })
appendOptional("SAT_NAME", record.satelliteName)
appendOptional("SAT_MODE", record.satelliteMode)
if (record.lotwConfirmed) append(field("LOTW_QSL_RCVD", "Y"))
if (record.lotwReceived) append(field("LOTW_QSL_SENT", "Y"))
appendOptional("LOTW_QSLRDATE", record.lotwQslDate)
appendOptional("VUCC_GRIDS", record.vuccGrids.joinToString(","))
record.dxcc?.let { append(field("DXCC", it.toString())) }
appendOptional("COUNTRY", record.country)
record.cqZone?.let { append(field("CQZ", it.toString())) }
appendOptional("STATE", record.region)
appendOptional("COMMENT", record.comment)
appendOptional("APP_LOOK4SAT_TRANSPONDER", record.transponderName)
record.passAosUtcMillis?.let { append(field("APP_LOOK4SAT_PASS_AOS", it.toString())) }
append(field("APP_LOOK4SAT_AUTOMATIC", if (record.automatic) "Y" else "N"))
append(field("APP_LOOK4SAT_STATUS", record.status.name))
append("<EOR>\r\n")
}
}
fun decode(content: String): List<QsoRecord> = decodeRecords(splitRecords(content))
fun decodeRecords(records: List<Map<String, String>>): List<QsoRecord> = records.mapNotNull { values ->
val call = values["CALL"].orEmpty().trim().uppercase(Locale.US)
val date = values["QSO_DATE"].orEmpty()
val time = values["TIME_ON"].orEmpty()
if (call.isBlank() || date.length != 8 || time.length !in setOf(4, 6)) return@mapNotNull null
val start = parse(date, time) ?: return@mapNotNull null
val end = values["QSO_DATE_OFF"]?.let { endDate ->
values["TIME_OFF"]?.let { endTime -> parse(endDate, endTime) }
}
QsoRecord(
startUtcMillis = start,
endUtcMillis = end,
theirCallsign = call,
myCallsign = values["STATION_CALLSIGN"].orEmpty().uppercase(Locale.US),
theirGrid = values["GRIDSQUARE"].orEmpty().uppercase(Locale.US),
myGrid = values["MY_GRIDSQUARE"].orEmpty().uppercase(Locale.US),
sentReport = values["RST_SENT"].orEmpty(),
receivedReport = values["RST_RCVD"].orEmpty(),
txFrequencyHz = mhzToHz(values["FREQ"]),
rxFrequencyHz = mhzToHz(values["FREQ_RX"]),
band = values["BAND"].orEmpty(),
rxBand = values["BAND_RX"].orEmpty(),
mode = values["MODE"].orEmpty(),
submode = values["SUBMODE"].orEmpty(),
satelliteName = values["SAT_NAME"].orEmpty(),
transponderName = values["APP_LOOK4SAT_TRANSPONDER"].orEmpty(),
satelliteMode = values["SAT_MODE"].orEmpty(),
passAosUtcMillis = values["APP_LOOK4SAT_PASS_AOS"]?.toLongOrNull(),
automatic = values["APP_LOOK4SAT_AUTOMATIC"].equals("Y", true),
status = values["APP_LOOK4SAT_STATUS"]?.let { runCatching { QsoStatus.valueOf(it) }.getOrNull() }
?: QsoStatus.COMPLETE,
propagationMode = values["PROP_MODE"].orEmpty().trim().uppercase(Locale.US),
lotwConfirmed = values["LOTW_QSL_RCVD"].equals("Y", true),
lotwReceived = values["LOTW_QSL_SENT"].equals("Y", true),
lotwQslDate = values["LOTW_QSLRDATE"].orEmpty(),
vuccGrids = values["VUCC_GRIDS"].orEmpty().split(',').map { it.trim().uppercase(Locale.US) }
.filter { it.matches(Regex("[A-R]{2}[0-9]{2}([A-X]{2}([0-9]{2})?)?")) }.distinct(),
dxcc = values["DXCC"]?.toIntOrNull(),
country = values["COUNTRY"].orEmpty(),
cqZone = values["CQZ"]?.toIntOrNull()?.takeIf { it in 1..40 },
region = values["STATE"].orEmpty().substringBefore(" // ").trim().uppercase(Locale.US),
comment = values["COMMENT"].orEmpty()
)
}
private fun splitRecords(content: String): List<Map<String, String>> {
val records = mutableListOf<Map<String, String>>()
var values = linkedMapOf<String, String>()
var index = 0
while (index < content.length) {
val start = content.indexOf('<', index)
if (start < 0) break
val end = content.indexOf('>', start + 1)
if (end < 0) break
val descriptor = content.substring(start + 1, end)
val parts = descriptor.split(':')
val name = parts.first().trim().uppercase(Locale.US)
index = end + 1
when (name) {
"EOH" -> values.clear()
"EOR" -> {
if (values.isNotEmpty()) records += values.toMap()
values = linkedMapOf()
}
else -> {
val length = parts.getOrNull(1)?.toIntOrNull() ?: continue
if (length < 0 || index + length > content.length) continue
values[name] = content.substring(index, index + length)
index += length
}
}
}
return records
}
private fun StringBuilder.appendOptional(name: String, value: String) {
if (value.isNotBlank()) append(field(name, value))
}
private fun field(name: String, value: String): String = "<$name:${value.length}>$value"
private fun hzToMhz(value: Long): String = String.format(Locale.US, "%.6f", value / 1_000_000.0)
private fun mhzToHz(value: String?): Long? = value?.toDoubleOrNull()?.let { (it * 1_000_000.0).toLong() }
private fun format(value: Long, pattern: String): String =
SimpleDateFormat(pattern, Locale.US).apply { timeZone = utc }.format(Date(value))
private fun parse(date: String, time: String): Long? = runCatching {
val normalizedTime = if (time.length == 4) "${time}00" else time
SimpleDateFormat("yyyyMMddHHmmss", Locale.US).apply {
isLenient = false
timeZone = utc
}.parse(date + normalizedTime)?.time
}.getOrNull()
}
@@ -0,0 +1,24 @@
/*
* 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.domain.logbook
import kotlinx.coroutines.flow.Flow
interface IQsoRepository {
val records: Flow<List<QsoRecord>>
suspend fun find(id: Long): QsoRecord?
suspend fun save(record: QsoRecord): Long
suspend fun delete(id: Long)
suspend fun exportAdi(ids: Set<Long>? = null, includeIncomplete: Boolean = false): String
suspend fun importAdi(content: String): AdifImportResult
suspend fun mergeConfirmed(records: List<QsoRecord>): AdifImportResult
suspend fun mergeLoTW(records: List<QsoRecord>): AdifImportResult
}
@@ -0,0 +1,25 @@
package com.rtbishop.look4sat.core.domain.logbook
import com.rtbishop.look4sat.core.domain.model.GridQso
/** Bridge a LoTW report QSO (confirmed, download side) into a logbook record
* so the logbook can show downloaded confirmations and mark local uploads
* as confirmed (matched by [sameConfirmedContact]). */
fun GridQso.toConfirmedRecord(accountCallsign: String): QsoRecord = QsoRecord(
startUtcMillis = epochMs,
theirCallsign = call,
myCallsign = accountCallsign.trim().uppercase(),
myGrid = (myGrids.firstOrNull() ?: myGrid).orEmpty(),
vuccGrids = myGrids.toList(),
band = bandUp,
rxBand = bandDown,
mode = mode,
satelliteName = satName,
propagationMode = "SAT",
status = QsoStatus.COMPLETE,
lotwConfirmed = true,
dxcc = dxcc,
country = country.orEmpty(),
cqZone = cqz,
region = state.orEmpty()
)
@@ -0,0 +1,65 @@
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. */
fun satMatchKey(name: String): String = name.substringBefore('(').trim().uppercase(Locale.US)
/** 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
fun QsoRecord.confirmationLookupKey(): String = listOf(
theirCallsign.trim().uppercase(Locale.US), satMatchKey(satelliteName), displayMode
).joinToString("|")
fun QsoRecord.withConfirmation(confirmed: QsoRecord): QsoRecord = copy(
myCallsign = myCallsign.ifBlank { confirmed.myCallsign },
theirGrid = confirmed.theirGrid.ifBlank { theirGrid },
myGrid = myGrid.ifBlank { confirmed.myGrid },
sentReport = sentReport.ifBlank { confirmed.sentReport },
receivedReport = receivedReport.ifBlank { confirmed.receivedReport },
txFrequencyHz = txFrequencyHz ?: confirmed.txFrequencyHz,
rxFrequencyHz = rxFrequencyHz ?: confirmed.rxFrequencyHz,
band = band.ifBlank { confirmed.band },
rxBand = rxBand.ifBlank { confirmed.rxBand },
propagationMode = propagationMode.ifBlank { confirmed.propagationMode },
status = QsoStatus.COMPLETE,
lotwConfirmed = lotwConfirmed || confirmed.lotwConfirmed,
lotwReceived = lotwReceived || confirmed.lotwReceived || confirmed.lotwConfirmed,
lotwQslDate = confirmed.lotwQslDate.ifBlank { lotwQslDate },
vuccGrids = confirmed.vuccGrids.ifEmpty { vuccGrids },
dxcc = confirmed.dxcc ?: dxcc,
country = confirmed.country.ifBlank { country },
cqZone = confirmed.cqZone ?: cqZone,
region = confirmed.region.ifBlank { region }
)
val QsoRecord.displayMode: String
get() = submode.ifBlank { mode }.trim().uppercase(Locale.US)
val QsoRecord.isSatellite: Boolean
get() = propagationMode.equals("SAT", true) || (propagationMode.isBlank() && satelliteName.isNotBlank())
fun frequencyBand(hz: Long?): String = when (hz) {
null -> ""
in 28_000_000L..29_700_000L -> "10M"
in 50_000_000L..54_000_000L -> "6M"
in 144_000_000L..148_000_000L -> "2M"
in 219_000_000L..225_000_000L -> "1.25M"
in 420_000_000L..450_000_000L -> "70CM"
in 902_000_000L..928_000_000L -> "33CM"
in 1_240_000_000L..1_300_000_000L -> "23CM"
in 2_300_000_000L..2_450_000_000L -> "13CM"
in 5_650_000_000L..5_925_000_000L -> "6CM"
in 10_000_000_000L..10_500_000_000L -> "3CM"
else -> ""
}
@@ -0,0 +1,48 @@
/*
* 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.domain.logbook
enum class QsoStatus { DRAFT, COMPLETE, ABORTED }
data class QsoRecord(
val id: Long = 0L,
val startUtcMillis: Long,
val endUtcMillis: Long? = null,
val theirCallsign: String,
val myCallsign: String,
val theirGrid: String = "",
val myGrid: String = "",
val sentReport: String = "",
val receivedReport: String = "",
val txFrequencyHz: Long? = null,
val rxFrequencyHz: Long? = null,
val band: String = "",
val rxBand: String = "",
val mode: String = "MFSK",
val submode: String = "FT4",
val satelliteName: String = "",
val transponderName: String = "",
val satelliteMode: String = "",
val passAosUtcMillis: Long? = null,
val automatic: Boolean = false,
val status: QsoStatus = QsoStatus.DRAFT,
val propagationMode: String = if (satelliteName.isNotBlank()) "SAT" else "",
val lotwConfirmed: Boolean = false,
val lotwReceived: Boolean = false,
val lotwQslDate: String = "",
val vuccGrids: List<String> = emptyList(),
val dxcc: Int? = null,
val country: String = "",
val cqZone: Int? = null,
val region: String = "",
val comment: String = ""
)
data class AdifImportResult(val imported: Int, val skipped: Int, val updated: Int = 0)
@@ -0,0 +1,74 @@
package com.rtbishop.look4sat.core.domain.repository
import com.rtbishop.look4sat.core.domain.logbook.QsoRecord
interface ILoTWUploadRepository {
suspend fun certificate(): LoTWCertificate?
suspend fun station(): LoTWStation?
suspend fun importCertificate(data: ByteArray, password: CharArray): LoTWCertificate
suspend fun saveCertificatePassword(password: CharArray): LoTWCertificate
suspend fun saveStation(station: LoTWStation): LoTWStation
suspend fun removeCertificate()
suspend fun audit(records: List<QsoRecord>): LoTWUploadAudit
suspend fun prepare(records: List<QsoRecord>, resubmit: Boolean): LoTWUploadPreview
suspend fun upload(previewId: String): LoTWUploadResult
fun discardPreview()
}
data class LoTWCertificate(
val callsign: String,
val dxcc: Int,
val serial: String,
val expires: String,
val firstQsoDate: String,
val lastQsoDate: String,
val passwordSaved: Boolean = false
)
/** The location is explicitly confirmed for the selected contacts, not inferred from today's GPS. */
data class LoTWStation(
val grid: String = "",
val cqZone: String = "",
val ituZone: String = "",
val region: String = "",
val county: String = "",
val iota: String = ""
)
data class LoTWUploadPreview(
val id: String,
val callsign: String,
val dxcc: Int,
val grid: String,
val count: Int,
val skipped: Int,
val firstUtc: String,
val lastUtc: String,
val contacts: List<String>,
val unknownSkipped: Int = 0
)
/** Local comparison against downloaded LoTW receipt flags and this app's durable upload receipts. */
data class LoTWUploadAudit(
val total: Int,
val pending: Int,
val uploaded: Int,
val unknown: Int,
val unavailable: Int
)
sealed interface LoTWUploadResult {
data class Accepted(val count: Int) : LoTWUploadResult
data class Rejected(val message: String = "") : LoTWUploadResult
/** May have reached LoTW. Do not retry automatically. */
data object Unknown : LoTWUploadResult
data object ExpiredPreview : LoTWUploadResult
}
class LoTWOperationException(val reason: LoTWProblem, val detail: String = "") : Exception(reason.name)
enum class LoTWProblem {
CERTIFICATE_PASSWORD, CERTIFICATE_INVALID, CERTIFICATE_EXPIRED, CERTIFICATE_MISSING,
STORAGE, EMPTY_SELECTION, CALLSIGN_MISMATCH, QSO_DATE, STATION_GRID, STATION_REGION,
STATION_ZONE, STATION_IOTA, MODE, BAND, SATELLITE, INVALID_CONTACT, LOCATION_MISMATCH, TOO_MANY_CONTACTS
}
@@ -16,6 +16,7 @@
* 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.IQsoRepository
import com.rtbishop.look4sat.core.domain.model.SatRadio
import com.rtbishop.look4sat.core.domain.usecase.IAddToCalendar
import com.rtbishop.look4sat.core.domain.usecase.IAudioCapture
@@ -34,6 +35,8 @@ interface IMainContainer {
val updateRepo: IUpdateRepository
val wavelogRepo: IWavelogRepository
val lotwRepo: com.rtbishop.look4sat.core.domain.repository.ILoTWRepository
val qsoRepository: IQsoRepository
val lotwUploadRepository: ILoTWUploadRepository
val radioTrackingService: IRadioTrackingService
val mutualPassData: StateFlow<MutualPassData>
fun setMutualPassData(data: MutualPassData)
@@ -89,6 +89,12 @@ interface ISettingsRepo {
fun setSatelliteOffset(catnum: Int, offset: String)
//endregion
//region # Per-satellite logbook mode preset settings
/** Last selected logbook mode (CW/SSB/FT4) for a satellite; empty when unset. */
fun getSatelliteMode(catnum: Int): String
fun setSatelliteMode(catnum: Int, mode: String)
//endregion
//region # AMSAT status report settings
fun getAmSatCallsign(): String
fun setAmSatCallsign(callsign: String)
@@ -0,0 +1,86 @@
/*
* 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.domain.logbook
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
class AdifCodecTest {
@Test
fun encodeDecode_roundTrip() {
val record = QsoRecord(
startUtcMillis = 1_700_000_000_000L,
endUtcMillis = 1_700_000_000_100L,
theirCallsign = "BG5JVM",
myCallsign = "BA7OPF",
theirGrid = "OM60",
myGrid = "OL62TI",
sentReport = "59",
receivedReport = "59",
txFrequencyHz = 145_850_000L,
rxFrequencyHz = 436_805_000L,
band = "2M",
rxBand = "70CM",
mode = "FM",
satelliteName = "SO-50",
satelliteMode = "FM",
propagationMode = "SAT",
status = QsoStatus.COMPLETE,
lotwConfirmed = true,
lotwQslDate = "2026-09-20",
vuccGrids = listOf("OM60", "OM50")
)
val decoded = AdifCodec.decode(AdifCodec.encode(listOf(record))).single()
assertEquals(record.startUtcMillis, decoded.startUtcMillis)
assertEquals(record.theirCallsign, decoded.theirCallsign)
assertEquals(record.myCallsign, decoded.myCallsign)
assertEquals(record.satelliteName, decoded.satelliteName)
assertEquals("2M", decoded.band)
assertEquals("70CM", decoded.rxBand)
assertTrue(decoded.lotwConfirmed)
assertEquals(listOf("OM60", "OM50"), decoded.vuccGrids)
}
@Test
fun decode_lotwStyleReportFields() {
// LoTW report subset: lowercase eor, alphabetical fields, VUCC_GRIDS list.
val adi = """
<EOH>
<CALL:6>BH6RJD
<QSO_DATE:8>20260916
<TIME_ON:6>051300
<MODE:2>FM
<SAT_NAME:5>SO-50
<PROP_MODE:3>SAT
<BAND:3>2M
<BAND_RX:4>70CM
<GRIDSQUARE:4>OM60
<MY_GRIDSQUARE:8>OM60IL70
<VUCC_GRIDS:9>OM60,OM50
<STATION_CALLSIGN:6>BH6RJD
<DXCC:3>318
<COUNTRY:5>China
<LOTW_QSL_RCVD:1>Y
<eor>
""".trimIndent()
val records = AdifCodec.decode(adi)
assertEquals(1, records.size)
val record = records.single()
assertEquals("BH6RJD", record.theirCallsign)
assertEquals("SO-50", record.satelliteName)
assertEquals("FM", record.displayMode)
assertEquals("OM60IL70", record.myGrid)
assertEquals(listOf("OM60", "OM50"), record.vuccGrids)
assertTrue(record.lotwConfirmed)
assertTrue(record.isSatellite)
}
}
@@ -0,0 +1,126 @@
/*
* 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.domain.logbook
import com.rtbishop.look4sat.core.domain.model.GridQso
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class LogbookMergeTest {
private val local = QsoRecord(
startUtcMillis = 1_700_000_000_000L,
theirCallsign = "bg5jvm",
myCallsign = "ba7opf",
txFrequencyHz = 145_850_000L,
mode = "FM",
submode = "",
satelliteName = "SO-50 (SaudiOSCAR 50)",
propagationMode = "SAT",
status = QsoStatus.COMPLETE
)
private val confirmed = QsoRecord(
startUtcMillis = 1_700_000_000_000L,
theirCallsign = "BG5JVM",
myCallsign = "BA7OPF",
mode = "FM",
submode = "",
satelliteName = "SO-50",
propagationMode = "SAT",
status = QsoStatus.COMPLETE,
lotwConfirmed = true,
vuccGrids = listOf("OM60", "OM50")
)
@Test
fun satMatchKey_stripsParenthetical() {
assertEquals("SO-50", satMatchKey("SO-50 (SaudiOSCAR 50)"))
assertEquals("SO-50", satMatchKey("SO-50"))
assertEquals("AO-91", satMatchKey("AO-91 (FUNcube-1)"))
assertEquals("IO-86", satMatchKey("IO-86"))
}
@Test
fun sameConfirmedContact_matchesAcrossSatNOGSAndOfficialName() {
assertTrue(sameConfirmedContact(local, confirmed))
}
@Test
fun sameConfirmedContact_rejectsDifferentSatellite() {
val other = confirmed.copy(satelliteName = "AO-91")
assertFalse(sameConfirmedContact(local, other))
}
@Test
fun sameConfirmedContact_rejectsTimeSkew() {
val skewed = confirmed.copy(startUtcMillis = confirmed.startUtcMillis + 120_000L)
assertFalse(sameConfirmedContact(local, skewed))
}
@Test
fun confirmationLookupKey_usesNormalizedSatellite() {
assertEquals(local.confirmationLookupKey(), confirmed.confirmationLookupKey())
}
@Test
fun withConfirmation_marksConfirmedAndMergesVuccGrids() {
val merged = local.withConfirmation(confirmed)
assertTrue(merged.lotwConfirmed)
assertTrue(merged.lotwReceived)
assertEquals(listOf("OM60", "OM50"), merged.vuccGrids)
// Local myCallsign is kept when present; only blanks are backfilled.
assertEquals("ba7opf", merged.myCallsign)
}
@Test
fun toConfirmedRecord_mapsGridQso() {
val qso = GridQso(
call = "BH6RJD",
epochMs = 1_700_000_000_000L,
satName = "SO-50",
mode = "FM",
bandUp = "70CM",
bandDown = "2M",
dxcc = 318,
country = "China",
cqz = 24,
state = "GD",
myGrid = "OM60",
myGrids = setOf("OM60", "OM50")
)
val record = qso.toConfirmedRecord("ba7opf")
assertEquals("BH6RJD", record.theirCallsign)
assertEquals("BA7OPF", record.myCallsign)
assertEquals("OM60", record.myGrid)
assertEquals(listOf("OM60", "OM50"), record.vuccGrids)
assertTrue(record.lotwConfirmed)
assertTrue(record.isSatellite)
assertEquals("70CM", record.band)
assertEquals("2M", record.rxBand)
}
@Test
fun frequencyBand_edges() {
assertEquals("2M", frequencyBand(144_000_000L))
assertEquals("70CM", frequencyBand(450_000_000L))
assertEquals("10M", frequencyBand(29_000_000L))
assertEquals("", frequencyBand(null))
}
@Test
fun displayMode_prefersSubmode() {
val ft4 = QsoRecord(startUtcMillis = 0, theirCallsign = "x", myCallsign = "y", mode = "MFSK", submode = "FT4")
assertEquals("FT4", ft4.displayMode)
assertEquals("FM", local.displayMode)
}
}
@@ -285,6 +285,8 @@ class LoTWGridSyncTest {
override fun updateDataSourcesStatus(status: Map<String, Int>) = TODO()
override fun updateRadioControlSettings(settings: RadioControlSettings) = TODO()
override fun getSatelliteOffset(catnum: Int): String = ""
override fun getSatelliteMode(catnum: Int): String = ""
override fun setSatelliteMode(catnum: Int, mode: String) {}
override fun setSatelliteOffset(catnum: Int, offset: String) = TODO()
override fun getAmSatCallsign(): String = ""
override fun setAmSatCallsign(callsign: String) = TODO()