From 8e549cd43c710d185da793c34a067fcea7e0fb7e Mon Sep 17 00:00:00 2001 From: atsunatsu Date: Fri, 25 Sep 2026 22:37:20 +0800 Subject: [PATCH] feat: LoTW upload + logbook (port from Iu-yang1's fork) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Port TQ8 signing/upload pipeline (core/data/lotw: LoTWConfig, LoTWKeyMaterial, LoTWSigner, LoTWStorage, LoTWUploadRepository) with bundled ARRL config.tq6 - Multi-grid station locations: primary grid in GRIDSQUARE + full set in MY_VUCC_GRIDS (unsigned tSTATION extra, IOTA precedent); signer matches myGrid against the whole grid set - Satellite-name normalization: SatNOGS names ("SO-50 (SaudiOSCAR 50)") resolve to official LoTW names by exact-then-substring match - Logbook storage: separate QsoDatabase (version 1, no migration on existing installs), QsoRepository with dedupe/confirmation merge; LoTW sync (manual + auto) feeds confirmations into the logbook and marks local uploads confirmed - Radar Log tab: single-callsign recording, per-satellite mode preset (CW/SSB/FT4), English social post generation (copy/share), LoTW upload with one-shot preview confirmation + receipts ledger - Settings: 日志本 section (records + confirmation state, delete on tap) above LoTW config; LoTW upload config (TrustedQSL .p12 import + password, station grid list incl. IOTA/CQZ/ITUZ) - i18n strings (en/zh) --- .../com/rtbishop/look4sat/MainApplication.kt | 6 + core/data/src/main/assets/lotw/config.tq6 | Bin 0 -> 67225 bytes .../look4sat/core/data/database/QsoDao.kt | 47 +++ .../core/data/database/QsoDatabase.kt | 24 ++ .../core/data/database/entity/QsoEntity.kt | 54 +++ .../core/data/injection/MainContainer.kt | 10 + .../look4sat/core/data/lotw/LoTWConfig.kt | 132 +++++++ .../core/data/lotw/LoTWKeyMaterial.kt | 109 ++++++ .../look4sat/core/data/lotw/LoTWSigner.kt | 84 ++++ .../look4sat/core/data/lotw/LoTWStorage.kt | 61 +++ .../core/data/lotw/LoTWUploadRepository.kt | 369 ++++++++++++++++++ .../core/data/repository/QsoRepository.kt | 199 ++++++++++ .../core/data/repository/SettingsRepo.kt | 25 ++ .../core/data/repository/DatabaseRepoTest.kt | 2 + .../repository/SelectionRepoSearchTest.kt | 2 + .../look4sat/core/domain/logbook/AdifCodec.kt | 163 ++++++++ .../core/domain/logbook/IQsoRepository.kt | 24 ++ .../core/domain/logbook/LoTWReportBridge.kt | 25 ++ .../domain/logbook/QsoConfirmationMerge.kt | 65 +++ .../look4sat/core/domain/logbook/QsoModels.kt | 48 +++ .../repository/ILoTWUploadRepository.kt | 74 ++++ .../core/domain/repository/IMainContainer.kt | 3 + .../core/domain/repository/ISettingsRepo.kt | 6 + .../core/domain/logbook/AdifCodecTest.kt | 86 ++++ .../core/domain/logbook/LogbookMergeTest.kt | 126 ++++++ .../domain/repository/LoTWGridSyncTest.kt | 2 + .../src/main/res/values-zh/strings.xml | 18 + .../src/main/res/values/strings.xml | 18 + .../look4sat/feature/radar/LogPage.kt | 281 +++++++++++++ .../look4sat/feature/radar/LogViewModel.kt | 198 ++++++++++ .../look4sat/feature/radar/RadarScreen.kt | 15 +- .../settings/LoTWUploadConfigDialog.kt | 178 +++++++++ .../feature/settings/LogbookDialog.kt | 117 ++++++ .../feature/settings/SettingsScreen.kt | 37 +- .../feature/settings/SettingsState.kt | 17 + .../feature/settings/SettingsViewModel.kt | 75 ++++ 36 files changed, 2696 insertions(+), 4 deletions(-) create mode 100644 core/data/src/main/assets/lotw/config.tq6 create mode 100644 core/data/src/main/java/com/rtbishop/look4sat/core/data/database/QsoDao.kt create mode 100644 core/data/src/main/java/com/rtbishop/look4sat/core/data/database/QsoDatabase.kt create mode 100644 core/data/src/main/java/com/rtbishop/look4sat/core/data/database/entity/QsoEntity.kt create mode 100644 core/data/src/main/java/com/rtbishop/look4sat/core/data/lotw/LoTWConfig.kt create mode 100644 core/data/src/main/java/com/rtbishop/look4sat/core/data/lotw/LoTWKeyMaterial.kt create mode 100644 core/data/src/main/java/com/rtbishop/look4sat/core/data/lotw/LoTWSigner.kt create mode 100644 core/data/src/main/java/com/rtbishop/look4sat/core/data/lotw/LoTWStorage.kt create mode 100644 core/data/src/main/java/com/rtbishop/look4sat/core/data/lotw/LoTWUploadRepository.kt create mode 100644 core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/QsoRepository.kt create mode 100644 core/domain/src/main/java/com/rtbishop/look4sat/core/domain/logbook/AdifCodec.kt create mode 100644 core/domain/src/main/java/com/rtbishop/look4sat/core/domain/logbook/IQsoRepository.kt create mode 100644 core/domain/src/main/java/com/rtbishop/look4sat/core/domain/logbook/LoTWReportBridge.kt create mode 100644 core/domain/src/main/java/com/rtbishop/look4sat/core/domain/logbook/QsoConfirmationMerge.kt create mode 100644 core/domain/src/main/java/com/rtbishop/look4sat/core/domain/logbook/QsoModels.kt create mode 100644 core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/ILoTWUploadRepository.kt create mode 100644 core/domain/src/test/java/com/rtbishop/look4sat/core/domain/logbook/AdifCodecTest.kt create mode 100644 core/domain/src/test/java/com/rtbishop/look4sat/core/domain/logbook/LogbookMergeTest.kt create mode 100644 feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/LogPage.kt create mode 100644 feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/LogViewModel.kt create mode 100644 feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/LoTWUploadConfigDialog.kt create mode 100644 feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/LogbookDialog.kt 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 0000000000000000000000000000000000000000..04fdfe4c587d7f312a32e3c9bc7820a06040ae22 GIT binary patch literal 67225 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 literal 0 HcmV?d00001 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()