diff --git a/app/src/main/java/com/rtbishop/look4sat/MainApplication.kt b/app/src/main/java/com/rtbishop/look4sat/MainApplication.kt index 7ecf1182..8984ecb2 100644 --- a/app/src/main/java/com/rtbishop/look4sat/MainApplication.kt +++ b/app/src/main/java/com/rtbishop/look4sat/MainApplication.kt @@ -26,6 +26,7 @@ import com.rtbishop.look4sat.core.domain.repository.IContainerProvider import com.rtbishop.look4sat.core.domain.repository.IMainContainer import com.rtbishop.look4sat.core.domain.repository.LoTWSyncMode import com.rtbishop.look4sat.core.domain.repository.LoTWResult +import com.rtbishop.look4sat.core.domain.logbook.toConfirmedRecord import com.rtbishop.look4sat.core.domain.repository.applyLoTWGridResult import com.rtbishop.look4sat.core.domain.repository.lotwCursorApi import com.rtbishop.look4sat.core.domain.repository.lotwSyncToday @@ -116,6 +117,11 @@ class MainApplication : Application(), IContainerProvider { val result = container.lotwRepo.fetchConfirmedGridQsos(callsign, lotwSettings.password, since) if (result is LoTWResult.Success) { val count = applyLoTWGridResult(settingsRepo, result, mode, callsign, timeNow) + // Feed the logbook so confirmations appear and local uploads get matched. + runCatching { + val confirmed = result.qsos.values.flatten().map { it.toConfirmedRecord(callsign) } + container.qsoRepository.mergeLoTW(confirmed) + } println("Periodic LoTW grid sync finished: $count worked grids") } else { println("Periodic LoTW grid sync skipped (${result::class.simpleName})") diff --git a/core/data/src/main/assets/lotw/config.tq6 b/core/data/src/main/assets/lotw/config.tq6 new file mode 100644 index 00000000..04fdfe4c Binary files /dev/null and b/core/data/src/main/assets/lotw/config.tq6 differ diff --git a/core/data/src/main/java/com/rtbishop/look4sat/core/data/database/QsoDao.kt b/core/data/src/main/java/com/rtbishop/look4sat/core/data/database/QsoDao.kt new file mode 100644 index 00000000..4e833eea --- /dev/null +++ b/core/data/src/main/java/com/rtbishop/look4sat/core/data/database/QsoDao.kt @@ -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> + + @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 + + @Query("SELECT dedupeKey FROM qso_records") + suspend fun getDedupeKeys(): List + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun save(record: QsoEntity): Long + + @Insert(onConflict = OnConflictStrategy.IGNORE) + suspend fun importRecords(records: List): List + + @Transaction + suspend fun saveBatch(records: List) { + records.forEach { save(it) } + } + + @Query("DELETE FROM qso_records WHERE id = :id") + suspend fun delete(id: Long) +} diff --git a/core/data/src/main/java/com/rtbishop/look4sat/core/data/database/QsoDatabase.kt b/core/data/src/main/java/com/rtbishop/look4sat/core/data/database/QsoDatabase.kt new file mode 100644 index 00000000..7708e6f6 --- /dev/null +++ b/core/data/src/main/java/com/rtbishop/look4sat/core/data/database/QsoDatabase.kt @@ -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 +} diff --git a/core/data/src/main/java/com/rtbishop/look4sat/core/data/database/entity/QsoEntity.kt b/core/data/src/main/java/com/rtbishop/look4sat/core/data/database/entity/QsoEntity.kt new file mode 100644 index 00000000..f534d094 --- /dev/null +++ b/core/data/src/main/java/com/rtbishop/look4sat/core/data/database/entity/QsoEntity.kt @@ -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 = "" +) diff --git a/core/data/src/main/java/com/rtbishop/look4sat/core/data/injection/MainContainer.kt b/core/data/src/main/java/com/rtbishop/look4sat/core/data/injection/MainContainer.kt index 1a9454e9..343e917c 100644 --- a/core/data/src/main/java/com/rtbishop/look4sat/core/data/injection/MainContainer.kt +++ b/core/data/src/main/java/com/rtbishop/look4sat/core/data/injection/MainContainer.kt @@ -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) diff --git a/core/data/src/main/java/com/rtbishop/look4sat/core/data/lotw/LoTWConfig.kt b/core/data/src/main/java/com/rtbishop/look4sat/core/data/lotw/LoTWConfig.kt new file mode 100644 index 00000000..1f9f0e10 --- /dev/null +++ b/core/data/src/main/java/com/rtbishop/look4sat/core/data/lotw/LoTWConfig.kt @@ -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(" 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 { + 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 = getElementsByTagName(tag).let { nodes -> + (0 until nodes.length).map { nodes.item(it) as Element } +} +private fun Element.childElements(): List = (0 until childNodes.length).mapNotNull { childNodes.item(it) as? Element } diff --git a/core/data/src/main/java/com/rtbishop/look4sat/core/data/lotw/LoTWKeyMaterial.kt b/core/data/src/main/java/com/rtbishop/look4sat/core/data/lotw/LoTWKeyMaterial.kt new file mode 100644 index 00000000..e89503ce --- /dev/null +++ b/core/data/src/main/java/com/rtbishop/look4sat/core/data/lotw/LoTWKeyMaterial.kt @@ -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 = 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 { + val result = mutableListOf() + 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 + } + } +} diff --git a/core/data/src/main/java/com/rtbishop/look4sat/core/data/lotw/LoTWSigner.kt b/core/data/src/main/java/com/rtbishop/look4sat/core/data/lotw/LoTWSigner.kt new file mode 100644 index 00000000..5e8fd9de --- /dev/null +++ b/core/data/src/main/java/com/rtbishop/look4sat/core/data/lotw/LoTWSigner.kt @@ -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, val signData: String, val fingerprint: String) + +internal class LoTWSigner(private val config: LoTWConfig) { + fun contact(record: QsoRecord, key: LoTWKeyMaterial, station: Map, 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, key: LoTWKeyMaterial, station: Map): 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("\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("\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("$encoded\n") + append(field("SIGNDATA", contact.signData)); append("\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" diff --git a/core/data/src/main/java/com/rtbishop/look4sat/core/data/lotw/LoTWStorage.kt b/core/data/src/main/java/com/rtbishop/look4sat/core/data/lotw/LoTWStorage.kt new file mode 100644 index 00000000..676e24e2 --- /dev/null +++ b/core/data/src/main/java/com/rtbishop/look4sat/core/data/lotw/LoTWStorage.kt @@ -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" } +} diff --git a/core/data/src/main/java/com/rtbishop/look4sat/core/data/lotw/LoTWUploadRepository.kt b/core/data/src/main/java/com/rtbishop/look4sat/core/data/lotw/LoTWUploadRepository.kt new file mode 100644 index 00000000..742b4a49 --- /dev/null +++ b/core/data/src/main/java/com/rtbishop/look4sat/core/data/lotw/LoTWUploadRepository.kt @@ -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, val created: Long) + private data class SigningContext(val key: LoTWKeyMaterial, val signer: LoTWSigner, val location: Map) + + 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): LoTWUploadAudit = withContext(Dispatchers.IO) { + mutex.withLock { + val signing = signingContext() + val ledger = ledger() + val unique = hashSetOf() + 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, 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() + 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 = 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() + repeat(size) { entries[input.readUTF()] = input.readUTF() } + entries + } + } ?: linkedMapOf() + + private fun saveLedger(entries: Map) { + 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("", RegexOption.IGNORE_CASE) + .findAll(body).map { it.groupValues[1].lowercase() }.toList() + return when (markers.singleOrNull()) { + "accepted" -> LoTWUploadResult.Accepted(count) + "rejected" -> { + val message = Regex("", 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 diff --git a/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/QsoRepository.kt b/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/QsoRepository.kt new file mode 100644 index 00000000..0dd869c8 --- /dev/null +++ b/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/QsoRepository.kt @@ -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> = 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?, 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): AdifImportResult = withContext(dispatcher) { + importMutex.withLock { mergeRecords(records.filter { it.lotwConfirmed }) } + } + + override suspend fun mergeLoTW(records: List): AdifImportResult = withContext(dispatcher) { + importMutex.withLock { mergeRecords(records, fromLoTW = true) } + } + + private suspend fun mergeRecords(records: List, 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() + 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("|") diff --git a/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/SettingsRepo.kt b/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/SettingsRepo.kt index 095f8b28..930282db 100644 --- a/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/SettingsRepo.kt +++ b/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/SettingsRepo.kt @@ -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" diff --git a/core/data/src/test/java/com/rtbishop/look4sat/core/data/repository/DatabaseRepoTest.kt b/core/data/src/test/java/com/rtbishop/look4sat/core/data/repository/DatabaseRepoTest.kt index 75c87b7a..a3ae662d 100644 --- a/core/data/src/test/java/com/rtbishop/look4sat/core/data/repository/DatabaseRepoTest.kt +++ b/core/data/src/test/java/com/rtbishop/look4sat/core/data/repository/DatabaseRepoTest.kt @@ -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 diff --git a/core/data/src/test/java/com/rtbishop/look4sat/core/data/repository/SelectionRepoSearchTest.kt b/core/data/src/test/java/com/rtbishop/look4sat/core/data/repository/SelectionRepoSearchTest.kt index 6a214d35..2474c62d 100644 --- a/core/data/src/test/java/com/rtbishop/look4sat/core/data/repository/SelectionRepoSearchTest.kt +++ b/core/data/src/test/java/com/rtbishop/look4sat/core/data/repository/SelectionRepoSearchTest.kt @@ -343,6 +343,8 @@ private class FakeSettingsRepoForSearch : ISettingsRepo { override fun updateDataSourcesStatus(status: Map) = 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 diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/logbook/AdifCodec.kt b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/logbook/AdifCodec.kt new file mode 100644 index 00000000..4c1620f5 --- /dev/null +++ b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/logbook/AdifCodec.kt @@ -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): String = buildString { + append(field("ADIF_VER", "3.1.7")) + append(field("PROGRAMID", "Look4Sat")) + append("\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("\r\n") + } + } + + fun decode(content: String): List = decodeRecords(splitRecords(content)) + + fun decodeRecords(records: List>): List = 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> { + val records = mutableListOf>() + var values = linkedMapOf() + 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() +} diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/logbook/IQsoRepository.kt b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/logbook/IQsoRepository.kt new file mode 100644 index 00000000..e6904205 --- /dev/null +++ b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/logbook/IQsoRepository.kt @@ -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> + + suspend fun find(id: Long): QsoRecord? + suspend fun save(record: QsoRecord): Long + suspend fun delete(id: Long) + suspend fun exportAdi(ids: Set? = null, includeIncomplete: Boolean = false): String + suspend fun importAdi(content: String): AdifImportResult + suspend fun mergeConfirmed(records: List): AdifImportResult + suspend fun mergeLoTW(records: List): AdifImportResult +} diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/logbook/LoTWReportBridge.kt b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/logbook/LoTWReportBridge.kt new file mode 100644 index 00000000..2ecbbe1a --- /dev/null +++ b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/logbook/LoTWReportBridge.kt @@ -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() +) diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/logbook/QsoConfirmationMerge.kt b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/logbook/QsoConfirmationMerge.kt new file mode 100644 index 00000000..f4dd59ba --- /dev/null +++ b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/logbook/QsoConfirmationMerge.kt @@ -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 -> "" +} diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/logbook/QsoModels.kt b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/logbook/QsoModels.kt new file mode 100644 index 00000000..2c64504d --- /dev/null +++ b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/logbook/QsoModels.kt @@ -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 = 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) diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/ILoTWUploadRepository.kt b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/ILoTWUploadRepository.kt new file mode 100644 index 00000000..230fb8ca --- /dev/null +++ b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/ILoTWUploadRepository.kt @@ -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): LoTWUploadAudit + suspend fun prepare(records: List, 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, + 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 +} diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/IMainContainer.kt b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/IMainContainer.kt index 4e5d0a87..4205dad8 100644 --- a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/IMainContainer.kt +++ b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/IMainContainer.kt @@ -16,6 +16,7 @@ * along with this program. If not, see . */ 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 fun setMutualPassData(data: MutualPassData) diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/ISettingsRepo.kt b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/ISettingsRepo.kt index cc80662a..4d2388da 100644 --- a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/ISettingsRepo.kt +++ b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/ISettingsRepo.kt @@ -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) diff --git a/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/logbook/AdifCodecTest.kt b/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/logbook/AdifCodecTest.kt new file mode 100644 index 00000000..05a824b7 --- /dev/null +++ b/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/logbook/AdifCodecTest.kt @@ -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 = """ + + BH6RJD + 20260916 + 051300 + FM + SO-50 + SAT + 2M + 70CM + OM60 + OM60IL70 + OM60,OM50 + BH6RJD + 318 + China + Y + + """.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) + } +} diff --git a/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/logbook/LogbookMergeTest.kt b/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/logbook/LogbookMergeTest.kt new file mode 100644 index 00000000..7338d200 --- /dev/null +++ b/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/logbook/LogbookMergeTest.kt @@ -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) + } +} diff --git a/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/repository/LoTWGridSyncTest.kt b/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/repository/LoTWGridSyncTest.kt index 78da21e5..4edb0409 100644 --- a/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/repository/LoTWGridSyncTest.kt +++ b/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/repository/LoTWGridSyncTest.kt @@ -285,6 +285,8 @@ class LoTWGridSyncTest { override fun updateDataSourcesStatus(status: Map) = 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() diff --git a/core/presentation/src/main/res/values-zh/strings.xml b/core/presentation/src/main/res/values-zh/strings.xml index 7ccb8cb7..49097479 100644 --- a/core/presentation/src/main/res/values-zh/strings.xml +++ b/core/presentation/src/main/res/values-zh/strings.xml @@ -191,6 +191,24 @@ 本次同步 %1$d 条 QSO,预计还需 %2$d 秒 使用梅登黑德网格设置站点位置 + 日志本 + %1$d 条记录 — 本地通联与 LoTW 确认 + 暂无记录 — 在雷达页 Log 标签记录通联 + 关闭 + LoTW 上传 + 证书已导入 · 台址网格 %1$s + 未配置 — 导入 TrustedQSL 证书后即可上传通联 + 处理中… + 导入你的 TrustedQSL .p12 证书(与 TQSL 同款)用于签名上传通联。密码仅加密保存在手机本地。 + 证书密码 + 选择 .p12 文件 + %1$s · DXCC %2$d · 有效期至 %3$s + 移除证书 + 台址 + 网格(逗号分隔,可多格) + 保存 + 关闭 + 卫星数据更新 卫星:%s 收发器:%s diff --git a/core/presentation/src/main/res/values/strings.xml b/core/presentation/src/main/res/values/strings.xml index daeba1ab..ca8c90be 100644 --- a/core/presentation/src/main/res/values/strings.xml +++ b/core/presentation/src/main/res/values/strings.xml @@ -222,6 +222,24 @@ Syncing %1$d QSOs, ~%2$d s remaining Set station\'s position using locator + Logbook + %1$d records — local QSOs and LoTW confirmations + No records yet — record QSOs from the Radar Log tab + Close + LoTW upload + Certificate imported · station grid %1$s + Not configured — import your TrustedQSL certificate to upload QSOs + Working… + 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. + Certificate password + Choose .p12 file + %1$s · DXCC %2$d · expires %3$s + Remove certificate + Station location + Grid(s), comma-separated + Save + Close + Satellite data Satellites: %s Transceivers: %s diff --git a/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/LogPage.kt b/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/LogPage.kt new file mode 100644 index 00000000..2926360e --- /dev/null +++ b/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/LogPage.kt @@ -0,0 +1,281 @@ +/* + * 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.feature.radar + +import android.content.Intent +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.FilterChip +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.rtbishop.look4sat.core.domain.logbook.QsoRecord +import com.rtbishop.look4sat.core.domain.logbook.displayMode +import com.rtbishop.look4sat.core.domain.utility.DopplerFrequencyCalculator +import com.rtbishop.look4sat.core.presentation.EmptyListCard + +private const val LOG_WINDOW_MS = 24 * 3_600_000L + +@Composable +fun LogPage( + uiState: RadarState, + logViewModel: LogViewModel, + modifier: Modifier = Modifier +) { + val logUiState by logViewModel.uiState.collectAsStateWithLifecycle() + val records by logViewModel.records.collectAsStateWithLifecycle(initialValue = emptyList()) + + val selectedRadio = remember(uiState.transceivers.transmitters, uiState.transceivers.selectedUuid) { + uiState.transceivers.transmitters.firstOrNull { it.uuid == uiState.transceivers.selectedUuid } + } + val isLinear = selectedRadio != null && + DopplerFrequencyCalculator.isNamedLinearTransponder(selectedRadio) + val catnum = uiState.currentPass?.catNum ?: selectedRadio?.catnum ?: 0 + val satName = uiState.currentPass?.name?.trim().orEmpty() + + LaunchedEffect(catnum) { + if (catnum != 0) logViewModel.selectSatellite(catnum) + } + + val txHz = remember(selectedRadio) { + selectedRadio?.uplinkLow?.let { low -> + selectedRadio.uplinkHigh?.let { high -> (low + high) / 2 } ?: low + } + } + val rxHz = remember(selectedRadio) { + selectedRadio?.downlinkLow?.let { low -> + selectedRadio.downlinkHigh?.let { high -> (low + high) / 2 } ?: low + } + } + val mode = if (isLinear) logUiState.selectedMode.ifBlank { "CW" } else "FM" + val now = System.currentTimeMillis() + val recent = records + .filter { it.satelliteName.trim().equals(satName, true) && it.startUtcMillis > now - LOG_WINDOW_MS } + .sortedByDescending { it.startUtcMillis } + val maxElev = uiState.currentPass?.maxElevation ?: 0.0 + + Column(modifier = modifier.fillMaxSize().padding(8.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) { + Row(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalAlignment = Alignment.CenterVertically) { + OutlinedTextField( + value = logUiState.callsignInput, + onValueChange = logViewModel::updateCallsign, + modifier = Modifier.weight(1f), + placeholder = { Text("Callsign", fontSize = 14.sp) }, + singleLine = true, + keyboardOptions = androidx.compose.foundation.text.KeyboardOptions( + capitalization = KeyboardCapitalization.Characters, + imeAction = ImeAction.Done + ), + keyboardActions = androidx.compose.foundation.text.KeyboardActions(onDone = { + logViewModel.record(satName, mode, txHz, rxHz, logUiState.stationGrid) + }) + ) + Button( + onClick = { logViewModel.record(satName, mode, txHz, rxHz, logUiState.stationGrid) }, + enabled = logUiState.callsignInput.isNotBlank() && satName.isNotBlank() + ) { Text("Log") } + } + if (isLinear) { + Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { + listOf("CW", "SSB", "FT4").forEach { candidate -> + FilterChip( + selected = mode == candidate, + onClick = { logViewModel.selectMode(catnum, candidate) }, + label = { Text(candidate, fontSize = 13.sp) } + ) + } + } + } else { + Text("FM", style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + + if (recent.isEmpty()) { + EmptyListCard(message = "No QSOs yet — type a callsign and tap Log") + } else { + LazyColumn(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { + items(recent, key = { it.id }) { record -> + LogRecordRow(record, onDelete = { logViewModel.delete(record.id) }) + } + } + } + + Row(horizontalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.fillMaxWidth()) { + OutlinedButton( + onClick = { logViewModel.generatePost(satName, maxElev) }, + enabled = recent.isNotEmpty(), + modifier = Modifier.weight(1f) + ) { Text("生成通联记录", fontSize = 13.sp) } + OutlinedButton( + onClick = logViewModel::prepareUpload, + enabled = !logUiState.busy, + modifier = Modifier.weight(1f) + ) { Text("上传 LoTW", fontSize = 13.sp) } + } + if (logUiState.busy) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + CircularProgressIndicator(modifier = Modifier.padding(4.dp), strokeWidth = 2.dp) + Text("Working…", fontSize = 13.sp) + } + } + } + + logUiState.postText?.let { text -> + val clipboard = LocalClipboardManager.current + val context = LocalContext.current + PostDialog( + text = text, + onDismiss = logViewModel::dismissPost, + onCopy = { + clipboard.setText(AnnotatedString(text)) + logViewModel.dismissPost() + }, + onShare = { + val send = Intent(Intent.ACTION_SEND).apply { + type = "text/plain" + putExtra(Intent.EXTRA_TEXT, text) + } + context.startActivity(Intent.createChooser(send, "Share QSO log")) + logViewModel.dismissPost() + } + ) + } + + logUiState.preview?.let { preview -> + UploadPreviewDialog( + preview = preview, + busy = logUiState.busy, + onConfirm = logViewModel::confirmUpload, + onDismiss = logViewModel::dismissPreview + ) + } + + if (logUiState.message.isNotBlank()) { + AlertDialog( + onDismissRequest = logViewModel::clearMessage, + title = { Text("LoTW Upload") }, + text = { Text(logUiState.message) }, + confirmButton = { + TextButton(onClick = logViewModel::clearMessage) { Text("OK") } + } + ) + } +} + +@Composable +private fun LogRecordRow(record: QsoRecord, onDelete: () -> Unit) { + val time = remember(record.startUtcMillis) { + java.text.SimpleDateFormat("HH:mm'Z'", java.util.Locale.US).apply { + timeZone = java.util.TimeZone.getTimeZone("UTC") + }.format(java.util.Date(record.startUtcMillis)) + } + Row( + modifier = Modifier.fillMaxWidth().clickable { onDelete() }.padding(horizontal = 4.dp, vertical = 2.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "$time ${record.theirCallsign} ${record.displayMode}", + fontSize = 14.sp, + maxLines = 1 + ) + Text(text = if (record.lotwConfirmed) "✓" else "", fontSize = 13.sp, color = MaterialTheme.colorScheme.primary) + } +} + +@Composable +private fun PostDialog( + text: String, + onDismiss: () -> Unit, + onCopy: () -> Unit, + onShare: () -> Unit +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("通联记录") }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + Text(text, fontSize = 13.sp) + Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { + TextButton(onClick = onCopy) { Text("Copy") } + TextButton(onClick = onShare) { Text("Share") } + } + } + }, + confirmButton = { TextButton(onClick = onDismiss) { Text("Close") } } + ) +} + +@Composable +private fun UploadPreviewDialog( + preview: com.rtbishop.look4sat.core.domain.repository.LoTWUploadPreview, + busy: Boolean, + onConfirm: () -> Unit, + onDismiss: () -> Unit +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("上传到 LoTW") }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text("${preview.callsign} DXCC ${preview.dxcc} Grid ${preview.grid}", fontSize = 13.sp) + Text("${preview.count} QSO(s) · ${ + java.text.SimpleDateFormat("MM-dd HH:mm", java.util.Locale.US).apply { + timeZone = java.util.TimeZone.getTimeZone("UTC") + }.format(java.util.Date(preview.firstUtc.let { + runCatching { java.text.SimpleDateFormat("yyyy-MM-dd HH:mm:ss", java.util.Locale.US).apply { + timeZone = java.util.TimeZone.getTimeZone("UTC") + }.parse(it).time }.getOrDefault(System.currentTimeMillis()) + })) + } – ${ + java.text.SimpleDateFormat("MM-dd HH:mm", java.util.Locale.US).apply { + timeZone = java.util.TimeZone.getTimeZone("UTC") + }.format(java.util.Date(preview.lastUtc.let { + runCatching { java.text.SimpleDateFormat("yyyy-MM-dd HH:mm:ss", java.util.Locale.US).apply { + timeZone = java.util.TimeZone.getTimeZone("UTC") + }.parse(it).time }.getOrDefault(System.currentTimeMillis()) + })) + }Z", fontSize = 13.sp) + Text(preview.contacts.joinToString("\n") { it }, fontSize = 12.sp, maxLines = 8) + } + }, + confirmButton = { + TextButton(onClick = onConfirm, enabled = !busy) { Text("确认上传") } + }, + dismissButton = { + TextButton(onClick = onDismiss) { Text("Cancel") } + } + ) +} diff --git a/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/LogViewModel.kt b/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/LogViewModel.kt new file mode 100644 index 00000000..2f30e039 --- /dev/null +++ b/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/LogViewModel.kt @@ -0,0 +1,198 @@ +/* + * 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.feature.radar + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import androidx.lifecycle.viewmodel.initializer +import androidx.lifecycle.viewmodel.viewModelFactory +import com.rtbishop.look4sat.core.domain.logbook.IQsoRepository +import com.rtbishop.look4sat.core.domain.logbook.QsoRecord +import com.rtbishop.look4sat.core.domain.logbook.QsoStatus +import com.rtbishop.look4sat.core.domain.logbook.displayMode +import com.rtbishop.look4sat.core.domain.logbook.frequencyBand +import com.rtbishop.look4sat.core.domain.repository.ILoTWUploadRepository +import com.rtbishop.look4sat.core.domain.repository.IMainContainer +import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo +import com.rtbishop.look4sat.core.domain.repository.LoTWOperationException +import com.rtbishop.look4sat.core.domain.repository.LoTWUploadPreview +import com.rtbishop.look4sat.core.domain.repository.LoTWUploadResult +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale +import java.util.TimeZone + +data class LogUiState( + val callsignInput: String = "", + /** Current logbook mode for the selected satellite (FM/CW/SSB/FT4). */ + val selectedMode: String = "", + /** Station callsign from the imported LoTW certificate ("" when none). */ + val certificateCallsign: String = "", + /** Primary grid of the LoTW upload station location ("" when none). */ + val stationGrid: String = "", + val preview: LoTWUploadPreview? = null, + /** User-facing upload / record message ("" when none). */ + val message: String = "", + val postText: String? = null, + val busy: Boolean = false +) + +class LogViewModel( + private val qsoRepository: IQsoRepository, + private val lotwUploadRepository: ILoTWUploadRepository, + private val settingsRepo: ISettingsRepo +) : ViewModel() { + + val records: Flow> = qsoRepository.records + + private val _uiState = MutableStateFlow(LogUiState()) + val uiState: StateFlow = _uiState + + init { + viewModelScope.launch { + val cert = lotwUploadRepository.certificate() + val station = lotwUploadRepository.station() + _uiState.update { + it.copy( + certificateCallsign = cert?.callsign.orEmpty(), + stationGrid = station?.grid.orEmpty() + ) + } + } + } + + fun updateCallsign(value: String) = + _uiState.update { it.copy(callsignInput = value.uppercase(Locale.US)) } + + /** Load the per-satellite mode preset when the selected satellite changes. */ + fun selectSatellite(catnum: Int) { + val preset = settingsRepo.getSatelliteMode(catnum) + _uiState.update { it.copy(selectedMode = preset) } + } + + fun selectMode(catnum: Int, mode: String) { + _uiState.update { it.copy(selectedMode = mode) } + settingsRepo.setSatelliteMode(catnum, mode) + } + + fun record(satName: String, mode: String, txHz: Long?, rxHz: Long?, myGrid: String) { + val call = _uiState.value.callsignInput.trim() + if (call.isEmpty()) return + val normalizedMode = mode.ifBlank { "FM" }.uppercase(Locale.US) + val now = System.currentTimeMillis() + val record = QsoRecord( + startUtcMillis = now, + endUtcMillis = now, + theirCallsign = call, + myCallsign = _uiState.value.certificateCallsign, + myGrid = myGrid.take(6).uppercase(Locale.US), + txFrequencyHz = txHz, + rxFrequencyHz = rxHz, + band = frequencyBand(txHz), + rxBand = frequencyBand(rxHz), + mode = if (normalizedMode == "FT4") "MFSK" else normalizedMode, + submode = normalizedMode.takeIf { it == "FT4" }.orEmpty(), + satelliteName = satName, + satelliteMode = normalizedMode, + status = QsoStatus.COMPLETE, + propagationMode = "SAT" + ) + viewModelScope.launch { qsoRepository.save(record) } + _uiState.update { it.copy(callsignInput = "") } + } + + fun delete(id: Long) = viewModelScope.launch { qsoRepository.delete(id) } + + /** English social post from the recorded QSOs of the current satellite (last 24h). */ + fun generatePost(satName: String, maxElev: Double) { + viewModelScope.launch { + val now = System.currentTimeMillis() + val list = qsoRepository.records.first() + .filter { it.satelliteName.trim().equals(satName.trim(), true) } + .filter { it.startUtcMillis > now - 24 * 3_600_000L } + .sortedBy { it.startUtcMillis } + if (list.isEmpty()) return@launch + val shortName = satName.substringBefore('(').trim().uppercase(Locale.US) + val utc = SimpleDateFormat("yyyyMMdd|HH:mm'Z'", Locale.US).apply { + timeZone = TimeZone.getTimeZone("UTC") + } + val firstLine = "🛰️ $shortName | ${utc.format(Date(list.first().startUtcMillis))}" + val elLine = "🔺 EL ${String.format(Locale.US, "%.1f", maxElev)}° | ${list.size} QSOs" + val modeLines = list.groupBy { it.displayMode } + .map { (mode, qsos) -> "📡 $mode: ${qsos.joinToString(" ") { it.theirCallsign }}" } + .joinToString("\n") + val currentGrid = settingsRepo.stationPosition.value.qthLocator.take(6) + val logLine = "📍 Log: ${_uiState.value.stationGrid.take(4)} | Current: $currentGrid" + val viaLine = "via Look4Sat — TNX de ${_uiState.value.certificateCallsign}" + val tag = "#HamRadio #SatelliteQSO #" + shortName.filter { it.isLetterOrDigit() } + val text = listOf(firstLine, elLine, modeLines, "", logLine, viaLine, tag).joinToString("\n") + _uiState.update { it.copy(postText = text) } + } + } + + fun dismissPost() = _uiState.update { it.copy(postText = null) } + + fun prepareUpload() { + if (_uiState.value.busy) return + viewModelScope.launch { + _uiState.update { it.copy(busy = true, message = "") } + try { + val all = qsoRepository.records.first() + // Only local (non-confirmed) records are candidates for upload; + // LoTW-imported confirmations are the feedback side. + val pending = all.filter { !it.lotwConfirmed && it.status == QsoStatus.COMPLETE } + val audit = lotwUploadRepository.audit(pending) + if (audit.pending == 0) { + _uiState.update { it.copy(busy = false, message = "No pending QSOs to upload") } + return@launch + } + val preview = lotwUploadRepository.prepare(pending, false) + _uiState.update { it.copy(busy = false, preview = preview) } + } catch (e: LoTWOperationException) { + _uiState.update { it.copy(busy = false, message = "Upload unavailable: ${e.reason}") } + } catch (_: Exception) { + _uiState.update { it.copy(busy = false, message = "Upload failed") } + } + } + } + + fun confirmUpload() { + val preview = _uiState.value.preview ?: return + viewModelScope.launch { + _uiState.update { it.copy(busy = true) } + val result = lotwUploadRepository.upload(preview.id) + val msg = when (result) { + is LoTWUploadResult.Accepted -> "Uploaded ${result.count} QSO(s) — accepted by LoTW" + is LoTWUploadResult.Rejected -> "Rejected: ${result.message}" + LoTWUploadResult.Unknown -> "Unknown result — will not auto-retry" + LoTWUploadResult.ExpiredPreview -> "Preview expired — tap upload again" + } + _uiState.update { it.copy(busy = false, preview = null, message = msg) } + } + } + + fun dismissPreview() = _uiState.update { it.copy(preview = null) } + + fun clearMessage() = _uiState.update { it.copy(message = "") } + + companion object { + fun factory(container: IMainContainer) = viewModelFactory { + initializer { + LogViewModel(container.qsoRepository, container.lotwUploadRepository, container.settingsRepo) + } + } + } +} diff --git a/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/RadarScreen.kt b/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/RadarScreen.kt index 74164712..84cf4ecb 100644 --- a/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/RadarScreen.kt +++ b/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/RadarScreen.kt @@ -83,6 +83,7 @@ import kotlin.math.PI private enum class RadarPage(val title: String) { Transceivers("Transceivers"), Calculator("Calculator"), + Log("Log"), Sstv("SSTV") } @@ -91,6 +92,7 @@ fun RadarDestination(navigateUp: () -> Unit) { val context = LocalContext.current val container = (context.applicationContext as IContainerProvider).getMainContainer() val viewModel: RadarViewModel = viewModel(factory = RadarViewModel.factory(container)) + val logViewModel: LogViewModel = viewModel(factory = LogViewModel.factory(container)) val uiState by viewModel.uiState.collectAsStateWithLifecycle() val mutualData by container.mutualPassData.collectAsStateWithLifecycle() val navigateUpAndClearMutual = { @@ -127,7 +129,7 @@ fun RadarDestination(navigateUp: () -> Unit) { viewModel.onAction(RadarAction.SstvPermissionResult(granted)) viewModel.onAction(RadarAction.CwPermissionResult(granted)) } - RadarScreen(uiState, viewModel::onAction, navigateUpAndClearMutual, mutualData, requestMicPermission = { + RadarScreen(uiState, viewModel::onAction, navigateUpAndClearMutual, mutualData, logViewModel, requestMicPermission = { permissionLauncher.launch(Manifest.permission.RECORD_AUDIO) }) } @@ -138,6 +140,7 @@ private fun RadarScreen( onAction: (RadarAction) -> Unit, navigateUp: () -> Unit, mutualData: MutualPassData, + logViewModel: LogViewModel, requestMicPermission: () -> Unit ) { val upcomingPass = uiState.currentPass ?: getDefaultPass() @@ -194,11 +197,11 @@ private fun RadarScreen( } if (isVertical) { RadarCard(uiState, trackB, trackBPosition, Modifier.weight(1f)) - PagerCard(uiState, onAction, requestMicPermission, Modifier.weight(1f)) + PagerCard(uiState, onAction, logViewModel, requestMicPermission, Modifier.weight(1f)) } else { Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { RadarCard(uiState, trackB, trackBPosition, Modifier.weight(1f)) - PagerCard(uiState, onAction, requestMicPermission, Modifier.weight(1f)) + PagerCard(uiState, onAction, logViewModel, requestMicPermission, Modifier.weight(1f)) } } } @@ -208,6 +211,7 @@ private fun RadarScreen( private fun PagerCard( uiState: RadarState, onAction: (RadarAction) -> Unit, + logViewModel: LogViewModel, requestMicPermission: () -> Unit, modifier: Modifier = Modifier ) { @@ -218,6 +222,7 @@ private fun PagerCard( buildList { add(RadarPage.Transceivers) if (hasCalculatorPage) add(RadarPage.Calculator) + add(RadarPage.Log) add(RadarPage.Sstv) } } @@ -261,6 +266,10 @@ private fun PagerCard( onAction = onAction, requestMicPermission = requestMicPermission ) + RadarPage.Log -> LogPage( + uiState = uiState, + logViewModel = logViewModel + ) RadarPage.Sstv -> SstvPage( sstv = uiState.sstv, dopplerFrequency = uiState.transceivers.selectedFrequency?.let { formatFrequency(it) }, diff --git a/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/LoTWUploadConfigDialog.kt b/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/LoTWUploadConfigDialog.kt new file mode 100644 index 00000000..d76e0e7e --- /dev/null +++ b/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/LoTWUploadConfigDialog.kt @@ -0,0 +1,178 @@ +/* + * 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.feature.settings + +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ElevatedCard +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.rtbishop.look4sat.core.domain.repository.LoTWCertificate +import com.rtbishop.look4sat.core.domain.repository.LoTWStation +import com.rtbishop.look4sat.core.presentation.R + +@Composable +fun LoTWUploadCard( + hasCertificate: Boolean, + stationGrid: String, + showUploadConfigDialog: () -> Unit +) { + ElevatedCard(modifier = Modifier.fillMaxWidth().clickable { showUploadConfigDialog() }) { + Column(modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)) { + Text( + text = stringResource(R.string.prefs_lotw_upload_title), + color = MaterialTheme.colorScheme.primary + ) + Spacer(modifier = Modifier.height(2.dp)) + Text( + text = if (hasCertificate) { + stringResource(R.string.prefs_lotw_upload_configured, stationGrid) + } else { + stringResource(R.string.prefs_lotw_upload_not_configured) + }, + style = MaterialTheme.typography.bodySmall, + maxLines = 2 + ) + } + } +} + +@Composable +fun LoTWUploadConfigDialog( + certificate: LoTWCertificate?, + station: LoTWStation?, + busy: Boolean, + onDismiss: () -> Unit, + onImport: (ByteArray, CharArray) -> Unit, + onRemove: () -> Unit, + onSaveStation: (LoTWStation) -> Unit +) { + var password by remember { mutableStateOf("") } + var grid by remember { mutableStateOf(station?.grid.orEmpty()) } + var cqZone by remember { mutableStateOf(station?.cqZone.orEmpty()) } + var ituZone by remember { mutableStateOf(station?.ituZone.orEmpty()) } + var iota by remember { mutableStateOf(station?.iota.orEmpty()) } + val context = LocalContext.current + + val filePicker = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri -> + if (uri != null && password.isNotBlank()) { + val bytes = runCatching { + context.contentResolver.openInputStream(uri)?.use { it.readBytes() } + }.getOrNull() + if (bytes != null && bytes.isNotEmpty()) { + onImport(bytes, password.toCharArray()) + password = "" + } + } + } + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.prefs_lotw_upload_title)) }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + if (busy) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + CircularProgressIndicator(modifier = Modifier.height(20.dp), strokeWidth = 2.dp) + Text(stringResource(R.string.prefs_lotw_upload_busy), fontSize = 13.sp) + } + } + if (certificate == null) { + Text(stringResource(R.string.prefs_lotw_upload_cert_hint), fontSize = 13.sp) + OutlinedTextField( + value = password, + onValueChange = { password = it }, + label = { Text(stringResource(R.string.prefs_lotw_upload_password), fontSize = 13.sp) }, + singleLine = true, + visualTransformation = PasswordVisualTransformation(), + modifier = Modifier.fillMaxWidth() + ) + Button( + onClick = { filePicker.launch(arrayOf("*/*")) }, + enabled = password.isNotBlank() && !busy, + modifier = Modifier.fillMaxWidth() + ) { Text(stringResource(R.string.prefs_lotw_upload_import), fontSize = 13.sp) } + } else { + Text(stringResource(R.string.prefs_lotw_upload_cert_info, certificate.callsign, certificate.dxcc, certificate.expires), fontSize = 13.sp) + OutlinedButton(onClick = onRemove, enabled = !busy) { + Text(stringResource(R.string.prefs_lotw_upload_remove), fontSize = 13.sp) + } + } + Text(stringResource(R.string.prefs_lotw_upload_station_title), style = MaterialTheme.typography.titleSmall) + // Grid field accepts a comma-separated multi-grid set (e.g. "OL62,OL72"). + OutlinedTextField( + value = grid, + onValueChange = { grid = it.uppercase() }, + label = { Text(stringResource(R.string.prefs_lotw_upload_grid), fontSize = 13.sp) }, + singleLine = true, + keyboardOptions = androidx.compose.foundation.text.KeyboardOptions(capitalization = KeyboardCapitalization.Characters), + modifier = Modifier.fillMaxWidth() + ) + Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { + OutlinedTextField( + value = cqZone, + onValueChange = { cqZone = it }, + label = { Text("CQZ", fontSize = 12.sp) }, + singleLine = true, + modifier = Modifier.weight(1f) + ) + OutlinedTextField( + value = ituZone, + onValueChange = { ituZone = it }, + label = { Text("ITUZ", fontSize = 12.sp) }, + singleLine = true, + modifier = Modifier.weight(1f) + ) + OutlinedTextField( + value = iota, + onValueChange = { iota = it.uppercase() }, + label = { Text("IOTA", fontSize = 12.sp) }, + singleLine = true, + modifier = Modifier.weight(1f) + ) + } + } + }, + confirmButton = { + TextButton( + onClick = { onSaveStation(LoTWStation(grid, cqZone, ituZone, "", "", iota)) }, + enabled = grid.isNotBlank() && !busy + ) { Text(stringResource(R.string.prefs_lotw_upload_save)) } + }, + dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.prefs_lotw_upload_close)) } } + ) +} diff --git a/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/LogbookDialog.kt b/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/LogbookDialog.kt new file mode 100644 index 00000000..17cf0574 --- /dev/null +++ b/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/LogbookDialog.kt @@ -0,0 +1,117 @@ +/* + * 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.feature.settings + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.ElevatedCard +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.rtbishop.look4sat.core.domain.logbook.QsoRecord +import com.rtbishop.look4sat.core.domain.logbook.displayMode +import com.rtbishop.look4sat.core.presentation.R +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale +import java.util.TimeZone + +@Composable +fun LogbookCard(recordCount: Int, showLogbookDialog: () -> Unit) { + ElevatedCard(modifier = Modifier.fillMaxWidth().clickable { showLogbookDialog() }) { + Column(modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)) { + Text( + text = stringResource(R.string.prefs_logbook_title), + color = MaterialTheme.colorScheme.primary + ) + Spacer(modifier = Modifier.height(2.dp)) + Text( + text = stringResource(R.string.prefs_logbook_count, recordCount), + style = MaterialTheme.typography.bodySmall, + maxLines = 2 + ) + } + } +} + +@Composable +fun LogbookDialog( + records: List, + onDismiss: () -> Unit, + onDelete: (Long) -> Unit +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.prefs_logbook_title)) }, + text = { + if (records.isEmpty()) { + Text(stringResource(R.string.prefs_logbook_empty), fontSize = 14.sp) + } else { + LazyColumn( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.spacedBy(2.dp) + ) { + items(records, key = { it.id }) { record -> + LogbookRow(record, onDelete = { onDelete(record.id) }) + } + } + } + }, + confirmButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.prefs_logbook_close)) } } + ) +} + +@Composable +private fun LogbookRow(record: QsoRecord, onDelete: () -> Unit) { + val time = remember(record.startUtcMillis) { + SimpleDateFormat("MM-dd HH:mm'Z'", Locale.US).apply { + timeZone = TimeZone.getTimeZone("UTC") + }.format(Date(record.startUtcMillis)) + } + val satShort = record.satelliteName.substringBefore('(').trim() + Row( + modifier = Modifier.fillMaxWidth().clickable { onDelete() }.padding(horizontal = 4.dp, vertical = 2.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = "$time ${record.theirCallsign} ${record.displayMode}", + fontSize = 13.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Text(text = satShort, fontSize = 11.sp, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1) + } + Text( + text = if (record.lotwConfirmed) "✓" else "", + fontSize = 14.sp, + color = MaterialTheme.colorScheme.primary + ) + } +} diff --git a/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsScreen.kt b/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsScreen.kt index b8770257..ae653c74 100644 --- a/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsScreen.kt +++ b/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsScreen.kt @@ -261,6 +261,24 @@ private fun SettingsScreen(uiState: SettingsState, onAction: (SettingsAction) -> onSyncIncremental = { onAction(SettingsAction.SyncLoTWGrids(it, LoTWSyncMode.Incremental)) } ) } + if (dialogs.logbook) { + LogbookDialog( + records = uiState.logbookRecords, + onDismiss = { dialogs.logbook = false }, + onDelete = { onAction(SettingsAction.DeleteLogbookRecord(it)) } + ) + } + if (dialogs.lotwUpload) { + LoTWUploadConfigDialog( + certificate = uiState.lotwCertificate, + station = uiState.lotwStation, + busy = uiState.lotwUploadBusy, + onDismiss = { dialogs.lotwUpload = false }, + onImport = { bytes, password -> onAction(SettingsAction.ImportLoTWCertificate(bytes, password)) }, + onRemove = { onAction(SettingsAction.RemoveLoTWCertificate) }, + onSaveStation = { onAction(SettingsAction.SaveLoTWStation(it)) } + ) + } // URLs for top bar val uriHandler = LocalUriHandler.current @@ -365,6 +383,19 @@ private fun SettingsScreen(uiState: SettingsState, onAction: (SettingsAction) -> onRadioControlClick = { dialogs.radioControl = true } ) } + item { + LogbookCard( + recordCount = uiState.logbookRecords.size, + showLogbookDialog = { dialogs.logbook = true } + ) + } + item { + LoTWUploadCard( + hasCertificate = uiState.lotwCertificate != null, + stationGrid = uiState.lotwStation?.grid.orEmpty(), + showUploadConfigDialog = { onAction(SettingsAction.LoadLoTWUploadStatus); dialogs.lotwUpload = true } + ) + } item { LoTWCard( settings = uiState.lotwSettings, @@ -895,6 +926,8 @@ private class DialogVisibility { var wavelog by mutableStateOf(false) var lotw by mutableStateOf(false) var compassCalibration by mutableStateOf(false) + var logbook by mutableStateOf(false) + var lotwUpload by mutableStateOf(false) } @Composable @@ -902,7 +935,7 @@ private fun rememberDialogVisibility(): DialogVisibility { return rememberSaveable(saver = run { androidx.compose.runtime.saveable.Saver( save = { - listOf(it.position, it.locator, it.dataSources, it.network, it.bluetooth, it.radioControl, it.wavelog, it.lotw, it.compassCalibration) + listOf(it.position, it.locator, it.dataSources, it.network, it.bluetooth, it.radioControl, it.wavelog, it.lotw, it.compassCalibration, it.logbook, it.lotwUpload) }, restore = { DialogVisibility().apply { @@ -910,6 +943,8 @@ private fun rememberDialogVisibility(): DialogVisibility { network = it[3]; bluetooth = it[4]; radioControl = it[5]; wavelog = it[6] lotw = it.getOrElse(7) { false } compassCalibration = it.getOrElse(8) { false } + logbook = it.getOrElse(9) { false } + lotwUpload = it.getOrElse(10) { false } } } ) diff --git a/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsState.kt b/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsState.kt index 4ac9b2e3..19369431 100644 --- a/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsState.kt +++ b/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsState.kt @@ -68,6 +68,13 @@ data class SettingsState( val lotwError: LoTWError? = null, /** Epoch ms of the last successful LoTW sync (0 = never) — shown like the ephemeris update time. */ val lotwLastSyncEpochMs: Long = 0L, + /** Logbook (QSO records + LoTW confirmations), newest first. */ + val logbookRecords: List = emptyList(), + /** Imported LoTW upload certificate (null when none). */ + val lotwCertificate: com.rtbishop.look4sat.core.domain.repository.LoTWCertificate? = null, + /** LoTW upload station location (null when unset). */ + val lotwStation: com.rtbishop.look4sat.core.domain.repository.LoTWStation? = null, + val lotwUploadBusy: Boolean = false, /** 指南针校准精度等级 (校准对话框进度条). */ val compassAccuracy: CompassAccuracy = CompassAccuracy.UNRELIABLE, /** 校正后航向(度, 含磁偏角+手动偏置), 校准对话框实时显示. */ @@ -130,6 +137,16 @@ sealed interface SettingsAction { /** Abort an in-flight LoTW sync (wrong button / changed mind). */ data object CancelLoTWSync : SettingsAction + // Logbook (QSO records + LoTW confirmations) + data object RefreshLogbook : SettingsAction + data class DeleteLogbookRecord(val id: Long) : SettingsAction + + // LoTW upload configuration (certificate + station) + data object LoadLoTWUploadStatus : SettingsAction + data class ImportLoTWCertificate(val bytes: ByteArray, val password: CharArray) : SettingsAction + data object RemoveLoTWCertificate : SettingsAction + data class SaveLoTWStation(val station: com.rtbishop.look4sat.core.domain.repository.LoTWStation) : SettingsAction + // Update checker data object CheckForUpdate : SettingsAction data object DownloadUpdate : SettingsAction diff --git a/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsViewModel.kt b/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsViewModel.kt index 1f01b303..82dfc433 100644 --- a/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsViewModel.kt +++ b/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsViewModel.kt @@ -37,9 +37,11 @@ import com.rtbishop.look4sat.core.domain.repository.resolveLoTWSyncMode import com.rtbishop.look4sat.core.domain.repository.IWavelogRepository import com.rtbishop.look4sat.core.domain.usecase.IShowToast import com.rtbishop.look4sat.core.domain.utility.VersionComparator +import com.rtbishop.look4sat.core.domain.logbook.toConfirmedRecord import com.rtbishop.look4sat.core.presentation.R import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import java.io.File @@ -50,6 +52,8 @@ class SettingsViewModel( private val updateRepo: IUpdateRepository, private val wavelogRepo: IWavelogRepository, private val lotwRepo: com.rtbishop.look4sat.core.domain.repository.ILoTWRepository, + private val qsoRepository: com.rtbishop.look4sat.core.domain.logbook.IQsoRepository, + private val lotwUploadRepository: com.rtbishop.look4sat.core.domain.repository.ILoTWUploadRepository, private val sensorsRepo: ISensorsRepo, private val apkFile: File, private val showToast: IShowToast @@ -154,6 +158,11 @@ class SettingsViewModel( _uiState.update { it.copy(lotwSettings = settings) } } } + viewModelScope.launch { + qsoRepository.records.collect { records -> + _uiState.update { it.copy(logbookRecords = records) } + } + } } @@ -200,6 +209,14 @@ class SettingsViewModel( is SettingsAction.UpdateLoTW -> settingsRepo.updateLoTWSettings(action.settings) is SettingsAction.SyncLoTWGrids -> syncLoTWGrids(action.settings, action.mode) SettingsAction.CancelLoTWSync -> cancelLoTWSync() + // Logbook + SettingsAction.RefreshLogbook -> refreshLogbook() + is SettingsAction.DeleteLogbookRecord -> viewModelScope.launch { qsoRepository.delete(action.id) } + // LoTW upload configuration + SettingsAction.LoadLoTWUploadStatus -> loadLoTWUploadStatus() + is SettingsAction.ImportLoTWCertificate -> importLoTWCertificate(action.bytes, action.password) + SettingsAction.RemoveLoTWCertificate -> removeLoTWCertificate() + is SettingsAction.SaveLoTWStation -> saveLoTWStation(action.station) // Update checker SettingsAction.CheckForUpdate -> checkForUpdate() SettingsAction.DownloadUpdate -> downloadUpdate() @@ -263,6 +280,13 @@ class SettingsViewModel( // by call + QSO time); full: the fresh report replaces it all. // Shared with the automatic sync on app start (MainApplication). val mergedGridsCount = applyLoTWGridResult(settingsRepo, result, effectiveMode, callsign) + // Feed the logbook: imported confirmations show up in 日志本 and + // match local uploads (sameConfirmedContact) to mark them confirmed. + viewModelScope.launch { + val confirmed = result.qsos.values.flatten() + .map { it.toConfirmedRecord(callsign) } + qsoRepository.mergeLoTW(confirmed) + } _uiState.update { state -> state.copy( lotwSyncing = false, lotwSyncMode = null, lotwProgress = null, @@ -312,6 +336,55 @@ class SettingsViewModel( // endregion + // region Logbook + LoTW upload configuration + + private fun refreshLogbook() { + viewModelScope.launch { + _uiState.update { it.copy(logbookRecords = qsoRepository.records.first()) } + } + } + + private fun loadLoTWUploadStatus() { + viewModelScope.launch { + val cert = lotwUploadRepository.certificate() + val station = lotwUploadRepository.station() + _uiState.update { it.copy(lotwCertificate = cert, lotwStation = station, lotwUploadBusy = false) } + } + } + + private fun importLoTWCertificate(bytes: ByteArray, password: CharArray) { + viewModelScope.launch { + _uiState.update { it.copy(lotwUploadBusy = true) } + try { + val cert = lotwUploadRepository.importCertificate(bytes, password) + _uiState.update { it.copy(lotwCertificate = cert, lotwUploadBusy = false) } + } catch (_: Exception) { + _uiState.update { it.copy(lotwUploadBusy = false) } + } + } + } + + private fun removeLoTWCertificate() { + viewModelScope.launch { + lotwUploadRepository.removeCertificate() + _uiState.update { it.copy(lotwCertificate = null, lotwStation = null) } + } + } + + private fun saveLoTWStation(station: com.rtbishop.look4sat.core.domain.repository.LoTWStation) { + viewModelScope.launch { + _uiState.update { it.copy(lotwUploadBusy = true) } + try { + val saved = lotwUploadRepository.saveStation(station) + _uiState.update { it.copy(lotwStation = saved, lotwUploadBusy = false) } + } catch (_: Exception) { + _uiState.update { it.copy(lotwUploadBusy = false) } + } + } + } + + // endregion + // region Update checker private fun checkForUpdate() { @@ -447,6 +520,8 @@ class SettingsViewModel( updateRepo = container.updateRepo, wavelogRepo = container.wavelogRepo, lotwRepo = container.lotwRepo, + qsoRepository = container.qsoRepository, + lotwUploadRepository = container.lotwUploadRepository, sensorsRepo = container.provideSensorsRepo(), apkFile = File(context.cacheDir, "look4sat-update.apk"), showToast = container.provideShowToast()