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

Binary file not shown.
@@ -0,0 +1,47 @@
/*
* 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.database
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import androidx.room.Transaction
import com.rtbishop.look4sat.core.data.database.entity.QsoEntity
import kotlinx.coroutines.flow.Flow
@Dao
interface QsoDao {
@Query("SELECT * FROM qso_records ORDER BY startUtcMillis DESC, id DESC")
fun observeAll(): Flow<List<QsoEntity>>
@Query("SELECT * FROM qso_records WHERE id = :id LIMIT 1")
suspend fun find(id: Long): QsoEntity?
@Query("SELECT * FROM qso_records ORDER BY startUtcMillis DESC, id DESC")
suspend fun getAll(): List<QsoEntity>
@Query("SELECT dedupeKey FROM qso_records")
suspend fun getDedupeKeys(): List<String>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun save(record: QsoEntity): Long
@Insert(onConflict = OnConflictStrategy.IGNORE)
suspend fun importRecords(records: List<QsoEntity>): List<Long>
@Transaction
suspend fun saveBatch(records: List<QsoEntity>) {
records.forEach { save(it) }
}
@Query("DELETE FROM qso_records WHERE id = :id")
suspend fun delete(id: Long)
}
@@ -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.data.database
import androidx.room.Database
import androidx.room.RoomDatabase
import com.rtbishop.look4sat.core.data.database.entity.QsoEntity
/**
* Separate database for the logbook (QSO records, LoTW confirmations and
* upload receipts). Kept apart from [Look4SatDb] so existing installations
* never need a migration of the satellite database.
*/
@Database(entities = [QsoEntity::class], version = 1, exportSchema = false)
abstract class QsoDatabase : RoomDatabase() {
abstract fun qsoDao(): QsoDao
}
@@ -0,0 +1,54 @@
/*
* 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.database.entity
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.Index
import androidx.room.PrimaryKey
@Entity(
tableName = "qso_records",
indices = [Index(value = ["dedupeKey"])]
)
data class QsoEntity(
@PrimaryKey(autoGenerate = true) val id: Long = 0L,
val startUtcMillis: Long,
val endUtcMillis: Long?,
val theirCallsign: String,
val myCallsign: String,
val theirGrid: String,
val myGrid: String,
val sentReport: String,
val receivedReport: String,
val txFrequencyHz: Long?,
val rxFrequencyHz: Long?,
val band: String,
val rxBand: String,
@ColumnInfo(defaultValue = "'MFSK'") val mode: String,
@ColumnInfo(defaultValue = "'FT4'") val submode: String,
val satelliteName: String,
val transponderName: String,
val satelliteMode: String,
val passAosUtcMillis: Long?,
val automatic: Boolean,
val status: String,
@ColumnInfo(defaultValue = "''") val dedupeKey: String,
@ColumnInfo(defaultValue = "''") val propagationMode: String = "",
@ColumnInfo(defaultValue = "0") val lotwConfirmed: Boolean = false,
@ColumnInfo(defaultValue = "0") val lotwReceived: Boolean = false,
@ColumnInfo(defaultValue = "''") val lotwQslDate: String = "",
@ColumnInfo(defaultValue = "''") val vuccGrids: String = "",
val dxcc: Int? = null,
@ColumnInfo(defaultValue = "''") val country: String = "",
val cqZone: Int? = null,
@ColumnInfo(defaultValue = "''") val region: String = "",
@ColumnInfo(defaultValue = "''") val comment: String = ""
)
@@ -26,13 +26,16 @@ import androidx.room.Room
import com.rtbishop.look4sat.core.data.database.Look4SatDb
import com.rtbishop.look4sat.core.data.database.MIGRATION_1_2
import com.rtbishop.look4sat.core.data.database.MIGRATION_2_3
import com.rtbishop.look4sat.core.data.database.QsoDatabase
import com.rtbishop.look4sat.core.data.framework.BluetoothReporter
import com.rtbishop.look4sat.core.data.framework.Ft817Controller
import com.rtbishop.look4sat.core.data.framework.Ic705Controller
import com.rtbishop.look4sat.core.data.framework.NetworkReporter
import com.rtbishop.look4sat.core.data.framework.RadioTrackingService
import com.rtbishop.look4sat.core.data.lotw.LoTWUploadRepository
import com.rtbishop.look4sat.core.data.repository.AmSatRepository
import com.rtbishop.look4sat.core.data.repository.DatabaseRepo
import com.rtbishop.look4sat.core.data.repository.QsoRepository
import com.rtbishop.look4sat.core.data.repository.SatelliteRepo
import com.rtbishop.look4sat.core.data.repository.SelectionRepo
import com.rtbishop.look4sat.core.data.repository.SensorsRepo
@@ -46,8 +49,10 @@ import com.rtbishop.look4sat.core.data.usecase.AddToCalendar
import com.rtbishop.look4sat.core.data.usecase.AudioCapture
import com.rtbishop.look4sat.core.data.usecase.SaveImage
import com.rtbishop.look4sat.core.data.usecase.ShowToast
import com.rtbishop.look4sat.core.domain.logbook.IQsoRepository
import com.rtbishop.look4sat.core.domain.model.RadioControlSettings
import com.rtbishop.look4sat.core.domain.repository.IDatabaseRepo
import com.rtbishop.look4sat.core.domain.repository.ILoTWUploadRepository
import com.rtbishop.look4sat.core.domain.repository.IMainContainer
import com.rtbishop.look4sat.core.domain.repository.IRadioController
import com.rtbishop.look4sat.core.domain.repository.IRadioTrackingService
@@ -90,6 +95,11 @@ class MainContainer(private val context: Context) : IMainContainer {
override val updateRepo by lazy { UpdateRepository(remoteSource) }
override val wavelogRepo: IWavelogRepository by lazy { WavelogRepository() }
override val lotwRepo: ILoTWRepository by lazy { LoTWRepository() }
override val qsoRepository: IQsoRepository by lazy {
val database = Room.databaseBuilder(context, QsoDatabase::class.java, "Look4SatQsoDB").build()
QsoRepository(database.qsoDao(), Dispatchers.IO)
}
override val lotwUploadRepository: ILoTWUploadRepository by lazy { LoTWUploadRepository(context) }
override val radioTrackingService: IRadioTrackingService by lazy {
val manager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
RadioTrackingService(appScope, manager, satelliteRepo, settingsRepo)
@@ -0,0 +1,132 @@
package com.rtbishop.look4sat.core.data.lotw
import com.rtbishop.look4sat.core.domain.logbook.QsoRecord
import com.rtbishop.look4sat.core.domain.logbook.displayMode
import com.rtbishop.look4sat.core.domain.repository.LoTWProblem
import com.rtbishop.look4sat.core.domain.repository.LoTWStation
import org.w3c.dom.Element
import org.xml.sax.InputSource
import org.xml.sax.SAXException
import java.io.InputStream
import java.io.StringReader
import java.util.Locale
import java.util.zip.GZIPInputStream
import javax.xml.parsers.DocumentBuilderFactory
/** Bundled ARRL configuration 11.34 from https://lotw.arrl.org/lotw/config.tq6. */
internal class LoTWConfig(input: InputStream) {
private val root = input.use { source -> GZIPInputStream(source).use { gzip ->
val xml = gzip.bufferedReader(Charsets.UTF_8).readText()
require(!xml.contains("<!DOCTYPE", true) && !xml.contains("<!ENTITY", true))
// Android's DOM provider does not support Xerces-specific setFeature flags.
DocumentBuilderFactory.newInstance().apply { isExpandEntityReferences = false }.newDocumentBuilder().apply {
setEntityResolver { _, _ -> throw SAXException("External entities are not permitted") }
}.parse(InputSource(StringReader(xml))).documentElement
} }
private val config = root.elements("tqslconfig").single()
val version = config.getAttribute("majorversion") + "." + config.getAttribute("minorversion")
private val spec = config.elements("sigspec").single { it.getAttribute("version") == "2.0" }
val stationOrder = spec.elements("tSTATION").single().childElements().map { it.tagName }
val contactOrder = spec.elements("tCONTACT").single().childElements().map { it.tagName }
private val bands = config.elements("bands").single().elements("band")
private val satellites = config.elements("satellite").associateBy { it.getAttribute("name").uppercase(Locale.US) }
private val modes = config.elements("modes").single().elements("mode").map { it.textContent }.toSet()
fun mode(record: QsoRecord): String {
val display = record.displayMode
if (display in modes) return display
val mapped = config.elements("adifmode").firstOrNull { it.textContent.equals(display, true) }?.getAttribute("mode")
return mapped ?: fail(LoTWProblem.MODE, display)
}
fun band(explicit: String, frequency: Long?, required: Boolean): String {
if (frequency != null && frequency <= 0) fail(LoTWProblem.BAND)
val frequencyBand = frequency?.let { hz -> bands.firstOrNull { band ->
val unit = if (band.getAttribute("spectrum") == "HF") 1_000.0 else 1_000_000.0
val mhz = hz / unit
mhz >= band.getAttribute("low").toDouble() && mhz <= band.getAttribute("high").toDouble()
}?.textContent }
val value = explicit.trim().uppercase(Locale.US).ifBlank { frequencyBand.orEmpty() }
if (value.isBlank() && !required && frequency == null) return ""
if (bands.none { it.textContent == value } || (frequency != null && frequencyBand != value)) fail(LoTWProblem.BAND, value)
return value
}
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)
val first = sat.getAttribute("startDate")
val last = sat.getAttribute("endDate")
if ((first.isNotBlank() && date < first) || (last.isNotBlank() && date > last)) fail(LoTWProblem.SATELLITE, normalized)
return sat.getAttribute("name").uppercase(Locale.US)
}
fun propagation(value: String): String {
val normalized = value.trim().uppercase(Locale.US)
if (normalized.isNotBlank() && config.elements("propmode").none { it.getAttribute("name") == normalized || it.textContent == normalized }) {
fail(LoTWProblem.INVALID_CONTACT, normalized)
}
return normalized
}
fun stationFields(station: LoTWStation, dxcc: Int): Map<String, String> {
fun normalized(value: String) = value.trim().uppercase(Locale.US)
val grids = station.grid.split(',').map(::normalized).filter(String::isNotBlank).distinct()
if (grids.size !in 1..4 || grids.any { !it.matches(Regex("[A-R]{2}[0-9]{2}([A-X]{2}([0-9]{2})?)?")) }) {
fail(LoTWProblem.STATION_GRID)
}
// The primary grid goes into GRIDSQUARE (the field the config's tSTATION
// order signs); the full multi-grid set goes into MY_VUCC_GRIDS, an extra
// station field that TQSL stores but does not sign (same precedent as IOTA).
val fields = linkedMapOf("GRIDSQUARE" to grids.first())
if (grids.size > 1) fields["MY_VUCC_GRIDS"] = grids.joinToString(",")
fun zone(name: String, value: String, maximum: Int) {
if (value.isBlank()) return
val number = value.trim().toIntOrNull()?.takeIf { it in 1..maximum } ?: fail(LoTWProblem.STATION_ZONE)
fields[name] = number.toString()
}
zone("CQZ", station.cqZone, 40)
zone("ITUZ", station.ituZone, 90)
if (station.iota.isNotBlank()) {
val iota = normalized(station.iota)
if (!iota.matches(Regex("(AF|AN|AS|EU|NA|OC|SA)-[0-9]{3}"))) fail(LoTWProblem.STATION_IOTA)
fields["IOTA"] = iota
}
val pageFields = config.elements("page").filter { it.getAttribute("dependency") == dxcc.toString() }
.flatMap { it.elements("pageField") }.map { it.textContent }
val primary = pageFields.firstOrNull { it in setOf("US_STATE", "CA_PROVINCE", "RU_OBLAST", "CN_PROVINCE", "AU_STATE", "JA_PREFECTURE", "FI_KUNTA") }
val secondary = pageFields.firstOrNull { it in setOf("US_COUNTY", "JA_CITY_GUN_KU") }
fun region(name: String?, value: String) {
if (value.isBlank()) return
if (name == null) fail(LoTWProblem.STATION_REGION)
val field = config.elements("field").single { it.getAttribute("Id") == name }
val dependency = when (field.getAttribute("dependsOn")) {
"DXCC" -> dxcc.toString()
else -> fields[field.getAttribute("dependsOn")]
}
val options = field.elements("enums").filter {
it.getAttribute("dependency").isBlank() || it.getAttribute("dependency") == dependency
}.flatMap { it.elements("enum") }.map { it.getAttribute("value").uppercase(Locale.US) }
val code = normalized(value)
if (code !in options) fail(LoTWProblem.STATION_REGION, name)
fields[name] = code
}
region(primary, station.region)
region(secondary, station.county)
return fields
}
}
private fun Element.elements(tag: String): List<Element> = getElementsByTagName(tag).let { nodes ->
(0 until nodes.length).map { nodes.item(it) as Element }
}
private fun Element.childElements(): List<Element> = (0 until childNodes.length).mapNotNull { childNodes.item(it) as? Element }
@@ -0,0 +1,109 @@
package com.rtbishop.look4sat.core.data.lotw
import com.rtbishop.look4sat.core.domain.repository.LoTWCertificate
import com.rtbishop.look4sat.core.domain.repository.LoTWOperationException
import com.rtbishop.look4sat.core.domain.repository.LoTWProblem
import java.security.KeyStore
import java.security.PrivateKey
import java.security.Signature
import java.security.cert.X509Certificate
import java.text.SimpleDateFormat
import java.util.Collections
import java.util.Date
import java.util.Locale
import java.util.TimeZone
internal data class LoTWKeyMaterial(val key: PrivateKey, val certificate: X509Certificate, val info: LoTWCertificate) {
companion object {
fun read(bytes: ByteArray, password: CharArray, now: Long): LoTWKeyMaterial {
if (bytes.isEmpty() || bytes.size > MAX_CERTIFICATE_BYTES) fail(LoTWProblem.CERTIFICATE_INVALID)
val store = try {
KeyStore.getInstance("PKCS12").apply { bytes.inputStream().use { load(it, password) } }
} catch (_: Exception) { fail(LoTWProblem.CERTIFICATE_PASSWORD) }
val aliases = Collections.list(store.aliases()).filter { store.isKeyEntry(it) }
if (aliases.size != 1) fail(LoTWProblem.CERTIFICATE_INVALID)
val alias = aliases.single()
val key = try { store.getKey(alias, password) as? PrivateKey }
catch (_: Exception) { fail(LoTWProblem.CERTIFICATE_PASSWORD) }
?: fail(LoTWProblem.CERTIFICATE_INVALID)
val cert = store.getCertificate(alias) as? X509Certificate ?: fail(LoTWProblem.CERTIFICATE_INVALID)
if (key.algorithm != "RSA" || cert.publicKey.algorithm != "RSA") fail(LoTWProblem.CERTIFICATE_INVALID)
try { cert.checkValidity(Date(now)) } catch (_: Exception) { fail(LoTWProblem.CERTIFICATE_EXPIRED) }
val info = try { metadata(cert) } catch (_: Exception) { fail(LoTWProblem.CERTIFICATE_INVALID) }
// Use the platform's crypto implementation, including PKCS#1 padding. No custom cryptography.
val challenge = "Look4Sat LoTW certificate key check".toByteArray(Charsets.US_ASCII)
val signed = Signature.getInstance("SHA1withRSA").run { initSign(key); update(challenge); sign() }
val valid = Signature.getInstance("SHA1withRSA").run { initVerify(cert); update(challenge); verify(signed) }
if (!valid) fail(LoTWProblem.CERTIFICATE_INVALID)
return LoTWKeyMaterial(key, cert, info)
}
private fun metadata(cert: X509Certificate): LoTWCertificate {
val oid = byteArrayOf(0x2b, 0x06, 0x01, 0x04, 0x01, 0xe0.toByte(), 0x3c, 0x01, 0x01)
val subject = DerValue.read(cert.subjectX500Principal.encoded).single()
require(subject.tag == 0x30)
val calls = subject.children().flatMap { it.children() }.mapNotNull { attribute ->
val pair = attribute.children()
if (pair.size == 2 && pair[0].tag == 6 && pair[0].data.contentEquals(oid)) pair[1].text() else null
}
val call = calls.single().trim().uppercase(Locale.US)
require(call.matches(Regex("[A-Z0-9]+(/[A-Z0-9]+)*")) && call.any(Char::isDigit) && call.any(Char::isLetter))
val first = extension(cert, "2")
val last = extension(cert, "3").takeUnless { it == "0000-00-00" }.orEmpty()
require(validDate(first) && (last.isBlank() || validDate(last)) && (last.isBlank() || last >= first))
val dxcc = extension(cert, "4").toInt()
require(dxcc > 0)
return LoTWCertificate(call, dxcc, cert.serialNumber.toString(16), utc(cert.notAfter.time, "yyyy-MM-dd"), first, last)
}
private fun extension(cert: X509Certificate, suffix: String): String {
val bytes = cert.getExtensionValue("1.3.6.1.4.1.12348.1.$suffix") ?: return ""
val outer = DerValue.read(bytes).single()
require(outer.tag == 4)
// TrustedQSL uses raw ASCII extension values. Also accept DER string-wrapped values.
return if (outer.data.all { it.toInt() in 32..126 || it.toInt() == 0 }) {
outer.data.toString(Charsets.US_ASCII).trim('\u0000', ' ')
} else DerValue.read(outer.data).single().text().trim()
}
}
}
internal const val MAX_CERTIFICATE_BYTES = 1024 * 1024
internal fun fail(problem: LoTWProblem, detail: String = ""): Nothing = throw LoTWOperationException(problem, detail)
internal fun utc(millis: Long, pattern: String): String = SimpleDateFormat(pattern, Locale.US).apply {
timeZone = TimeZone.getTimeZone("UTC")
}.format(Date(millis))
internal fun validDate(value: String): Boolean = value.matches(Regex("[0-9]{4}-[0-9]{2}-[0-9]{2}")) && runCatching {
SimpleDateFormat("yyyy-MM-dd", Locale.US).apply { isLenient = false }.parse(value) != null
}.getOrDefault(false)
/** Only bounded DER metadata decoding; cryptographic verification is delegated to JCA. */
internal data class DerValue(val tag: Int, val data: ByteArray) {
fun children(): List<DerValue> = read(data)
fun text(): String = when (tag) {
12, 19, 20, 22, 23, 24 -> data.toString(Charsets.UTF_8)
30 -> data.toString(Charsets.UTF_16BE)
else -> error("Not an ASN.1 string")
}
companion object {
fun read(bytes: ByteArray): List<DerValue> {
val result = mutableListOf<DerValue>()
var offset = 0
while (offset < bytes.size) {
require(bytes.size - offset >= 2)
val tag = bytes[offset++].toInt() and 255
var length = bytes[offset++].toInt() and 255
if (length and 128 != 0) {
val size = length and 127
require(size in 1..3 && size <= bytes.size - offset)
length = 0
repeat(size) { length = (length shl 8) or (bytes[offset++].toInt() and 255) }
}
require(length <= bytes.size - offset)
result += DerValue(tag, bytes.copyOfRange(offset, offset + length))
offset += length
}
return result
}
}
}
@@ -0,0 +1,84 @@
package com.rtbishop.look4sat.core.data.lotw
import com.rtbishop.look4sat.core.domain.logbook.QsoRecord
import com.rtbishop.look4sat.core.domain.logbook.QsoStatus
import com.rtbishop.look4sat.core.domain.logbook.isSatellite
import com.rtbishop.look4sat.core.domain.repository.LoTWProblem
import java.io.ByteArrayOutputStream
import java.math.BigDecimal
import java.security.MessageDigest
import java.security.Signature
import java.util.Locale
import java.util.zip.GZIPOutputStream
import kotlin.io.encoding.Base64
internal data class LoTWContact(val record: QsoRecord, val fields: Map<String, String>, val signData: String, val fingerprint: String)
internal class LoTWSigner(private val config: LoTWConfig) {
fun contact(record: QsoRecord, key: LoTWKeyMaterial, station: Map<String, String>, now: Long): LoTWContact {
val call = record.theirCallsign.trim().uppercase(Locale.US)
if (record.status != QsoStatus.COMPLETE || !call.matches(Regex("[A-Z0-9]+(/[A-Z0-9]+)*")) ||
!call.any(Char::isLetter) || !call.any(Char::isDigit)) fail(LoTWProblem.INVALID_CONTACT, call)
if (!record.myCallsign.trim().equals(key.info.callsign, true)) fail(LoTWProblem.CALLSIGN_MISMATCH, call)
val date = utc(record.startUtcMillis, "yyyy-MM-dd")
if (date < key.info.firstQsoDate || (key.info.lastQsoDate.isNotBlank() && date > key.info.lastQsoDate) || record.startUtcMillis > now) {
fail(LoTWProblem.QSO_DATE, call)
}
val grids = buildList {
addAll(station.getValue("GRIDSQUARE").split(',').map(String::trim))
station["MY_VUCC_GRIDS"]?.split(',')?.map(String::trim)?.let(::addAll)
}.filter(String::isNotBlank)
if (record.myGrid.isNotBlank() && grids.none { grid ->
val local = record.myGrid.trim().uppercase(Locale.US)
grid.startsWith(local) || local.startsWith(grid)
}) fail(LoTWProblem.LOCATION_MISMATCH, call)
fun mhz(hz: Long?): String = hz?.let { BigDecimal.valueOf(it, 6).stripTrailingZeros().toPlainString() }.orEmpty()
val fields = linkedMapOf(
"BAND" to config.band(record.band, record.txFrequencyHz, true),
"BAND_RX" to config.band(record.rxBand, record.rxFrequencyHz, false),
"CALL" to call,
"FREQ" to mhz(record.txFrequencyHz),
"FREQ_RX" to mhz(record.rxFrequencyHz),
"MODE" to config.mode(record),
"PROP_MODE" to if (record.isSatellite) "SAT" else config.propagation(record.propagationMode),
"QSO_DATE" to date,
"QSO_TIME" to utc(record.startUtcMillis, "HH:mm:ss'Z'"),
"SAT_NAME" to if (record.isSatellite) config.satellite(record.satelliteName, date) else ""
).filterValues { it.isNotBlank() }
val signData = (config.stationOrder.map { station[it].orEmpty() } + config.contactOrder.map { fields[it].orEmpty() }).joinToString("")
val identity = field("CALL", key.info.callsign) + field("DXCC", key.info.dxcc.toString()) +
station.toSortedMap().entries.joinToString("") { field(it.key, it.value) } +
fields.entries.joinToString("") { field(it.key, it.value) }
val hash = MessageDigest.getInstance("SHA-256").digest(identity.toByteArray(Charsets.UTF_8)).joinToString("") { "%02x".format(it) }
return LoTWContact(record, fields, signData, hash)
}
fun sign(contacts: List<LoTWContact>, key: LoTWKeyMaterial, station: Map<String, String>): ByteArray {
val text = buildString {
append(field("TQSL_IDENT", "Look4Sat LoTW Config: V${config.version} AllowDupes: false"))
append(field("REC_TYPE", "tCERT")); append(field("CERT_UID", "1"))
append(field("CERTIFICATE", Base64.Default.encode(key.certificate.encoded).chunked(64).joinToString("\n")))
append("<eor>\n")
append(field("REC_TYPE", "tSTATION")); append(field("STATION_UID", "1")); append(field("CERT_UID", "1"))
append(field("CALL", key.info.callsign)); append(field("DXCC", key.info.dxcc.toString()))
station.forEach { (name, value) -> append(field(name, value)) }
append("<eor>\n")
contacts.forEach { contact ->
append(field("REC_TYPE", "tCONTACT")); append(field("STATION_UID", "1"))
contact.fields.forEach { (name, value) -> append(field(name, value)) }
// SHA-1 is required by the LoTW V2.0 wire format, not chosen for general-purpose signing.
val signature = Signature.getInstance("SHA1withRSA").run {
initSign(key.key); update(contact.signData.toByteArray(Charsets.UTF_8)); sign()
}
val encoded = Base64.Default.encode(signature).chunked(64).joinToString("\n")
append("<SIGN_LOTW_V2.0:${encoded.length}:6>$encoded\n")
append(field("SIGNDATA", contact.signData)); append("<eor>\n")
}
}
return ByteArrayOutputStream().also { output ->
GZIPOutputStream(output).use { it.write(text.toByteArray(Charsets.UTF_8)) }
}.toByteArray()
}
}
private fun field(name: String, value: String): String = "<$name:${value.toByteArray(Charsets.UTF_8).size}>$value\n"
@@ -0,0 +1,61 @@
package com.rtbishop.look4sat.core.data.lotw
import android.content.Context
import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyProperties
import android.util.AtomicFile
import java.io.File
import java.security.KeyStore
import javax.crypto.Cipher
import javax.crypto.KeyGenerator
import javax.crypto.SecretKey
import javax.crypto.spec.GCMParameterSpec
internal interface LoTWStorage {
fun read(name: String): ByteArray?
fun write(name: String, data: ByteArray)
fun delete(name: String)
}
/** P12 backups and their saved passwords are encrypted at rest and excluded from Android/cloud backup. */
internal class AndroidLoTWStorage(context: Context) : LoTWStorage {
private val directory = File(context.noBackupFilesDir, "lotw")
private val store by lazy { KeyStore.getInstance("AndroidKeyStore").apply { load(null) } }
private fun file(name: String): AtomicFile {
require(name.matches(Regex("[a-z]+")))
check(directory.isDirectory || directory.mkdirs())
return AtomicFile(File(directory, name))
}
private fun key(): SecretKey = (store.getKey(ALIAS, null) as? SecretKey) ?: KeyGenerator.getInstance(
KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore"
).run {
init(KeyGenParameterSpec.Builder(ALIAS, KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT)
.setBlockModes(KeyProperties.BLOCK_MODE_GCM).setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
.setKeySize(256).build())
generateKey()
}
@Synchronized override fun read(name: String): ByteArray? {
val file = file(name)
if (!file.baseFile.exists() && !File(file.baseFile.path + ".bak").exists()) return null
val bytes = file.readFully()
require(bytes.size in 29..(16 * 1024 * 1024) && bytes[0] == 1.toByte())
return Cipher.getInstance("AES/GCM/NoPadding").run {
init(Cipher.DECRYPT_MODE, key(), GCMParameterSpec(128, bytes.copyOfRange(1, 13)))
updateAAD(name.toByteArray(Charsets.UTF_8))
doFinal(bytes, 13, bytes.size - 13)
}
}
@Synchronized override fun write(name: String, data: ByteArray) {
val cipher = Cipher.getInstance("AES/GCM/NoPadding").apply {
init(Cipher.ENCRYPT_MODE, key())
updateAAD(name.toByteArray(Charsets.UTF_8))
}
val bytes = byteArrayOf(1) + cipher.iv + cipher.doFinal(data)
val file = file(name)
val stream = file.startWrite()
try { stream.write(bytes); file.finishWrite(stream) }
catch (error: Exception) { file.failWrite(stream); throw error }
}
@Synchronized override fun delete(name: String) = file(name).delete()
private companion object { const val ALIAS = "look4sat.lotw.storage.v1" }
}
@@ -0,0 +1,369 @@
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.QsoStatus
import com.rtbishop.look4sat.core.domain.repository.ILoTWUploadRepository
import com.rtbishop.look4sat.core.domain.repository.LoTWCertificate
import com.rtbishop.look4sat.core.domain.repository.LoTWOperationException
import com.rtbishop.look4sat.core.domain.repository.LoTWProblem
import com.rtbishop.look4sat.core.domain.repository.LoTWStation
import com.rtbishop.look4sat.core.domain.repository.LoTWUploadAudit
import com.rtbishop.look4sat.core.domain.repository.LoTWUploadPreview
import com.rtbishop.look4sat.core.domain.repository.LoTWUploadResult
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import okhttp3.Call
import okhttp3.Callback
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.MultipartBody
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.Response
import java.io.ByteArrayOutputStream
import java.io.DataInputStream
import java.io.DataOutputStream
import java.io.IOException
import java.nio.ByteBuffer
import java.nio.CharBuffer
import java.util.Locale
import java.util.UUID
import java.util.concurrent.TimeUnit
import kotlin.coroutines.coroutineContext
import kotlin.coroutines.resume
private fun readBoundedBody(response: Response, limit: Int): String = response.body.charStream().use { reader ->
val result = StringBuilder()
val buffer = CharArray(8192)
while (true) {
val count = reader.read(buffer)
if (count < 0) break
require(result.length <= limit - count) { "Response exceeds size limit" }
result.append(buffer, 0, count)
}
result.toString()
}
class LoTWUploadRepository internal constructor(
private val storage: LoTWStorage,
private val config: () -> LoTWConfig,
client: OkHttpClient = OkHttpClient(),
private val now: () -> Long = System::currentTimeMillis
) : ILoTWUploadRepository {
constructor(context: Context) : this(AndroidLoTWStorage(context), { LoTWConfig(context.assets.open("lotw/config.tq6")) })
private val client = client.newBuilder().connectTimeout(30, TimeUnit.SECONDS).readTimeout(120, TimeUnit.SECONDS)
.callTimeout(180, TimeUnit.SECONDS).retryOnConnectionFailure(false).followRedirects(false).followSslRedirects(false).build()
private val mutex = Mutex()
@Volatile private var pending: Pending? = null
private data class Pending(val preview: LoTWUploadPreview, val data: ByteArray, val hashes: List<String>, val created: Long)
private data class SigningContext(val key: LoTWKeyMaterial, val signer: LoTWSigner, val location: Map<String, String>)
override suspend fun certificate(): LoTWCertificate? = withContext(Dispatchers.IO) {
mutex.withLock {
val bytes = storage.read("certificate") ?: return@withLock null
try {
readBundle(bytes).let { bundle ->
bundle.p12.fill(0)
bundle.password?.fill(0)
bundle.info.copy(passwordSaved = bundle.password != null)
}
} finally { bytes.fill(0) }
}
}
override suspend fun importCertificate(data: ByteArray, password: CharArray): LoTWCertificate = withContext(Dispatchers.IO) {
mutex.withLock {
val passwordBytes = encodePassword(password)
try {
if (passwordBytes.size > MAX_CERTIFICATE_PASSWORD_BYTES) fail(LoTWProblem.CERTIFICATE_PASSWORD)
val key = LoTWKeyMaterial.read(data, password, now())
val bytes = encodeBundle(key.info, data, passwordBytes)
try { storage.write("certificate", bytes) } finally { bytes.fill(0) }
discardPreview()
key.info.copy(passwordSaved = true)
} finally {
passwordBytes.fill(0)
password.fill('\u0000')
data.fill(0)
}
}
}
override suspend fun saveCertificatePassword(password: CharArray): LoTWCertificate = withContext(Dispatchers.IO) {
mutex.withLock {
val stored = storage.read("certificate") ?: fail(LoTWProblem.CERTIFICATE_MISSING)
val bundle = try { readBundle(stored) } finally { stored.fill(0) }
val passwordBytes = encodePassword(password)
try {
if (passwordBytes.size > MAX_CERTIFICATE_PASSWORD_BYTES) fail(LoTWProblem.CERTIFICATE_PASSWORD)
val key = LoTWKeyMaterial.read(bundle.p12, password, now())
val bytes = encodeBundle(key.info, bundle.p12, passwordBytes)
try { storage.write("certificate", bytes) } finally { bytes.fill(0) }
discardPreview()
key.info.copy(passwordSaved = true)
} finally {
bundle.p12.fill(0)
bundle.password?.fill(0)
passwordBytes.fill(0)
password.fill('\u0000')
}
}
}
override suspend fun station(): LoTWStation? = withContext(Dispatchers.IO) {
mutex.withLock { readStation() }
}
override suspend fun saveStation(station: LoTWStation): LoTWStation = withContext(Dispatchers.IO) {
mutex.withLock {
val stored = storage.read("certificate") ?: fail(LoTWProblem.CERTIFICATE_MISSING)
val bundle = try { readBundle(stored) } finally { stored.fill(0) }
val normalized = station.normalized()
try { config().stationFields(normalized, bundle.info.dxcc) }
finally {
bundle.p12.fill(0)
bundle.password?.fill(0)
}
writeStation(normalized)
discardPreview()
normalized
}
}
override suspend fun removeCertificate() = withContext(Dispatchers.IO) {
mutex.withLock { discardPreview(); storage.delete("certificate") }
}
override suspend fun audit(records: List<QsoRecord>): LoTWUploadAudit = withContext(Dispatchers.IO) {
mutex.withLock {
val signing = signingContext()
val ledger = ledger()
val unique = hashSetOf<String>()
var pendingCount = 0
var uploaded = 0
var unknown = 0
var unavailable = 0
records.sortedBy { it.startUtcMillis }.forEach { record ->
coroutineContext.ensureActive()
when {
record.status != QsoStatus.COMPLETE -> unavailable++
record.lotwReceived -> uploaded++
else -> {
val contact = try {
signing.signer.contact(record, signing.key, signing.location, now())
} catch (_: LoTWOperationException) {
unavailable++
null
}
if (contact != null) when {
!unique.add(contact.fingerprint) -> unavailable++
ledger[contact.fingerprint] == "accepted" -> uploaded++
ledger[contact.fingerprint] == "unknown" -> unknown++
else -> pendingCount++
}
}
}
}
LoTWUploadAudit(records.size, pendingCount, uploaded, unknown, unavailable)
}
}
override suspend fun prepare(records: List<QsoRecord>, resubmit: Boolean): LoTWUploadPreview = withContext(Dispatchers.IO) {
mutex.withLock {
discardPreview()
if (records.size > 10_000) fail(LoTWProblem.TOO_MANY_CONTACTS)
val signing = signingContext()
val ledger = ledger()
var skipped = 0
var unknown = 0
val unique = hashSetOf<String>()
val contacts = records.sortedBy { it.startUtcMillis }.mapNotNull { record ->
coroutineContext.ensureActive()
if (record.status != QsoStatus.COMPLETE || (record.lotwReceived && !resubmit)) { skipped++; return@mapNotNull null }
val contact = signing.signer.contact(record, signing.key, signing.location, now())
val previous = ledger[contact.fingerprint]
when {
(previous == "accepted" && !resubmit) || !unique.add(contact.fingerprint) -> { skipped++; null }
previous == "unknown" && !resubmit -> { skipped++; unknown++; null }
else -> contact
}
}
val preview = LoTWUploadPreview(
UUID.randomUUID().toString(), signing.key.info.callsign, signing.key.info.dxcc, signing.location.getValue("GRIDSQUARE"),
contacts.size, skipped,
contacts.firstOrNull()?.record?.let { utc(it.startUtcMillis, "yyyy-MM-dd HH:mm:ss") }.orEmpty(),
contacts.lastOrNull()?.record?.let { utc(it.startUtcMillis, "yyyy-MM-dd HH:mm:ss") }.orEmpty(),
contacts.map { "${utc(it.record.startUtcMillis, "MM-dd HH:mm")} ${it.record.theirCallsign} ${it.fields["MODE"]} ${it.fields["SAT_NAME"].orEmpty()}" },
unknown
)
if (contacts.isNotEmpty()) pending = Pending(
preview,
signing.signer.sign(contacts, signing.key, signing.location),
contacts.map { it.fingerprint },
now()
)
preview
}
}
override suspend fun upload(previewId: String): LoTWUploadResult = withContext(Dispatchers.IO) {
mutex.withLock {
val payload = pending?.takeIf { it.preview.id == previewId && now() - it.created in 0..900_000 }
?: return@withLock LoTWUploadResult.ExpiredPreview
pending = null // A preview is a one-shot authorization, including across failures.
val ledger = ledger()
payload.hashes.forEach { ledger[it] = "unknown" }
saveLedger(ledger) // Persist BEFORE the POST, so process death/cancellation cannot cause an automatic retry.
try {
val result = post(payload.data, payload.preview.count)
when (result) {
is LoTWUploadResult.Accepted -> payload.hashes.forEach { ledger[it] = "accepted" }
is LoTWUploadResult.Rejected -> payload.hashes.forEach { ledger.remove(it) }
else -> Unit
}
saveLedger(ledger)
result
} finally { payload.data.fill(0) }
}
}
override fun discardPreview() { pending?.data?.fill(0); pending = null }
private fun signingContext(): SigningContext {
val stored = storage.read("certificate") ?: fail(LoTWProblem.CERTIFICATE_MISSING)
val bundle = try { readBundle(stored) } finally { stored.fill(0) }
val passwordBytes = bundle.password ?: run {
bundle.p12.fill(0)
fail(LoTWProblem.CERTIFICATE_PASSWORD)
}
val password = decodePassword(passwordBytes)
return try {
val key = LoTWKeyMaterial.read(bundle.p12, password, now())
val tqslConfig = config()
val signer = LoTWSigner(tqslConfig)
val station = readStation() ?: fail(LoTWProblem.STATION_GRID)
SigningContext(key, signer, tqslConfig.stationFields(station, key.info.dxcc))
} finally {
bundle.p12.fill(0)
passwordBytes.fill(0)
password.fill('\u0000')
}
}
private fun readStation(): LoTWStation? = storage.read("station")?.inputStream()?.let { stream ->
DataInputStream(stream).use { input ->
require(input.readInt() == 1)
LoTWStation(input.readUTF(), input.readUTF(), input.readUTF(), input.readUTF(), input.readUTF(), input.readUTF())
}
}
private fun writeStation(station: LoTWStation) {
val bytes = ByteArrayOutputStream().also { stream -> DataOutputStream(stream).use { output ->
output.writeInt(1)
listOf(station.grid, station.cqZone, station.ituZone, station.region, station.county, station.iota).forEach(output::writeUTF)
} }.toByteArray()
try { storage.write("station", bytes) } finally { bytes.fill(0) }
}
private suspend fun post(data: ByteArray, count: Int): LoTWUploadResult = suspendCancellableCoroutine { continuation ->
val body = MultipartBody.Builder().setType(MultipartBody.FORM)
.addFormDataPart("upfile", "look4sat-${UUID.randomUUID()}.tq8", data.toRequestBody("application/octet-stream".toMediaType())).build()
val call = client.newCall(Request.Builder().url("https://lotw.arrl.org/lotw/upload").post(body).build())
continuation.invokeOnCancellation { call.cancel() }
call.enqueue(object : Callback {
override fun onFailure(call: Call, e: IOException) {
if (continuation.isActive) continuation.resume(LoTWUploadResult.Unknown)
}
override fun onResponse(call: Call, response: Response) {
val result = try {
response.use {
if (!it.isSuccessful) LoTWUploadResult.Unknown else parseLoTWUploadResponse(readBoundedBody(it, 256 * 1024), count)
}
} catch (_: Exception) { LoTWUploadResult.Unknown }
if (continuation.isActive) continuation.resume(result)
}
})
}
private fun ledger(): MutableMap<String, String> = storage.read("receipts")?.inputStream()?.let { stream ->
DataInputStream(stream).use { input ->
require(input.readInt() == 1)
val size = input.readInt().also { require(it in 0..200_000) }
val entries = linkedMapOf<String, String>()
repeat(size) { entries[input.readUTF()] = input.readUTF() }
entries
}
} ?: linkedMapOf()
private fun saveLedger(entries: Map<String, String>) {
val bytes = ByteArrayOutputStream().also { stream -> DataOutputStream(stream).use { output ->
output.writeInt(1); output.writeInt(entries.size)
entries.forEach { (hash, status) -> output.writeUTF(hash); output.writeUTF(status) }
} }.toByteArray()
storage.write("receipts", bytes)
}
}
internal fun parseLoTWUploadResponse(body: String, count: Int): LoTWUploadResult {
val markers = Regex("<!--\\s*\\.UPL\\.\\s*(accepted|rejected)\\s*-->", RegexOption.IGNORE_CASE)
.findAll(body).map { it.groupValues[1].lowercase() }.toList()
return when (markers.singleOrNull()) {
"accepted" -> LoTWUploadResult.Accepted(count)
"rejected" -> {
val message = Regex("<!--\\s*\\.UPLMESSAGE\\.\\s*(.*?)-->", setOf(RegexOption.IGNORE_CASE, RegexOption.DOT_MATCHES_ALL))
.find(body)?.groupValues?.get(1).orEmpty().replace(Regex("<[^>]*>"), " ")
.replace(Regex("[\\p{Cc}\\s]+"), " ").trim().take(500)
LoTWUploadResult.Rejected(message)
}
else -> LoTWUploadResult.Unknown
}
}
private data class StoredCertificate(val info: LoTWCertificate, val p12: ByteArray, val password: ByteArray?)
private fun encodeBundle(info: LoTWCertificate, p12: ByteArray, password: ByteArray): ByteArray = ByteArrayOutputStream().also { stream ->
DataOutputStream(stream).use { output ->
output.writeInt(2)
output.writeUTF(info.callsign); output.writeInt(info.dxcc); output.writeUTF(info.serial)
output.writeUTF(info.expires); output.writeUTF(info.firstQsoDate); output.writeUTF(info.lastQsoDate)
output.writeInt(password.size); output.write(password)
output.writeInt(p12.size); output.write(p12)
}
}.toByteArray()
private fun readBundle(bytes: ByteArray): StoredCertificate = DataInputStream(bytes.inputStream()).use { input ->
val version = input.readInt().also { require(it in 1..2) }
val info = LoTWCertificate(input.readUTF(), input.readInt(), input.readUTF(), input.readUTF(), input.readUTF(), input.readUTF())
val password = if (version >= 2) {
val size = input.readInt().also { require(it in 0..MAX_CERTIFICATE_PASSWORD_BYTES) }
ByteArray(size).also { input.readFully(it) }
} else null
val p12Size = input.readInt().also { require(it in 1..MAX_CERTIFICATE_BYTES) }
StoredCertificate(info, ByteArray(p12Size).also { input.readFully(it) }, password)
}
private fun encodePassword(password: CharArray): ByteArray {
val buffer = Charsets.UTF_8.encode(CharBuffer.wrap(password))
return ByteArray(buffer.remaining()).also(buffer::get)
}
private fun decodePassword(password: ByteArray): CharArray {
val buffer = Charsets.UTF_8.decode(ByteBuffer.wrap(password))
return CharArray(buffer.remaining()).also(buffer::get)
}
private fun LoTWStation.normalized() = LoTWStation(
grid.trim().uppercase(Locale.US),
cqZone.trim(),
ituZone.trim(),
region.trim().uppercase(Locale.US),
county.trim().uppercase(Locale.US),
iota.trim().uppercase(Locale.US)
)
private const val MAX_CERTIFICATE_PASSWORD_BYTES = 16 * 1024
@@ -0,0 +1,199 @@
/*
* 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.AdifCodec
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.sameConfirmedContact
import com.rtbishop.look4sat.core.domain.logbook.withConfirmation
import com.rtbishop.look4sat.core.domain.logbook.confirmationLookupKey
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import java.util.Locale
class QsoRepository(
private val dao: QsoDao,
private val dispatcher: CoroutineDispatcher
) : IQsoRepository {
private val importMutex = Mutex()
override val records: Flow<List<QsoRecord>> = dao.observeAll().map { records -> records.map(QsoEntity::toDomain) }
override suspend fun find(id: Long): QsoRecord? = withContext(dispatcher) { dao.find(id)?.toDomain() }
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())
}
}
override suspend fun delete(id: Long) = withContext(dispatcher) { importMutex.withLock { dao.delete(id) } }
override suspend fun exportAdi(ids: Set<Long>?, includeIncomplete: Boolean): String = withContext(dispatcher) {
val records = dao.getAll()
.asSequence()
.filter { ids == null || it.id in ids }
.map(QsoEntity::toDomain)
.filter { includeIncomplete || it.status == QsoStatus.COMPLETE }
.toList()
AdifCodec.encode(records)
}
override suspend fun importAdi(content: String): AdifImportResult = withContext(dispatcher) {
importMutex.withLock {
mergeRecords(AdifCodec.decode(content))
}
}
override suspend fun mergeConfirmed(records: List<QsoRecord>): AdifImportResult = withContext(dispatcher) {
importMutex.withLock { mergeRecords(records.filter { it.lotwConfirmed }) }
}
override suspend fun mergeLoTW(records: List<QsoRecord>): AdifImportResult = withContext(dispatcher) {
importMutex.withLock { mergeRecords(records, fromLoTW = true) }
}
private suspend fun mergeRecords(records: List<QsoRecord>, fromLoTW: Boolean = false): AdifImportResult {
val working = dao.getAll().map(QsoEntity::toDomain).toMutableList()
val lookup = working.indices.groupBy { working[it].confirmationLookupKey() }
.mapValues { it.value.toMutableList() }.toMutableMap()
val knownKeys = working.mapTo(mutableSetOf(), ::stableQsoKey)
val changes = linkedMapOf<Int, QsoRecord>()
var imported = 0
var updated = 0
var skipped = 0
records.forEach { remote ->
if (!fromLoTW && !remote.lotwConfirmed && stableQsoKey(remote) in knownKeys) { skipped++; return@forEach }
val candidates = if (fromLoTW || remote.lotwConfirmed) lookup[remote.confirmationLookupKey()].orEmpty()
.filter { sameConfirmedContact(working[it], remote) } else emptyList()
val exact = candidates.filter { working[it].startUtcMillis == remote.startUtcMillis }
val match = exact.singleOrNull() ?: candidates.singleOrNull()
if (match == null) {
if (stableQsoKey(remote) in knownKeys) { skipped++; return@forEach }
val added = remote.copy(id = 0L)
changes[working.size] = added
lookup.getOrPut(added.confirmationLookupKey()) { mutableListOf() }.add(working.size)
knownKeys += stableQsoKey(added)
working += added
imported++
} else {
val previous = working[match]
val merged = previous.withConfirmation(remote)
if (merged == previous) skipped++ else {
working[match] = merged
changes[match] = merged
updated++
}
}
}
dao.saveBatch(changes.values.map(QsoRecord::toEntity))
return AdifImportResult(imported, skipped, updated)
}
}
private fun QsoEntity.toDomain() = QsoRecord(
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 = runCatching { QsoStatus.valueOf(status) }.getOrDefault(QsoStatus.DRAFT),
propagationMode = propagationMode,
lotwConfirmed = lotwConfirmed,
lotwReceived = lotwReceived,
lotwQslDate = lotwQslDate,
vuccGrids = vuccGrids.split(',').filter(String::isNotBlank),
dxcc = dxcc,
country = country,
cqZone = cqZone,
region = region,
comment = comment
)
private fun QsoRecord.toEntity() = QsoEntity(
id = id,
startUtcMillis = startUtcMillis,
endUtcMillis = endUtcMillis,
theirCallsign = theirCallsign.trim().uppercase(Locale.US),
myCallsign = myCallsign.trim().uppercase(Locale.US),
theirGrid = theirGrid.trim().uppercase(Locale.US),
myGrid = myGrid.trim().uppercase(Locale.US),
sentReport = sentReport.trim(),
receivedReport = receivedReport.trim(),
txFrequencyHz = txFrequencyHz,
rxFrequencyHz = rxFrequencyHz,
band = band,
rxBand = rxBand,
mode = mode.trim(),
submode = submode.trim(),
satelliteName = satelliteName,
transponderName = transponderName,
satelliteMode = satelliteMode,
passAosUtcMillis = passAosUtcMillis,
automatic = automatic,
status = status.name,
dedupeKey = stableQsoKey(this),
propagationMode = propagationMode.ifBlank { if (satelliteName.isNotBlank()) "SAT" else "" },
lotwConfirmed = lotwConfirmed,
lotwReceived = lotwReceived,
lotwQslDate = lotwQslDate,
vuccGrids = vuccGrids.joinToString(","),
dxcc = dxcc,
country = country,
cqZone = cqZone,
region = region,
comment = comment
)
internal fun stableQsoKey(record: QsoRecord): String = listOf(
record.startUtcMillis.toString(),
record.theirCallsign.trim().uppercase(Locale.US),
record.myCallsign.trim().uppercase(Locale.US),
record.txFrequencyHz?.toString().orEmpty(),
record.mode.trim().uppercase(Locale.US),
record.submode.trim().uppercase(Locale.US),
record.satelliteName.trim().uppercase(Locale.US)
).joinToString("|")
@@ -877,6 +877,31 @@ class SettingsRepo(
}
//endregion
//region # Per-satellite logbook mode preset settings
private val keySatelliteModes = "satelliteModes"
override fun getSatelliteMode(catnum: Int): String {
val json = preferences.getString(keySatelliteModes, "{}") ?: "{}"
return try {
JSONObject(json).optString(catnum.toString(), "")
} catch (_: Exception) {
""
}
}
override fun setSatelliteMode(catnum: Int, mode: String) {
val json = preferences.getString(keySatelliteModes, "{}") ?: "{}"
val updated = try {
val obj = JSONObject(json)
if (mode.isBlank()) obj.remove(catnum.toString()) else obj.put(catnum.toString(), mode.uppercase())
obj.toString()
} catch (_: Exception) {
if (mode.isBlank()) "{}" else """{"$catnum": "${mode.uppercase()}"}"""
}
preferences.edit { putString(keySatelliteModes, updated) }
}
//endregion
//region # AMSAT status report settings
private val keyAmSatCallsign = "amSatCallsign"
@@ -298,6 +298,8 @@ private class FakeSettingsRepo(dataSources: DataSourcesSettings = defaultDataSou
override fun updateRadioControlSettings(settings: RadioControlSettings) = Unit
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) = Unit
@@ -343,6 +343,8 @@ private class FakeSettingsRepoForSearch : ISettingsRepo {
override fun updateDataSourcesStatus(status: Map<String, Int>) = Unit
override fun updateRadioControlSettings(settings: RadioControlSettings) = Unit
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) = Unit
override fun getAmSatCallsign(): String = ""
override fun setAmSatCallsign(callsign: String) = Unit
@@ -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()
@@ -191,6 +191,24 @@
<string name="lotw_sync_progress_qso">本次同步 %1$d 条 QSO,预计还需 %2$d 秒</string>
<string name="prefs_locator_text">使用梅登黑德网格设置站点位置</string>
<string name="prefs_logbook_title">日志本</string>
<string name="prefs_logbook_count">%1$d 条记录 — 本地通联与 LoTW 确认</string>
<string name="prefs_logbook_empty">暂无记录 — 在雷达页 Log 标签记录通联</string>
<string name="prefs_logbook_close">关闭</string>
<string name="prefs_lotw_upload_title">LoTW 上传</string>
<string name="prefs_lotw_upload_configured">证书已导入 · 台址网格 %1$s</string>
<string name="prefs_lotw_upload_not_configured">未配置 — 导入 TrustedQSL 证书后即可上传通联</string>
<string name="prefs_lotw_upload_busy">处理中…</string>
<string name="prefs_lotw_upload_cert_hint">导入你的 TrustedQSL .p12 证书(与 TQSL 同款)用于签名上传通联。密码仅加密保存在手机本地。</string>
<string name="prefs_lotw_upload_password">证书密码</string>
<string name="prefs_lotw_upload_import">选择 .p12 文件</string>
<string name="prefs_lotw_upload_cert_info">%1$s · DXCC %2$d · 有效期至 %3$s</string>
<string name="prefs_lotw_upload_remove">移除证书</string>
<string name="prefs_lotw_upload_station_title">台址</string>
<string name="prefs_lotw_upload_grid">网格(逗号分隔,可多格)</string>
<string name="prefs_lotw_upload_save">保存</string>
<string name="prefs_lotw_upload_close">关闭</string>
<string name="prefs_data_title">卫星数据更新</string>
<string name="prefs_data_entries">卫星:%s</string>
<string name="prefs_data_radios">收发器:%s</string>
@@ -222,6 +222,24 @@
<string name="lotw_sync_progress_qso">Syncing %1$d QSOs, ~%2$d s remaining</string>
<string name="prefs_locator_text">Set station\'s position using locator</string>
<string name="prefs_logbook_title">Logbook</string>
<string name="prefs_logbook_count">%1$d records — local QSOs and LoTW confirmations</string>
<string name="prefs_logbook_empty">No records yet — record QSOs from the Radar Log tab</string>
<string name="prefs_logbook_close">Close</string>
<string name="prefs_lotw_upload_title">LoTW upload</string>
<string name="prefs_lotw_upload_configured">Certificate imported · station grid %1$s</string>
<string name="prefs_lotw_upload_not_configured">Not configured — import your TrustedQSL certificate to upload QSOs</string>
<string name="prefs_lotw_upload_busy">Working…</string>
<string name="prefs_lotw_upload_cert_hint">Import your TrustedQSL .p12 certificate (same one you use in TQSL) to sign and upload QSOs. The password is stored encrypted on this device only.</string>
<string name="prefs_lotw_upload_password">Certificate password</string>
<string name="prefs_lotw_upload_import">Choose .p12 file</string>
<string name="prefs_lotw_upload_cert_info">%1$s · DXCC %2$d · expires %3$s</string>
<string name="prefs_lotw_upload_remove">Remove certificate</string>
<string name="prefs_lotw_upload_station_title">Station location</string>
<string name="prefs_lotw_upload_grid">Grid(s), comma-separated</string>
<string name="prefs_lotw_upload_save">Save</string>
<string name="prefs_lotw_upload_close">Close</string>
<string name="prefs_data_title">Satellite data</string>
<string name="prefs_data_entries">Satellites: %s</string>
<string name="prefs_data_radios">Transceivers: %s</string>