mirror of
https://github.com/atsunatsu/Look4Sat.git
synced 2026-10-02 03:15:37 +00:00
feat: LoTW upload + logbook (port from Iu-yang1's fork)
- Port TQ8 signing/upload pipeline (core/data/lotw: LoTWConfig, LoTWKeyMaterial,
LoTWSigner, LoTWStorage, LoTWUploadRepository) with bundled ARRL config.tq6
- Multi-grid station locations: primary grid in GRIDSQUARE + full set in
MY_VUCC_GRIDS (unsigned tSTATION extra, IOTA precedent); signer matches
myGrid against the whole grid set
- Satellite-name normalization: SatNOGS names ("SO-50 (SaudiOSCAR 50)") resolve
to official LoTW names by exact-then-substring match
- Logbook storage: separate QsoDatabase (version 1, no migration on existing
installs), QsoRepository with dedupe/confirmation merge; LoTW sync (manual +
auto) feeds confirmations into the logbook and marks local uploads confirmed
- Radar Log tab: single-callsign recording, per-satellite mode preset
(CW/SSB/FT4), English social post generation (copy/share), LoTW upload with
one-shot preview confirmation + receipts ledger
- Settings: 日志本 section (records + confirmation state, delete on tap) above
LoTW config; LoTW upload config (TrustedQSL .p12 import + password, station
grid list incl. IOTA/CQZ/ITUZ)
- i18n strings (en/zh)
This commit is contained in:
1 parent
d33629f015
commit
8e549cd43c
36 files changed
+2696
-4
No files matched your search
@@ -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.IMainContainer
|
||||||
import com.rtbishop.look4sat.core.domain.repository.LoTWSyncMode
|
import com.rtbishop.look4sat.core.domain.repository.LoTWSyncMode
|
||||||
import com.rtbishop.look4sat.core.domain.repository.LoTWResult
|
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.applyLoTWGridResult
|
||||||
import com.rtbishop.look4sat.core.domain.repository.lotwCursorApi
|
import com.rtbishop.look4sat.core.domain.repository.lotwCursorApi
|
||||||
import com.rtbishop.look4sat.core.domain.repository.lotwSyncToday
|
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)
|
val result = container.lotwRepo.fetchConfirmedGridQsos(callsign, lotwSettings.password, since)
|
||||||
if (result is LoTWResult.Success) {
|
if (result is LoTWResult.Success) {
|
||||||
val count = applyLoTWGridResult(settingsRepo, result, mode, callsign, timeNow)
|
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")
|
println("Periodic LoTW grid sync finished: $count worked grids")
|
||||||
} else {
|
} else {
|
||||||
println("Periodic LoTW grid sync skipped (${result::class.simpleName})")
|
println("Periodic LoTW grid sync skipped (${result::class.simpleName})")
|
||||||
|
|||||||
Binary file not shown.
@@ -0,0 +1,47 @@
|
|||||||
|
/*
|
||||||
|
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||||
|
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*/
|
||||||
|
package com.rtbishop.look4sat.core.data.database
|
||||||
|
|
||||||
|
import androidx.room.Dao
|
||||||
|
import androidx.room.Insert
|
||||||
|
import androidx.room.OnConflictStrategy
|
||||||
|
import androidx.room.Query
|
||||||
|
import androidx.room.Transaction
|
||||||
|
import com.rtbishop.look4sat.core.data.database.entity.QsoEntity
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
|
||||||
|
@Dao
|
||||||
|
interface QsoDao {
|
||||||
|
@Query("SELECT * FROM qso_records ORDER BY startUtcMillis DESC, id DESC")
|
||||||
|
fun observeAll(): Flow<List<QsoEntity>>
|
||||||
|
|
||||||
|
@Query("SELECT * FROM qso_records WHERE id = :id LIMIT 1")
|
||||||
|
suspend fun find(id: Long): QsoEntity?
|
||||||
|
|
||||||
|
@Query("SELECT * FROM qso_records ORDER BY startUtcMillis DESC, id DESC")
|
||||||
|
suspend fun getAll(): List<QsoEntity>
|
||||||
|
|
||||||
|
@Query("SELECT dedupeKey FROM qso_records")
|
||||||
|
suspend fun getDedupeKeys(): List<String>
|
||||||
|
|
||||||
|
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||||
|
suspend fun save(record: QsoEntity): Long
|
||||||
|
|
||||||
|
@Insert(onConflict = OnConflictStrategy.IGNORE)
|
||||||
|
suspend fun importRecords(records: List<QsoEntity>): List<Long>
|
||||||
|
|
||||||
|
@Transaction
|
||||||
|
suspend fun saveBatch(records: List<QsoEntity>) {
|
||||||
|
records.forEach { save(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Query("DELETE FROM qso_records WHERE id = :id")
|
||||||
|
suspend fun delete(id: Long)
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
/*
|
||||||
|
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||||
|
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*/
|
||||||
|
package com.rtbishop.look4sat.core.data.database
|
||||||
|
|
||||||
|
import androidx.room.Database
|
||||||
|
import androidx.room.RoomDatabase
|
||||||
|
import com.rtbishop.look4sat.core.data.database.entity.QsoEntity
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Separate database for the logbook (QSO records, LoTW confirmations and
|
||||||
|
* upload receipts). Kept apart from [Look4SatDb] so existing installations
|
||||||
|
* never need a migration of the satellite database.
|
||||||
|
*/
|
||||||
|
@Database(entities = [QsoEntity::class], version = 1, exportSchema = false)
|
||||||
|
abstract class QsoDatabase : RoomDatabase() {
|
||||||
|
abstract fun qsoDao(): QsoDao
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
/*
|
||||||
|
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||||
|
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*/
|
||||||
|
package com.rtbishop.look4sat.core.data.database.entity
|
||||||
|
|
||||||
|
import androidx.room.ColumnInfo
|
||||||
|
import androidx.room.Entity
|
||||||
|
import androidx.room.Index
|
||||||
|
import androidx.room.PrimaryKey
|
||||||
|
|
||||||
|
@Entity(
|
||||||
|
tableName = "qso_records",
|
||||||
|
indices = [Index(value = ["dedupeKey"])]
|
||||||
|
)
|
||||||
|
data class QsoEntity(
|
||||||
|
@PrimaryKey(autoGenerate = true) val id: Long = 0L,
|
||||||
|
val startUtcMillis: Long,
|
||||||
|
val endUtcMillis: Long?,
|
||||||
|
val theirCallsign: String,
|
||||||
|
val myCallsign: String,
|
||||||
|
val theirGrid: String,
|
||||||
|
val myGrid: String,
|
||||||
|
val sentReport: String,
|
||||||
|
val receivedReport: String,
|
||||||
|
val txFrequencyHz: Long?,
|
||||||
|
val rxFrequencyHz: Long?,
|
||||||
|
val band: String,
|
||||||
|
val rxBand: String,
|
||||||
|
@ColumnInfo(defaultValue = "'MFSK'") val mode: String,
|
||||||
|
@ColumnInfo(defaultValue = "'FT4'") val submode: String,
|
||||||
|
val satelliteName: String,
|
||||||
|
val transponderName: String,
|
||||||
|
val satelliteMode: String,
|
||||||
|
val passAosUtcMillis: Long?,
|
||||||
|
val automatic: Boolean,
|
||||||
|
val status: String,
|
||||||
|
@ColumnInfo(defaultValue = "''") val dedupeKey: String,
|
||||||
|
@ColumnInfo(defaultValue = "''") val propagationMode: String = "",
|
||||||
|
@ColumnInfo(defaultValue = "0") val lotwConfirmed: Boolean = false,
|
||||||
|
@ColumnInfo(defaultValue = "0") val lotwReceived: Boolean = false,
|
||||||
|
@ColumnInfo(defaultValue = "''") val lotwQslDate: String = "",
|
||||||
|
@ColumnInfo(defaultValue = "''") val vuccGrids: String = "",
|
||||||
|
val dxcc: Int? = null,
|
||||||
|
@ColumnInfo(defaultValue = "''") val country: String = "",
|
||||||
|
val cqZone: Int? = null,
|
||||||
|
@ColumnInfo(defaultValue = "''") val region: String = "",
|
||||||
|
@ColumnInfo(defaultValue = "''") val comment: String = ""
|
||||||
|
)
|
||||||
@@ -26,13 +26,16 @@ import androidx.room.Room
|
|||||||
import com.rtbishop.look4sat.core.data.database.Look4SatDb
|
import com.rtbishop.look4sat.core.data.database.Look4SatDb
|
||||||
import com.rtbishop.look4sat.core.data.database.MIGRATION_1_2
|
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.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.BluetoothReporter
|
||||||
import com.rtbishop.look4sat.core.data.framework.Ft817Controller
|
import com.rtbishop.look4sat.core.data.framework.Ft817Controller
|
||||||
import com.rtbishop.look4sat.core.data.framework.Ic705Controller
|
import com.rtbishop.look4sat.core.data.framework.Ic705Controller
|
||||||
import com.rtbishop.look4sat.core.data.framework.NetworkReporter
|
import com.rtbishop.look4sat.core.data.framework.NetworkReporter
|
||||||
import com.rtbishop.look4sat.core.data.framework.RadioTrackingService
|
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.AmSatRepository
|
||||||
import com.rtbishop.look4sat.core.data.repository.DatabaseRepo
|
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.SatelliteRepo
|
||||||
import com.rtbishop.look4sat.core.data.repository.SelectionRepo
|
import com.rtbishop.look4sat.core.data.repository.SelectionRepo
|
||||||
import com.rtbishop.look4sat.core.data.repository.SensorsRepo
|
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.AudioCapture
|
||||||
import com.rtbishop.look4sat.core.data.usecase.SaveImage
|
import com.rtbishop.look4sat.core.data.usecase.SaveImage
|
||||||
import com.rtbishop.look4sat.core.data.usecase.ShowToast
|
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.model.RadioControlSettings
|
||||||
import com.rtbishop.look4sat.core.domain.repository.IDatabaseRepo
|
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.IMainContainer
|
||||||
import com.rtbishop.look4sat.core.domain.repository.IRadioController
|
import com.rtbishop.look4sat.core.domain.repository.IRadioController
|
||||||
import com.rtbishop.look4sat.core.domain.repository.IRadioTrackingService
|
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 updateRepo by lazy { UpdateRepository(remoteSource) }
|
||||||
override val wavelogRepo: IWavelogRepository by lazy { WavelogRepository() }
|
override val wavelogRepo: IWavelogRepository by lazy { WavelogRepository() }
|
||||||
override val lotwRepo: ILoTWRepository by lazy { LoTWRepository() }
|
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 {
|
override val radioTrackingService: IRadioTrackingService by lazy {
|
||||||
val manager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
|
val manager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
|
||||||
RadioTrackingService(appScope, manager, satelliteRepo, settingsRepo)
|
RadioTrackingService(appScope, manager, satelliteRepo, settingsRepo)
|
||||||
|
|||||||
@@ -0,0 +1,132 @@
|
|||||||
|
package com.rtbishop.look4sat.core.data.lotw
|
||||||
|
|
||||||
|
import com.rtbishop.look4sat.core.domain.logbook.QsoRecord
|
||||||
|
import com.rtbishop.look4sat.core.domain.logbook.displayMode
|
||||||
|
import com.rtbishop.look4sat.core.domain.repository.LoTWProblem
|
||||||
|
import com.rtbishop.look4sat.core.domain.repository.LoTWStation
|
||||||
|
import org.w3c.dom.Element
|
||||||
|
import org.xml.sax.InputSource
|
||||||
|
import org.xml.sax.SAXException
|
||||||
|
import java.io.InputStream
|
||||||
|
import java.io.StringReader
|
||||||
|
import java.util.Locale
|
||||||
|
import java.util.zip.GZIPInputStream
|
||||||
|
import javax.xml.parsers.DocumentBuilderFactory
|
||||||
|
|
||||||
|
/** Bundled ARRL configuration 11.34 from https://lotw.arrl.org/lotw/config.tq6. */
|
||||||
|
internal class LoTWConfig(input: InputStream) {
|
||||||
|
private val root = input.use { source -> GZIPInputStream(source).use { gzip ->
|
||||||
|
val xml = gzip.bufferedReader(Charsets.UTF_8).readText()
|
||||||
|
require(!xml.contains("<!DOCTYPE", true) && !xml.contains("<!ENTITY", true))
|
||||||
|
// Android's DOM provider does not support Xerces-specific setFeature flags.
|
||||||
|
DocumentBuilderFactory.newInstance().apply { isExpandEntityReferences = false }.newDocumentBuilder().apply {
|
||||||
|
setEntityResolver { _, _ -> throw SAXException("External entities are not permitted") }
|
||||||
|
}.parse(InputSource(StringReader(xml))).documentElement
|
||||||
|
} }
|
||||||
|
private val config = root.elements("tqslconfig").single()
|
||||||
|
val version = config.getAttribute("majorversion") + "." + config.getAttribute("minorversion")
|
||||||
|
private val spec = config.elements("sigspec").single { it.getAttribute("version") == "2.0" }
|
||||||
|
val stationOrder = spec.elements("tSTATION").single().childElements().map { it.tagName }
|
||||||
|
val contactOrder = spec.elements("tCONTACT").single().childElements().map { it.tagName }
|
||||||
|
private val bands = config.elements("bands").single().elements("band")
|
||||||
|
private val satellites = config.elements("satellite").associateBy { it.getAttribute("name").uppercase(Locale.US) }
|
||||||
|
private val modes = config.elements("modes").single().elements("mode").map { it.textContent }.toSet()
|
||||||
|
|
||||||
|
fun mode(record: QsoRecord): String {
|
||||||
|
val display = record.displayMode
|
||||||
|
if (display in modes) return display
|
||||||
|
val mapped = config.elements("adifmode").firstOrNull { it.textContent.equals(display, true) }?.getAttribute("mode")
|
||||||
|
return mapped ?: fail(LoTWProblem.MODE, display)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun band(explicit: String, frequency: Long?, required: Boolean): String {
|
||||||
|
if (frequency != null && frequency <= 0) fail(LoTWProblem.BAND)
|
||||||
|
val frequencyBand = frequency?.let { hz -> bands.firstOrNull { band ->
|
||||||
|
val unit = if (band.getAttribute("spectrum") == "HF") 1_000.0 else 1_000_000.0
|
||||||
|
val mhz = hz / unit
|
||||||
|
mhz >= band.getAttribute("low").toDouble() && mhz <= band.getAttribute("high").toDouble()
|
||||||
|
}?.textContent }
|
||||||
|
val value = explicit.trim().uppercase(Locale.US).ifBlank { frequencyBand.orEmpty() }
|
||||||
|
if (value.isBlank() && !required && frequency == null) return ""
|
||||||
|
if (bands.none { it.textContent == value } || (frequency != null && frequencyBand != value)) fail(LoTWProblem.BAND, value)
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
fun satellite(name: String, date: String): String {
|
||||||
|
val normalized = name.trim().uppercase(Locale.US)
|
||||||
|
// Look4Sat satellite names come from SatNOGS (e.g. "SO-50 (SaudiOSCAR 50)");
|
||||||
|
// the bundled config only knows the official LoTW name ("SO-50"). Exact match
|
||||||
|
// first, then accept any satellite whose official name is a substring of the
|
||||||
|
// Look4Sat name (covers SO-50, AO-91, IO-86, CAS-7B …). Failing that the
|
||||||
|
// contact is unavailable for upload.
|
||||||
|
val sat = satellites[normalized]
|
||||||
|
?: satellites.entries.firstOrNull { (official, _) ->
|
||||||
|
official.length > 1 && normalized.contains(official)
|
||||||
|
}?.value
|
||||||
|
?: fail(LoTWProblem.SATELLITE, normalized)
|
||||||
|
val first = sat.getAttribute("startDate")
|
||||||
|
val last = sat.getAttribute("endDate")
|
||||||
|
if ((first.isNotBlank() && date < first) || (last.isNotBlank() && date > last)) fail(LoTWProblem.SATELLITE, normalized)
|
||||||
|
return sat.getAttribute("name").uppercase(Locale.US)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun propagation(value: String): String {
|
||||||
|
val normalized = value.trim().uppercase(Locale.US)
|
||||||
|
if (normalized.isNotBlank() && config.elements("propmode").none { it.getAttribute("name") == normalized || it.textContent == normalized }) {
|
||||||
|
fail(LoTWProblem.INVALID_CONTACT, normalized)
|
||||||
|
}
|
||||||
|
return normalized
|
||||||
|
}
|
||||||
|
|
||||||
|
fun stationFields(station: LoTWStation, dxcc: Int): Map<String, String> {
|
||||||
|
fun normalized(value: String) = value.trim().uppercase(Locale.US)
|
||||||
|
val grids = station.grid.split(',').map(::normalized).filter(String::isNotBlank).distinct()
|
||||||
|
if (grids.size !in 1..4 || grids.any { !it.matches(Regex("[A-R]{2}[0-9]{2}([A-X]{2}([0-9]{2})?)?")) }) {
|
||||||
|
fail(LoTWProblem.STATION_GRID)
|
||||||
|
}
|
||||||
|
// The primary grid goes into GRIDSQUARE (the field the config's tSTATION
|
||||||
|
// order signs); the full multi-grid set goes into MY_VUCC_GRIDS, an extra
|
||||||
|
// station field that TQSL stores but does not sign (same precedent as IOTA).
|
||||||
|
val fields = linkedMapOf("GRIDSQUARE" to grids.first())
|
||||||
|
if (grids.size > 1) fields["MY_VUCC_GRIDS"] = grids.joinToString(",")
|
||||||
|
fun zone(name: String, value: String, maximum: Int) {
|
||||||
|
if (value.isBlank()) return
|
||||||
|
val number = value.trim().toIntOrNull()?.takeIf { it in 1..maximum } ?: fail(LoTWProblem.STATION_ZONE)
|
||||||
|
fields[name] = number.toString()
|
||||||
|
}
|
||||||
|
zone("CQZ", station.cqZone, 40)
|
||||||
|
zone("ITUZ", station.ituZone, 90)
|
||||||
|
if (station.iota.isNotBlank()) {
|
||||||
|
val iota = normalized(station.iota)
|
||||||
|
if (!iota.matches(Regex("(AF|AN|AS|EU|NA|OC|SA)-[0-9]{3}"))) fail(LoTWProblem.STATION_IOTA)
|
||||||
|
fields["IOTA"] = iota
|
||||||
|
}
|
||||||
|
val pageFields = config.elements("page").filter { it.getAttribute("dependency") == dxcc.toString() }
|
||||||
|
.flatMap { it.elements("pageField") }.map { it.textContent }
|
||||||
|
val primary = pageFields.firstOrNull { it in setOf("US_STATE", "CA_PROVINCE", "RU_OBLAST", "CN_PROVINCE", "AU_STATE", "JA_PREFECTURE", "FI_KUNTA") }
|
||||||
|
val secondary = pageFields.firstOrNull { it in setOf("US_COUNTY", "JA_CITY_GUN_KU") }
|
||||||
|
fun region(name: String?, value: String) {
|
||||||
|
if (value.isBlank()) return
|
||||||
|
if (name == null) fail(LoTWProblem.STATION_REGION)
|
||||||
|
val field = config.elements("field").single { it.getAttribute("Id") == name }
|
||||||
|
val dependency = when (field.getAttribute("dependsOn")) {
|
||||||
|
"DXCC" -> dxcc.toString()
|
||||||
|
else -> fields[field.getAttribute("dependsOn")]
|
||||||
|
}
|
||||||
|
val options = field.elements("enums").filter {
|
||||||
|
it.getAttribute("dependency").isBlank() || it.getAttribute("dependency") == dependency
|
||||||
|
}.flatMap { it.elements("enum") }.map { it.getAttribute("value").uppercase(Locale.US) }
|
||||||
|
val code = normalized(value)
|
||||||
|
if (code !in options) fail(LoTWProblem.STATION_REGION, name)
|
||||||
|
fields[name] = code
|
||||||
|
}
|
||||||
|
region(primary, station.region)
|
||||||
|
region(secondary, station.county)
|
||||||
|
return fields
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Element.elements(tag: String): List<Element> = getElementsByTagName(tag).let { nodes ->
|
||||||
|
(0 until nodes.length).map { nodes.item(it) as Element }
|
||||||
|
}
|
||||||
|
private fun Element.childElements(): List<Element> = (0 until childNodes.length).mapNotNull { childNodes.item(it) as? Element }
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
package com.rtbishop.look4sat.core.data.lotw
|
||||||
|
|
||||||
|
import com.rtbishop.look4sat.core.domain.repository.LoTWCertificate
|
||||||
|
import com.rtbishop.look4sat.core.domain.repository.LoTWOperationException
|
||||||
|
import com.rtbishop.look4sat.core.domain.repository.LoTWProblem
|
||||||
|
import java.security.KeyStore
|
||||||
|
import java.security.PrivateKey
|
||||||
|
import java.security.Signature
|
||||||
|
import java.security.cert.X509Certificate
|
||||||
|
import java.text.SimpleDateFormat
|
||||||
|
import java.util.Collections
|
||||||
|
import java.util.Date
|
||||||
|
import java.util.Locale
|
||||||
|
import java.util.TimeZone
|
||||||
|
|
||||||
|
internal data class LoTWKeyMaterial(val key: PrivateKey, val certificate: X509Certificate, val info: LoTWCertificate) {
|
||||||
|
companion object {
|
||||||
|
fun read(bytes: ByteArray, password: CharArray, now: Long): LoTWKeyMaterial {
|
||||||
|
if (bytes.isEmpty() || bytes.size > MAX_CERTIFICATE_BYTES) fail(LoTWProblem.CERTIFICATE_INVALID)
|
||||||
|
val store = try {
|
||||||
|
KeyStore.getInstance("PKCS12").apply { bytes.inputStream().use { load(it, password) } }
|
||||||
|
} catch (_: Exception) { fail(LoTWProblem.CERTIFICATE_PASSWORD) }
|
||||||
|
val aliases = Collections.list(store.aliases()).filter { store.isKeyEntry(it) }
|
||||||
|
if (aliases.size != 1) fail(LoTWProblem.CERTIFICATE_INVALID)
|
||||||
|
val alias = aliases.single()
|
||||||
|
val key = try { store.getKey(alias, password) as? PrivateKey }
|
||||||
|
catch (_: Exception) { fail(LoTWProblem.CERTIFICATE_PASSWORD) }
|
||||||
|
?: fail(LoTWProblem.CERTIFICATE_INVALID)
|
||||||
|
val cert = store.getCertificate(alias) as? X509Certificate ?: fail(LoTWProblem.CERTIFICATE_INVALID)
|
||||||
|
if (key.algorithm != "RSA" || cert.publicKey.algorithm != "RSA") fail(LoTWProblem.CERTIFICATE_INVALID)
|
||||||
|
try { cert.checkValidity(Date(now)) } catch (_: Exception) { fail(LoTWProblem.CERTIFICATE_EXPIRED) }
|
||||||
|
val info = try { metadata(cert) } catch (_: Exception) { fail(LoTWProblem.CERTIFICATE_INVALID) }
|
||||||
|
// Use the platform's crypto implementation, including PKCS#1 padding. No custom cryptography.
|
||||||
|
val challenge = "Look4Sat LoTW certificate key check".toByteArray(Charsets.US_ASCII)
|
||||||
|
val signed = Signature.getInstance("SHA1withRSA").run { initSign(key); update(challenge); sign() }
|
||||||
|
val valid = Signature.getInstance("SHA1withRSA").run { initVerify(cert); update(challenge); verify(signed) }
|
||||||
|
if (!valid) fail(LoTWProblem.CERTIFICATE_INVALID)
|
||||||
|
return LoTWKeyMaterial(key, cert, info)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun metadata(cert: X509Certificate): LoTWCertificate {
|
||||||
|
val oid = byteArrayOf(0x2b, 0x06, 0x01, 0x04, 0x01, 0xe0.toByte(), 0x3c, 0x01, 0x01)
|
||||||
|
val subject = DerValue.read(cert.subjectX500Principal.encoded).single()
|
||||||
|
require(subject.tag == 0x30)
|
||||||
|
val calls = subject.children().flatMap { it.children() }.mapNotNull { attribute ->
|
||||||
|
val pair = attribute.children()
|
||||||
|
if (pair.size == 2 && pair[0].tag == 6 && pair[0].data.contentEquals(oid)) pair[1].text() else null
|
||||||
|
}
|
||||||
|
val call = calls.single().trim().uppercase(Locale.US)
|
||||||
|
require(call.matches(Regex("[A-Z0-9]+(/[A-Z0-9]+)*")) && call.any(Char::isDigit) && call.any(Char::isLetter))
|
||||||
|
val first = extension(cert, "2")
|
||||||
|
val last = extension(cert, "3").takeUnless { it == "0000-00-00" }.orEmpty()
|
||||||
|
require(validDate(first) && (last.isBlank() || validDate(last)) && (last.isBlank() || last >= first))
|
||||||
|
val dxcc = extension(cert, "4").toInt()
|
||||||
|
require(dxcc > 0)
|
||||||
|
return LoTWCertificate(call, dxcc, cert.serialNumber.toString(16), utc(cert.notAfter.time, "yyyy-MM-dd"), first, last)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun extension(cert: X509Certificate, suffix: String): String {
|
||||||
|
val bytes = cert.getExtensionValue("1.3.6.1.4.1.12348.1.$suffix") ?: return ""
|
||||||
|
val outer = DerValue.read(bytes).single()
|
||||||
|
require(outer.tag == 4)
|
||||||
|
// TrustedQSL uses raw ASCII extension values. Also accept DER string-wrapped values.
|
||||||
|
return if (outer.data.all { it.toInt() in 32..126 || it.toInt() == 0 }) {
|
||||||
|
outer.data.toString(Charsets.US_ASCII).trim('\u0000', ' ')
|
||||||
|
} else DerValue.read(outer.data).single().text().trim()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal const val MAX_CERTIFICATE_BYTES = 1024 * 1024
|
||||||
|
internal fun fail(problem: LoTWProblem, detail: String = ""): Nothing = throw LoTWOperationException(problem, detail)
|
||||||
|
internal fun utc(millis: Long, pattern: String): String = SimpleDateFormat(pattern, Locale.US).apply {
|
||||||
|
timeZone = TimeZone.getTimeZone("UTC")
|
||||||
|
}.format(Date(millis))
|
||||||
|
internal fun validDate(value: String): Boolean = value.matches(Regex("[0-9]{4}-[0-9]{2}-[0-9]{2}")) && runCatching {
|
||||||
|
SimpleDateFormat("yyyy-MM-dd", Locale.US).apply { isLenient = false }.parse(value) != null
|
||||||
|
}.getOrDefault(false)
|
||||||
|
|
||||||
|
/** Only bounded DER metadata decoding; cryptographic verification is delegated to JCA. */
|
||||||
|
internal data class DerValue(val tag: Int, val data: ByteArray) {
|
||||||
|
fun children(): List<DerValue> = read(data)
|
||||||
|
fun text(): String = when (tag) {
|
||||||
|
12, 19, 20, 22, 23, 24 -> data.toString(Charsets.UTF_8)
|
||||||
|
30 -> data.toString(Charsets.UTF_16BE)
|
||||||
|
else -> error("Not an ASN.1 string")
|
||||||
|
}
|
||||||
|
companion object {
|
||||||
|
fun read(bytes: ByteArray): List<DerValue> {
|
||||||
|
val result = mutableListOf<DerValue>()
|
||||||
|
var offset = 0
|
||||||
|
while (offset < bytes.size) {
|
||||||
|
require(bytes.size - offset >= 2)
|
||||||
|
val tag = bytes[offset++].toInt() and 255
|
||||||
|
var length = bytes[offset++].toInt() and 255
|
||||||
|
if (length and 128 != 0) {
|
||||||
|
val size = length and 127
|
||||||
|
require(size in 1..3 && size <= bytes.size - offset)
|
||||||
|
length = 0
|
||||||
|
repeat(size) { length = (length shl 8) or (bytes[offset++].toInt() and 255) }
|
||||||
|
}
|
||||||
|
require(length <= bytes.size - offset)
|
||||||
|
result += DerValue(tag, bytes.copyOfRange(offset, offset + length))
|
||||||
|
offset += length
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
package com.rtbishop.look4sat.core.data.lotw
|
||||||
|
|
||||||
|
import com.rtbishop.look4sat.core.domain.logbook.QsoRecord
|
||||||
|
import com.rtbishop.look4sat.core.domain.logbook.QsoStatus
|
||||||
|
import com.rtbishop.look4sat.core.domain.logbook.isSatellite
|
||||||
|
import com.rtbishop.look4sat.core.domain.repository.LoTWProblem
|
||||||
|
import java.io.ByteArrayOutputStream
|
||||||
|
import java.math.BigDecimal
|
||||||
|
import java.security.MessageDigest
|
||||||
|
import java.security.Signature
|
||||||
|
import java.util.Locale
|
||||||
|
import java.util.zip.GZIPOutputStream
|
||||||
|
import kotlin.io.encoding.Base64
|
||||||
|
|
||||||
|
internal data class LoTWContact(val record: QsoRecord, val fields: Map<String, String>, val signData: String, val fingerprint: String)
|
||||||
|
|
||||||
|
internal class LoTWSigner(private val config: LoTWConfig) {
|
||||||
|
fun contact(record: QsoRecord, key: LoTWKeyMaterial, station: Map<String, String>, now: Long): LoTWContact {
|
||||||
|
val call = record.theirCallsign.trim().uppercase(Locale.US)
|
||||||
|
if (record.status != QsoStatus.COMPLETE || !call.matches(Regex("[A-Z0-9]+(/[A-Z0-9]+)*")) ||
|
||||||
|
!call.any(Char::isLetter) || !call.any(Char::isDigit)) fail(LoTWProblem.INVALID_CONTACT, call)
|
||||||
|
if (!record.myCallsign.trim().equals(key.info.callsign, true)) fail(LoTWProblem.CALLSIGN_MISMATCH, call)
|
||||||
|
val date = utc(record.startUtcMillis, "yyyy-MM-dd")
|
||||||
|
if (date < key.info.firstQsoDate || (key.info.lastQsoDate.isNotBlank() && date > key.info.lastQsoDate) || record.startUtcMillis > now) {
|
||||||
|
fail(LoTWProblem.QSO_DATE, call)
|
||||||
|
}
|
||||||
|
val grids = buildList {
|
||||||
|
addAll(station.getValue("GRIDSQUARE").split(',').map(String::trim))
|
||||||
|
station["MY_VUCC_GRIDS"]?.split(',')?.map(String::trim)?.let(::addAll)
|
||||||
|
}.filter(String::isNotBlank)
|
||||||
|
if (record.myGrid.isNotBlank() && grids.none { grid ->
|
||||||
|
val local = record.myGrid.trim().uppercase(Locale.US)
|
||||||
|
grid.startsWith(local) || local.startsWith(grid)
|
||||||
|
}) fail(LoTWProblem.LOCATION_MISMATCH, call)
|
||||||
|
fun mhz(hz: Long?): String = hz?.let { BigDecimal.valueOf(it, 6).stripTrailingZeros().toPlainString() }.orEmpty()
|
||||||
|
val fields = linkedMapOf(
|
||||||
|
"BAND" to config.band(record.band, record.txFrequencyHz, true),
|
||||||
|
"BAND_RX" to config.band(record.rxBand, record.rxFrequencyHz, false),
|
||||||
|
"CALL" to call,
|
||||||
|
"FREQ" to mhz(record.txFrequencyHz),
|
||||||
|
"FREQ_RX" to mhz(record.rxFrequencyHz),
|
||||||
|
"MODE" to config.mode(record),
|
||||||
|
"PROP_MODE" to if (record.isSatellite) "SAT" else config.propagation(record.propagationMode),
|
||||||
|
"QSO_DATE" to date,
|
||||||
|
"QSO_TIME" to utc(record.startUtcMillis, "HH:mm:ss'Z'"),
|
||||||
|
"SAT_NAME" to if (record.isSatellite) config.satellite(record.satelliteName, date) else ""
|
||||||
|
).filterValues { it.isNotBlank() }
|
||||||
|
val signData = (config.stationOrder.map { station[it].orEmpty() } + config.contactOrder.map { fields[it].orEmpty() }).joinToString("")
|
||||||
|
val identity = field("CALL", key.info.callsign) + field("DXCC", key.info.dxcc.toString()) +
|
||||||
|
station.toSortedMap().entries.joinToString("") { field(it.key, it.value) } +
|
||||||
|
fields.entries.joinToString("") { field(it.key, it.value) }
|
||||||
|
val hash = MessageDigest.getInstance("SHA-256").digest(identity.toByteArray(Charsets.UTF_8)).joinToString("") { "%02x".format(it) }
|
||||||
|
return LoTWContact(record, fields, signData, hash)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun sign(contacts: List<LoTWContact>, key: LoTWKeyMaterial, station: Map<String, String>): ByteArray {
|
||||||
|
val text = buildString {
|
||||||
|
append(field("TQSL_IDENT", "Look4Sat LoTW Config: V${config.version} AllowDupes: false"))
|
||||||
|
append(field("REC_TYPE", "tCERT")); append(field("CERT_UID", "1"))
|
||||||
|
append(field("CERTIFICATE", Base64.Default.encode(key.certificate.encoded).chunked(64).joinToString("\n")))
|
||||||
|
append("<eor>\n")
|
||||||
|
append(field("REC_TYPE", "tSTATION")); append(field("STATION_UID", "1")); append(field("CERT_UID", "1"))
|
||||||
|
append(field("CALL", key.info.callsign)); append(field("DXCC", key.info.dxcc.toString()))
|
||||||
|
station.forEach { (name, value) -> append(field(name, value)) }
|
||||||
|
append("<eor>\n")
|
||||||
|
contacts.forEach { contact ->
|
||||||
|
append(field("REC_TYPE", "tCONTACT")); append(field("STATION_UID", "1"))
|
||||||
|
contact.fields.forEach { (name, value) -> append(field(name, value)) }
|
||||||
|
// SHA-1 is required by the LoTW V2.0 wire format, not chosen for general-purpose signing.
|
||||||
|
val signature = Signature.getInstance("SHA1withRSA").run {
|
||||||
|
initSign(key.key); update(contact.signData.toByteArray(Charsets.UTF_8)); sign()
|
||||||
|
}
|
||||||
|
val encoded = Base64.Default.encode(signature).chunked(64).joinToString("\n")
|
||||||
|
append("<SIGN_LOTW_V2.0:${encoded.length}:6>$encoded\n")
|
||||||
|
append(field("SIGNDATA", contact.signData)); append("<eor>\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ByteArrayOutputStream().also { output ->
|
||||||
|
GZIPOutputStream(output).use { it.write(text.toByteArray(Charsets.UTF_8)) }
|
||||||
|
}.toByteArray()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun field(name: String, value: String): String = "<$name:${value.toByteArray(Charsets.UTF_8).size}>$value\n"
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
package com.rtbishop.look4sat.core.data.lotw
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.security.keystore.KeyGenParameterSpec
|
||||||
|
import android.security.keystore.KeyProperties
|
||||||
|
import android.util.AtomicFile
|
||||||
|
import java.io.File
|
||||||
|
import java.security.KeyStore
|
||||||
|
import javax.crypto.Cipher
|
||||||
|
import javax.crypto.KeyGenerator
|
||||||
|
import javax.crypto.SecretKey
|
||||||
|
import javax.crypto.spec.GCMParameterSpec
|
||||||
|
|
||||||
|
internal interface LoTWStorage {
|
||||||
|
fun read(name: String): ByteArray?
|
||||||
|
fun write(name: String, data: ByteArray)
|
||||||
|
fun delete(name: String)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** P12 backups and their saved passwords are encrypted at rest and excluded from Android/cloud backup. */
|
||||||
|
internal class AndroidLoTWStorage(context: Context) : LoTWStorage {
|
||||||
|
private val directory = File(context.noBackupFilesDir, "lotw")
|
||||||
|
private val store by lazy { KeyStore.getInstance("AndroidKeyStore").apply { load(null) } }
|
||||||
|
private fun file(name: String): AtomicFile {
|
||||||
|
require(name.matches(Regex("[a-z]+")))
|
||||||
|
check(directory.isDirectory || directory.mkdirs())
|
||||||
|
return AtomicFile(File(directory, name))
|
||||||
|
}
|
||||||
|
private fun key(): SecretKey = (store.getKey(ALIAS, null) as? SecretKey) ?: KeyGenerator.getInstance(
|
||||||
|
KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore"
|
||||||
|
).run {
|
||||||
|
init(KeyGenParameterSpec.Builder(ALIAS, KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT)
|
||||||
|
.setBlockModes(KeyProperties.BLOCK_MODE_GCM).setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
|
||||||
|
.setKeySize(256).build())
|
||||||
|
generateKey()
|
||||||
|
}
|
||||||
|
@Synchronized override fun read(name: String): ByteArray? {
|
||||||
|
val file = file(name)
|
||||||
|
if (!file.baseFile.exists() && !File(file.baseFile.path + ".bak").exists()) return null
|
||||||
|
val bytes = file.readFully()
|
||||||
|
require(bytes.size in 29..(16 * 1024 * 1024) && bytes[0] == 1.toByte())
|
||||||
|
return Cipher.getInstance("AES/GCM/NoPadding").run {
|
||||||
|
init(Cipher.DECRYPT_MODE, key(), GCMParameterSpec(128, bytes.copyOfRange(1, 13)))
|
||||||
|
updateAAD(name.toByteArray(Charsets.UTF_8))
|
||||||
|
doFinal(bytes, 13, bytes.size - 13)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@Synchronized override fun write(name: String, data: ByteArray) {
|
||||||
|
val cipher = Cipher.getInstance("AES/GCM/NoPadding").apply {
|
||||||
|
init(Cipher.ENCRYPT_MODE, key())
|
||||||
|
updateAAD(name.toByteArray(Charsets.UTF_8))
|
||||||
|
}
|
||||||
|
val bytes = byteArrayOf(1) + cipher.iv + cipher.doFinal(data)
|
||||||
|
val file = file(name)
|
||||||
|
val stream = file.startWrite()
|
||||||
|
try { stream.write(bytes); file.finishWrite(stream) }
|
||||||
|
catch (error: Exception) { file.failWrite(stream); throw error }
|
||||||
|
}
|
||||||
|
@Synchronized override fun delete(name: String) = file(name).delete()
|
||||||
|
private companion object { const val ALIAS = "look4sat.lotw.storage.v1" }
|
||||||
|
}
|
||||||
@@ -0,0 +1,369 @@
|
|||||||
|
package com.rtbishop.look4sat.core.data.lotw
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import com.rtbishop.look4sat.core.domain.logbook.QsoRecord
|
||||||
|
import com.rtbishop.look4sat.core.domain.logbook.QsoStatus
|
||||||
|
import com.rtbishop.look4sat.core.domain.repository.ILoTWUploadRepository
|
||||||
|
import com.rtbishop.look4sat.core.domain.repository.LoTWCertificate
|
||||||
|
import com.rtbishop.look4sat.core.domain.repository.LoTWOperationException
|
||||||
|
import com.rtbishop.look4sat.core.domain.repository.LoTWProblem
|
||||||
|
import com.rtbishop.look4sat.core.domain.repository.LoTWStation
|
||||||
|
import com.rtbishop.look4sat.core.domain.repository.LoTWUploadAudit
|
||||||
|
import com.rtbishop.look4sat.core.domain.repository.LoTWUploadPreview
|
||||||
|
import com.rtbishop.look4sat.core.domain.repository.LoTWUploadResult
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.ensureActive
|
||||||
|
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||||
|
import kotlinx.coroutines.sync.Mutex
|
||||||
|
import kotlinx.coroutines.sync.withLock
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import okhttp3.Call
|
||||||
|
import okhttp3.Callback
|
||||||
|
import okhttp3.MediaType.Companion.toMediaType
|
||||||
|
import okhttp3.MultipartBody
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import okhttp3.Request
|
||||||
|
import okhttp3.RequestBody.Companion.toRequestBody
|
||||||
|
import okhttp3.Response
|
||||||
|
import java.io.ByteArrayOutputStream
|
||||||
|
import java.io.DataInputStream
|
||||||
|
import java.io.DataOutputStream
|
||||||
|
import java.io.IOException
|
||||||
|
import java.nio.ByteBuffer
|
||||||
|
import java.nio.CharBuffer
|
||||||
|
import java.util.Locale
|
||||||
|
import java.util.UUID
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
import kotlin.coroutines.coroutineContext
|
||||||
|
import kotlin.coroutines.resume
|
||||||
|
|
||||||
|
private fun readBoundedBody(response: Response, limit: Int): String = response.body.charStream().use { reader ->
|
||||||
|
val result = StringBuilder()
|
||||||
|
val buffer = CharArray(8192)
|
||||||
|
while (true) {
|
||||||
|
val count = reader.read(buffer)
|
||||||
|
if (count < 0) break
|
||||||
|
require(result.length <= limit - count) { "Response exceeds size limit" }
|
||||||
|
result.append(buffer, 0, count)
|
||||||
|
}
|
||||||
|
result.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
class LoTWUploadRepository internal constructor(
|
||||||
|
private val storage: LoTWStorage,
|
||||||
|
private val config: () -> LoTWConfig,
|
||||||
|
client: OkHttpClient = OkHttpClient(),
|
||||||
|
private val now: () -> Long = System::currentTimeMillis
|
||||||
|
) : ILoTWUploadRepository {
|
||||||
|
constructor(context: Context) : this(AndroidLoTWStorage(context), { LoTWConfig(context.assets.open("lotw/config.tq6")) })
|
||||||
|
private val client = client.newBuilder().connectTimeout(30, TimeUnit.SECONDS).readTimeout(120, TimeUnit.SECONDS)
|
||||||
|
.callTimeout(180, TimeUnit.SECONDS).retryOnConnectionFailure(false).followRedirects(false).followSslRedirects(false).build()
|
||||||
|
private val mutex = Mutex()
|
||||||
|
@Volatile private var pending: Pending? = null
|
||||||
|
private data class Pending(val preview: LoTWUploadPreview, val data: ByteArray, val hashes: List<String>, val created: Long)
|
||||||
|
private data class SigningContext(val key: LoTWKeyMaterial, val signer: LoTWSigner, val location: Map<String, String>)
|
||||||
|
|
||||||
|
override suspend fun certificate(): LoTWCertificate? = withContext(Dispatchers.IO) {
|
||||||
|
mutex.withLock {
|
||||||
|
val bytes = storage.read("certificate") ?: return@withLock null
|
||||||
|
try {
|
||||||
|
readBundle(bytes).let { bundle ->
|
||||||
|
bundle.p12.fill(0)
|
||||||
|
bundle.password?.fill(0)
|
||||||
|
bundle.info.copy(passwordSaved = bundle.password != null)
|
||||||
|
}
|
||||||
|
} finally { bytes.fill(0) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun importCertificate(data: ByteArray, password: CharArray): LoTWCertificate = withContext(Dispatchers.IO) {
|
||||||
|
mutex.withLock {
|
||||||
|
val passwordBytes = encodePassword(password)
|
||||||
|
try {
|
||||||
|
if (passwordBytes.size > MAX_CERTIFICATE_PASSWORD_BYTES) fail(LoTWProblem.CERTIFICATE_PASSWORD)
|
||||||
|
val key = LoTWKeyMaterial.read(data, password, now())
|
||||||
|
val bytes = encodeBundle(key.info, data, passwordBytes)
|
||||||
|
try { storage.write("certificate", bytes) } finally { bytes.fill(0) }
|
||||||
|
discardPreview()
|
||||||
|
key.info.copy(passwordSaved = true)
|
||||||
|
} finally {
|
||||||
|
passwordBytes.fill(0)
|
||||||
|
password.fill('\u0000')
|
||||||
|
data.fill(0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun saveCertificatePassword(password: CharArray): LoTWCertificate = withContext(Dispatchers.IO) {
|
||||||
|
mutex.withLock {
|
||||||
|
val stored = storage.read("certificate") ?: fail(LoTWProblem.CERTIFICATE_MISSING)
|
||||||
|
val bundle = try { readBundle(stored) } finally { stored.fill(0) }
|
||||||
|
val passwordBytes = encodePassword(password)
|
||||||
|
try {
|
||||||
|
if (passwordBytes.size > MAX_CERTIFICATE_PASSWORD_BYTES) fail(LoTWProblem.CERTIFICATE_PASSWORD)
|
||||||
|
val key = LoTWKeyMaterial.read(bundle.p12, password, now())
|
||||||
|
val bytes = encodeBundle(key.info, bundle.p12, passwordBytes)
|
||||||
|
try { storage.write("certificate", bytes) } finally { bytes.fill(0) }
|
||||||
|
discardPreview()
|
||||||
|
key.info.copy(passwordSaved = true)
|
||||||
|
} finally {
|
||||||
|
bundle.p12.fill(0)
|
||||||
|
bundle.password?.fill(0)
|
||||||
|
passwordBytes.fill(0)
|
||||||
|
password.fill('\u0000')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun station(): LoTWStation? = withContext(Dispatchers.IO) {
|
||||||
|
mutex.withLock { readStation() }
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun saveStation(station: LoTWStation): LoTWStation = withContext(Dispatchers.IO) {
|
||||||
|
mutex.withLock {
|
||||||
|
val stored = storage.read("certificate") ?: fail(LoTWProblem.CERTIFICATE_MISSING)
|
||||||
|
val bundle = try { readBundle(stored) } finally { stored.fill(0) }
|
||||||
|
val normalized = station.normalized()
|
||||||
|
try { config().stationFields(normalized, bundle.info.dxcc) }
|
||||||
|
finally {
|
||||||
|
bundle.p12.fill(0)
|
||||||
|
bundle.password?.fill(0)
|
||||||
|
}
|
||||||
|
writeStation(normalized)
|
||||||
|
discardPreview()
|
||||||
|
normalized
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun removeCertificate() = withContext(Dispatchers.IO) {
|
||||||
|
mutex.withLock { discardPreview(); storage.delete("certificate") }
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun audit(records: List<QsoRecord>): LoTWUploadAudit = withContext(Dispatchers.IO) {
|
||||||
|
mutex.withLock {
|
||||||
|
val signing = signingContext()
|
||||||
|
val ledger = ledger()
|
||||||
|
val unique = hashSetOf<String>()
|
||||||
|
var pendingCount = 0
|
||||||
|
var uploaded = 0
|
||||||
|
var unknown = 0
|
||||||
|
var unavailable = 0
|
||||||
|
records.sortedBy { it.startUtcMillis }.forEach { record ->
|
||||||
|
coroutineContext.ensureActive()
|
||||||
|
when {
|
||||||
|
record.status != QsoStatus.COMPLETE -> unavailable++
|
||||||
|
record.lotwReceived -> uploaded++
|
||||||
|
else -> {
|
||||||
|
val contact = try {
|
||||||
|
signing.signer.contact(record, signing.key, signing.location, now())
|
||||||
|
} catch (_: LoTWOperationException) {
|
||||||
|
unavailable++
|
||||||
|
null
|
||||||
|
}
|
||||||
|
if (contact != null) when {
|
||||||
|
!unique.add(contact.fingerprint) -> unavailable++
|
||||||
|
ledger[contact.fingerprint] == "accepted" -> uploaded++
|
||||||
|
ledger[contact.fingerprint] == "unknown" -> unknown++
|
||||||
|
else -> pendingCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
LoTWUploadAudit(records.size, pendingCount, uploaded, unknown, unavailable)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun prepare(records: List<QsoRecord>, resubmit: Boolean): LoTWUploadPreview = withContext(Dispatchers.IO) {
|
||||||
|
mutex.withLock {
|
||||||
|
discardPreview()
|
||||||
|
if (records.size > 10_000) fail(LoTWProblem.TOO_MANY_CONTACTS)
|
||||||
|
val signing = signingContext()
|
||||||
|
val ledger = ledger()
|
||||||
|
var skipped = 0
|
||||||
|
var unknown = 0
|
||||||
|
val unique = hashSetOf<String>()
|
||||||
|
val contacts = records.sortedBy { it.startUtcMillis }.mapNotNull { record ->
|
||||||
|
coroutineContext.ensureActive()
|
||||||
|
if (record.status != QsoStatus.COMPLETE || (record.lotwReceived && !resubmit)) { skipped++; return@mapNotNull null }
|
||||||
|
val contact = signing.signer.contact(record, signing.key, signing.location, now())
|
||||||
|
val previous = ledger[contact.fingerprint]
|
||||||
|
when {
|
||||||
|
(previous == "accepted" && !resubmit) || !unique.add(contact.fingerprint) -> { skipped++; null }
|
||||||
|
previous == "unknown" && !resubmit -> { skipped++; unknown++; null }
|
||||||
|
else -> contact
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val preview = LoTWUploadPreview(
|
||||||
|
UUID.randomUUID().toString(), signing.key.info.callsign, signing.key.info.dxcc, signing.location.getValue("GRIDSQUARE"),
|
||||||
|
contacts.size, skipped,
|
||||||
|
contacts.firstOrNull()?.record?.let { utc(it.startUtcMillis, "yyyy-MM-dd HH:mm:ss") }.orEmpty(),
|
||||||
|
contacts.lastOrNull()?.record?.let { utc(it.startUtcMillis, "yyyy-MM-dd HH:mm:ss") }.orEmpty(),
|
||||||
|
contacts.map { "${utc(it.record.startUtcMillis, "MM-dd HH:mm")} ${it.record.theirCallsign} ${it.fields["MODE"]} ${it.fields["SAT_NAME"].orEmpty()}" },
|
||||||
|
unknown
|
||||||
|
)
|
||||||
|
if (contacts.isNotEmpty()) pending = Pending(
|
||||||
|
preview,
|
||||||
|
signing.signer.sign(contacts, signing.key, signing.location),
|
||||||
|
contacts.map { it.fingerprint },
|
||||||
|
now()
|
||||||
|
)
|
||||||
|
preview
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun upload(previewId: String): LoTWUploadResult = withContext(Dispatchers.IO) {
|
||||||
|
mutex.withLock {
|
||||||
|
val payload = pending?.takeIf { it.preview.id == previewId && now() - it.created in 0..900_000 }
|
||||||
|
?: return@withLock LoTWUploadResult.ExpiredPreview
|
||||||
|
pending = null // A preview is a one-shot authorization, including across failures.
|
||||||
|
val ledger = ledger()
|
||||||
|
payload.hashes.forEach { ledger[it] = "unknown" }
|
||||||
|
saveLedger(ledger) // Persist BEFORE the POST, so process death/cancellation cannot cause an automatic retry.
|
||||||
|
try {
|
||||||
|
val result = post(payload.data, payload.preview.count)
|
||||||
|
when (result) {
|
||||||
|
is LoTWUploadResult.Accepted -> payload.hashes.forEach { ledger[it] = "accepted" }
|
||||||
|
is LoTWUploadResult.Rejected -> payload.hashes.forEach { ledger.remove(it) }
|
||||||
|
else -> Unit
|
||||||
|
}
|
||||||
|
saveLedger(ledger)
|
||||||
|
result
|
||||||
|
} finally { payload.data.fill(0) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun discardPreview() { pending?.data?.fill(0); pending = null }
|
||||||
|
|
||||||
|
private fun signingContext(): SigningContext {
|
||||||
|
val stored = storage.read("certificate") ?: fail(LoTWProblem.CERTIFICATE_MISSING)
|
||||||
|
val bundle = try { readBundle(stored) } finally { stored.fill(0) }
|
||||||
|
val passwordBytes = bundle.password ?: run {
|
||||||
|
bundle.p12.fill(0)
|
||||||
|
fail(LoTWProblem.CERTIFICATE_PASSWORD)
|
||||||
|
}
|
||||||
|
val password = decodePassword(passwordBytes)
|
||||||
|
return try {
|
||||||
|
val key = LoTWKeyMaterial.read(bundle.p12, password, now())
|
||||||
|
val tqslConfig = config()
|
||||||
|
val signer = LoTWSigner(tqslConfig)
|
||||||
|
val station = readStation() ?: fail(LoTWProblem.STATION_GRID)
|
||||||
|
SigningContext(key, signer, tqslConfig.stationFields(station, key.info.dxcc))
|
||||||
|
} finally {
|
||||||
|
bundle.p12.fill(0)
|
||||||
|
passwordBytes.fill(0)
|
||||||
|
password.fill('\u0000')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun readStation(): LoTWStation? = storage.read("station")?.inputStream()?.let { stream ->
|
||||||
|
DataInputStream(stream).use { input ->
|
||||||
|
require(input.readInt() == 1)
|
||||||
|
LoTWStation(input.readUTF(), input.readUTF(), input.readUTF(), input.readUTF(), input.readUTF(), input.readUTF())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun writeStation(station: LoTWStation) {
|
||||||
|
val bytes = ByteArrayOutputStream().also { stream -> DataOutputStream(stream).use { output ->
|
||||||
|
output.writeInt(1)
|
||||||
|
listOf(station.grid, station.cqZone, station.ituZone, station.region, station.county, station.iota).forEach(output::writeUTF)
|
||||||
|
} }.toByteArray()
|
||||||
|
try { storage.write("station", bytes) } finally { bytes.fill(0) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun post(data: ByteArray, count: Int): LoTWUploadResult = suspendCancellableCoroutine { continuation ->
|
||||||
|
val body = MultipartBody.Builder().setType(MultipartBody.FORM)
|
||||||
|
.addFormDataPart("upfile", "look4sat-${UUID.randomUUID()}.tq8", data.toRequestBody("application/octet-stream".toMediaType())).build()
|
||||||
|
val call = client.newCall(Request.Builder().url("https://lotw.arrl.org/lotw/upload").post(body).build())
|
||||||
|
continuation.invokeOnCancellation { call.cancel() }
|
||||||
|
call.enqueue(object : Callback {
|
||||||
|
override fun onFailure(call: Call, e: IOException) {
|
||||||
|
if (continuation.isActive) continuation.resume(LoTWUploadResult.Unknown)
|
||||||
|
}
|
||||||
|
override fun onResponse(call: Call, response: Response) {
|
||||||
|
val result = try {
|
||||||
|
response.use {
|
||||||
|
if (!it.isSuccessful) LoTWUploadResult.Unknown else parseLoTWUploadResponse(readBoundedBody(it, 256 * 1024), count)
|
||||||
|
}
|
||||||
|
} catch (_: Exception) { LoTWUploadResult.Unknown }
|
||||||
|
if (continuation.isActive) continuation.resume(result)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ledger(): MutableMap<String, String> = storage.read("receipts")?.inputStream()?.let { stream ->
|
||||||
|
DataInputStream(stream).use { input ->
|
||||||
|
require(input.readInt() == 1)
|
||||||
|
val size = input.readInt().also { require(it in 0..200_000) }
|
||||||
|
val entries = linkedMapOf<String, String>()
|
||||||
|
repeat(size) { entries[input.readUTF()] = input.readUTF() }
|
||||||
|
entries
|
||||||
|
}
|
||||||
|
} ?: linkedMapOf()
|
||||||
|
|
||||||
|
private fun saveLedger(entries: Map<String, String>) {
|
||||||
|
val bytes = ByteArrayOutputStream().also { stream -> DataOutputStream(stream).use { output ->
|
||||||
|
output.writeInt(1); output.writeInt(entries.size)
|
||||||
|
entries.forEach { (hash, status) -> output.writeUTF(hash); output.writeUTF(status) }
|
||||||
|
} }.toByteArray()
|
||||||
|
storage.write("receipts", bytes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun parseLoTWUploadResponse(body: String, count: Int): LoTWUploadResult {
|
||||||
|
val markers = Regex("<!--\\s*\\.UPL\\.\\s*(accepted|rejected)\\s*-->", RegexOption.IGNORE_CASE)
|
||||||
|
.findAll(body).map { it.groupValues[1].lowercase() }.toList()
|
||||||
|
return when (markers.singleOrNull()) {
|
||||||
|
"accepted" -> LoTWUploadResult.Accepted(count)
|
||||||
|
"rejected" -> {
|
||||||
|
val message = Regex("<!--\\s*\\.UPLMESSAGE\\.\\s*(.*?)-->", setOf(RegexOption.IGNORE_CASE, RegexOption.DOT_MATCHES_ALL))
|
||||||
|
.find(body)?.groupValues?.get(1).orEmpty().replace(Regex("<[^>]*>"), " ")
|
||||||
|
.replace(Regex("[\\p{Cc}\\s]+"), " ").trim().take(500)
|
||||||
|
LoTWUploadResult.Rejected(message)
|
||||||
|
}
|
||||||
|
else -> LoTWUploadResult.Unknown
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private data class StoredCertificate(val info: LoTWCertificate, val p12: ByteArray, val password: ByteArray?)
|
||||||
|
|
||||||
|
private fun encodeBundle(info: LoTWCertificate, p12: ByteArray, password: ByteArray): ByteArray = ByteArrayOutputStream().also { stream ->
|
||||||
|
DataOutputStream(stream).use { output ->
|
||||||
|
output.writeInt(2)
|
||||||
|
output.writeUTF(info.callsign); output.writeInt(info.dxcc); output.writeUTF(info.serial)
|
||||||
|
output.writeUTF(info.expires); output.writeUTF(info.firstQsoDate); output.writeUTF(info.lastQsoDate)
|
||||||
|
output.writeInt(password.size); output.write(password)
|
||||||
|
output.writeInt(p12.size); output.write(p12)
|
||||||
|
}
|
||||||
|
}.toByteArray()
|
||||||
|
|
||||||
|
private fun readBundle(bytes: ByteArray): StoredCertificate = DataInputStream(bytes.inputStream()).use { input ->
|
||||||
|
val version = input.readInt().also { require(it in 1..2) }
|
||||||
|
val info = LoTWCertificate(input.readUTF(), input.readInt(), input.readUTF(), input.readUTF(), input.readUTF(), input.readUTF())
|
||||||
|
val password = if (version >= 2) {
|
||||||
|
val size = input.readInt().also { require(it in 0..MAX_CERTIFICATE_PASSWORD_BYTES) }
|
||||||
|
ByteArray(size).also { input.readFully(it) }
|
||||||
|
} else null
|
||||||
|
val p12Size = input.readInt().also { require(it in 1..MAX_CERTIFICATE_BYTES) }
|
||||||
|
StoredCertificate(info, ByteArray(p12Size).also { input.readFully(it) }, password)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun encodePassword(password: CharArray): ByteArray {
|
||||||
|
val buffer = Charsets.UTF_8.encode(CharBuffer.wrap(password))
|
||||||
|
return ByteArray(buffer.remaining()).also(buffer::get)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun decodePassword(password: ByteArray): CharArray {
|
||||||
|
val buffer = Charsets.UTF_8.decode(ByteBuffer.wrap(password))
|
||||||
|
return CharArray(buffer.remaining()).also(buffer::get)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun LoTWStation.normalized() = LoTWStation(
|
||||||
|
grid.trim().uppercase(Locale.US),
|
||||||
|
cqZone.trim(),
|
||||||
|
ituZone.trim(),
|
||||||
|
region.trim().uppercase(Locale.US),
|
||||||
|
county.trim().uppercase(Locale.US),
|
||||||
|
iota.trim().uppercase(Locale.US)
|
||||||
|
)
|
||||||
|
|
||||||
|
private const val MAX_CERTIFICATE_PASSWORD_BYTES = 16 * 1024
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
/*
|
||||||
|
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||||
|
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*/
|
||||||
|
package com.rtbishop.look4sat.core.data.repository
|
||||||
|
|
||||||
|
import com.rtbishop.look4sat.core.data.database.QsoDao
|
||||||
|
import com.rtbishop.look4sat.core.data.database.entity.QsoEntity
|
||||||
|
import com.rtbishop.look4sat.core.domain.logbook.AdifCodec
|
||||||
|
import com.rtbishop.look4sat.core.domain.logbook.AdifImportResult
|
||||||
|
import com.rtbishop.look4sat.core.domain.logbook.IQsoRepository
|
||||||
|
import com.rtbishop.look4sat.core.domain.logbook.QsoRecord
|
||||||
|
import com.rtbishop.look4sat.core.domain.logbook.QsoStatus
|
||||||
|
import com.rtbishop.look4sat.core.domain.logbook.sameConfirmedContact
|
||||||
|
import com.rtbishop.look4sat.core.domain.logbook.withConfirmation
|
||||||
|
import com.rtbishop.look4sat.core.domain.logbook.confirmationLookupKey
|
||||||
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
import kotlinx.coroutines.sync.Mutex
|
||||||
|
import kotlinx.coroutines.sync.withLock
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
|
class QsoRepository(
|
||||||
|
private val dao: QsoDao,
|
||||||
|
private val dispatcher: CoroutineDispatcher
|
||||||
|
) : IQsoRepository {
|
||||||
|
private val importMutex = Mutex()
|
||||||
|
|
||||||
|
override val records: Flow<List<QsoRecord>> = dao.observeAll().map { records -> records.map(QsoEntity::toDomain) }
|
||||||
|
|
||||||
|
override suspend fun find(id: Long): QsoRecord? = withContext(dispatcher) { dao.find(id)?.toDomain() }
|
||||||
|
|
||||||
|
override suspend fun save(record: QsoRecord): Long = withContext(dispatcher) {
|
||||||
|
importMutex.withLock {
|
||||||
|
val previous = if (record.id != 0L) dao.find(record.id)?.toDomain() else null
|
||||||
|
val saved = if (previous?.lotwConfirmed == true && sameConfirmedContact(record, previous)) {
|
||||||
|
record.withConfirmation(previous)
|
||||||
|
} else if (previous?.lotwConfirmed == true) record.copy(
|
||||||
|
lotwConfirmed = false, lotwQslDate = "", vuccGrids = emptyList(),
|
||||||
|
dxcc = null, country = "", cqZone = null, region = ""
|
||||||
|
) else record
|
||||||
|
val received = if (previous != null && (
|
||||||
|
stableQsoKey(record) != stableQsoKey(previous) || record.myGrid != previous.myGrid ||
|
||||||
|
record.rxFrequencyHz != previous.rxFrequencyHz || record.band != previous.band ||
|
||||||
|
record.rxBand != previous.rxBand || record.propagationMode != previous.propagationMode
|
||||||
|
)) false else saved.lotwReceived
|
||||||
|
dao.save(saved.copy(lotwReceived = received).toEntity())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun delete(id: Long) = withContext(dispatcher) { importMutex.withLock { dao.delete(id) } }
|
||||||
|
|
||||||
|
override suspend fun exportAdi(ids: Set<Long>?, includeIncomplete: Boolean): String = withContext(dispatcher) {
|
||||||
|
val records = dao.getAll()
|
||||||
|
.asSequence()
|
||||||
|
.filter { ids == null || it.id in ids }
|
||||||
|
.map(QsoEntity::toDomain)
|
||||||
|
.filter { includeIncomplete || it.status == QsoStatus.COMPLETE }
|
||||||
|
.toList()
|
||||||
|
AdifCodec.encode(records)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun importAdi(content: String): AdifImportResult = withContext(dispatcher) {
|
||||||
|
importMutex.withLock {
|
||||||
|
mergeRecords(AdifCodec.decode(content))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun mergeConfirmed(records: List<QsoRecord>): AdifImportResult = withContext(dispatcher) {
|
||||||
|
importMutex.withLock { mergeRecords(records.filter { it.lotwConfirmed }) }
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun mergeLoTW(records: List<QsoRecord>): AdifImportResult = withContext(dispatcher) {
|
||||||
|
importMutex.withLock { mergeRecords(records, fromLoTW = true) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun mergeRecords(records: List<QsoRecord>, fromLoTW: Boolean = false): AdifImportResult {
|
||||||
|
val working = dao.getAll().map(QsoEntity::toDomain).toMutableList()
|
||||||
|
val lookup = working.indices.groupBy { working[it].confirmationLookupKey() }
|
||||||
|
.mapValues { it.value.toMutableList() }.toMutableMap()
|
||||||
|
val knownKeys = working.mapTo(mutableSetOf(), ::stableQsoKey)
|
||||||
|
val changes = linkedMapOf<Int, QsoRecord>()
|
||||||
|
var imported = 0
|
||||||
|
var updated = 0
|
||||||
|
var skipped = 0
|
||||||
|
records.forEach { remote ->
|
||||||
|
if (!fromLoTW && !remote.lotwConfirmed && stableQsoKey(remote) in knownKeys) { skipped++; return@forEach }
|
||||||
|
val candidates = if (fromLoTW || remote.lotwConfirmed) lookup[remote.confirmationLookupKey()].orEmpty()
|
||||||
|
.filter { sameConfirmedContact(working[it], remote) } else emptyList()
|
||||||
|
val exact = candidates.filter { working[it].startUtcMillis == remote.startUtcMillis }
|
||||||
|
val match = exact.singleOrNull() ?: candidates.singleOrNull()
|
||||||
|
if (match == null) {
|
||||||
|
if (stableQsoKey(remote) in knownKeys) { skipped++; return@forEach }
|
||||||
|
val added = remote.copy(id = 0L)
|
||||||
|
changes[working.size] = added
|
||||||
|
lookup.getOrPut(added.confirmationLookupKey()) { mutableListOf() }.add(working.size)
|
||||||
|
knownKeys += stableQsoKey(added)
|
||||||
|
working += added
|
||||||
|
imported++
|
||||||
|
} else {
|
||||||
|
val previous = working[match]
|
||||||
|
val merged = previous.withConfirmation(remote)
|
||||||
|
if (merged == previous) skipped++ else {
|
||||||
|
working[match] = merged
|
||||||
|
changes[match] = merged
|
||||||
|
updated++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dao.saveBatch(changes.values.map(QsoRecord::toEntity))
|
||||||
|
return AdifImportResult(imported, skipped, updated)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun QsoEntity.toDomain() = QsoRecord(
|
||||||
|
id = id,
|
||||||
|
startUtcMillis = startUtcMillis,
|
||||||
|
endUtcMillis = endUtcMillis,
|
||||||
|
theirCallsign = theirCallsign,
|
||||||
|
myCallsign = myCallsign,
|
||||||
|
theirGrid = theirGrid,
|
||||||
|
myGrid = myGrid,
|
||||||
|
sentReport = sentReport,
|
||||||
|
receivedReport = receivedReport,
|
||||||
|
txFrequencyHz = txFrequencyHz,
|
||||||
|
rxFrequencyHz = rxFrequencyHz,
|
||||||
|
band = band,
|
||||||
|
rxBand = rxBand,
|
||||||
|
mode = mode,
|
||||||
|
submode = submode,
|
||||||
|
satelliteName = satelliteName,
|
||||||
|
transponderName = transponderName,
|
||||||
|
satelliteMode = satelliteMode,
|
||||||
|
passAosUtcMillis = passAosUtcMillis,
|
||||||
|
automatic = automatic,
|
||||||
|
status = runCatching { QsoStatus.valueOf(status) }.getOrDefault(QsoStatus.DRAFT),
|
||||||
|
propagationMode = propagationMode,
|
||||||
|
lotwConfirmed = lotwConfirmed,
|
||||||
|
lotwReceived = lotwReceived,
|
||||||
|
lotwQslDate = lotwQslDate,
|
||||||
|
vuccGrids = vuccGrids.split(',').filter(String::isNotBlank),
|
||||||
|
dxcc = dxcc,
|
||||||
|
country = country,
|
||||||
|
cqZone = cqZone,
|
||||||
|
region = region,
|
||||||
|
comment = comment
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun QsoRecord.toEntity() = QsoEntity(
|
||||||
|
id = id,
|
||||||
|
startUtcMillis = startUtcMillis,
|
||||||
|
endUtcMillis = endUtcMillis,
|
||||||
|
theirCallsign = theirCallsign.trim().uppercase(Locale.US),
|
||||||
|
myCallsign = myCallsign.trim().uppercase(Locale.US),
|
||||||
|
theirGrid = theirGrid.trim().uppercase(Locale.US),
|
||||||
|
myGrid = myGrid.trim().uppercase(Locale.US),
|
||||||
|
sentReport = sentReport.trim(),
|
||||||
|
receivedReport = receivedReport.trim(),
|
||||||
|
txFrequencyHz = txFrequencyHz,
|
||||||
|
rxFrequencyHz = rxFrequencyHz,
|
||||||
|
band = band,
|
||||||
|
rxBand = rxBand,
|
||||||
|
mode = mode.trim(),
|
||||||
|
submode = submode.trim(),
|
||||||
|
satelliteName = satelliteName,
|
||||||
|
transponderName = transponderName,
|
||||||
|
satelliteMode = satelliteMode,
|
||||||
|
passAosUtcMillis = passAosUtcMillis,
|
||||||
|
automatic = automatic,
|
||||||
|
status = status.name,
|
||||||
|
dedupeKey = stableQsoKey(this),
|
||||||
|
propagationMode = propagationMode.ifBlank { if (satelliteName.isNotBlank()) "SAT" else "" },
|
||||||
|
lotwConfirmed = lotwConfirmed,
|
||||||
|
lotwReceived = lotwReceived,
|
||||||
|
lotwQslDate = lotwQslDate,
|
||||||
|
vuccGrids = vuccGrids.joinToString(","),
|
||||||
|
dxcc = dxcc,
|
||||||
|
country = country,
|
||||||
|
cqZone = cqZone,
|
||||||
|
region = region,
|
||||||
|
comment = comment
|
||||||
|
)
|
||||||
|
|
||||||
|
internal fun stableQsoKey(record: QsoRecord): String = listOf(
|
||||||
|
record.startUtcMillis.toString(),
|
||||||
|
record.theirCallsign.trim().uppercase(Locale.US),
|
||||||
|
record.myCallsign.trim().uppercase(Locale.US),
|
||||||
|
record.txFrequencyHz?.toString().orEmpty(),
|
||||||
|
record.mode.trim().uppercase(Locale.US),
|
||||||
|
record.submode.trim().uppercase(Locale.US),
|
||||||
|
record.satelliteName.trim().uppercase(Locale.US)
|
||||||
|
).joinToString("|")
|
||||||
@@ -877,6 +877,31 @@ class SettingsRepo(
|
|||||||
}
|
}
|
||||||
//endregion
|
//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
|
//region # AMSAT status report settings
|
||||||
private val keyAmSatCallsign = "amSatCallsign"
|
private val keyAmSatCallsign = "amSatCallsign"
|
||||||
|
|
||||||
|
|||||||
@@ -298,6 +298,8 @@ private class FakeSettingsRepo(dataSources: DataSourcesSettings = defaultDataSou
|
|||||||
override fun updateRadioControlSettings(settings: RadioControlSettings) = Unit
|
override fun updateRadioControlSettings(settings: RadioControlSettings) = Unit
|
||||||
|
|
||||||
override fun getSatelliteOffset(catnum: Int): String = ""
|
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 setSatelliteOffset(catnum: Int, offset: String) = Unit
|
||||||
|
|
||||||
|
|||||||
+2
@@ -343,6 +343,8 @@ private class FakeSettingsRepoForSearch : ISettingsRepo {
|
|||||||
override fun updateDataSourcesStatus(status: Map<String, Int>) = Unit
|
override fun updateDataSourcesStatus(status: Map<String, Int>) = Unit
|
||||||
override fun updateRadioControlSettings(settings: RadioControlSettings) = Unit
|
override fun updateRadioControlSettings(settings: RadioControlSettings) = Unit
|
||||||
override fun getSatelliteOffset(catnum: Int): String = ""
|
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 setSatelliteOffset(catnum: Int, offset: String) = Unit
|
||||||
override fun getAmSatCallsign(): String = ""
|
override fun getAmSatCallsign(): String = ""
|
||||||
override fun setAmSatCallsign(callsign: String) = Unit
|
override fun setAmSatCallsign(callsign: String) = Unit
|
||||||
|
|||||||
@@ -0,0 +1,163 @@
|
|||||||
|
/*
|
||||||
|
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||||
|
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*/
|
||||||
|
package com.rtbishop.look4sat.core.domain.logbook
|
||||||
|
|
||||||
|
import java.text.SimpleDateFormat
|
||||||
|
import java.util.Date
|
||||||
|
import java.util.Locale
|
||||||
|
import java.util.TimeZone
|
||||||
|
|
||||||
|
/** Small ADI codec for the logbook's actual field set (ADIF 3.1.7). */
|
||||||
|
object AdifCodec {
|
||||||
|
private val utc = TimeZone.getTimeZone("UTC")
|
||||||
|
|
||||||
|
fun encode(records: List<QsoRecord>): String = buildString {
|
||||||
|
append(field("ADIF_VER", "3.1.7"))
|
||||||
|
append(field("PROGRAMID", "Look4Sat"))
|
||||||
|
append("<EOH>\r\n")
|
||||||
|
records.forEach { record ->
|
||||||
|
val startDate = format(record.startUtcMillis, "yyyyMMdd")
|
||||||
|
val startTime = format(record.startUtcMillis, "HHmmss")
|
||||||
|
append(field("QSO_DATE", startDate))
|
||||||
|
append(field("TIME_ON", startTime))
|
||||||
|
record.endUtcMillis?.let {
|
||||||
|
append(field("QSO_DATE_OFF", format(it, "yyyyMMdd")))
|
||||||
|
append(field("TIME_OFF", format(it, "HHmmss")))
|
||||||
|
}
|
||||||
|
append(field("CALL", record.theirCallsign))
|
||||||
|
append(field("STATION_CALLSIGN", record.myCallsign))
|
||||||
|
appendOptional("MODE", record.mode)
|
||||||
|
appendOptional("SUBMODE", record.submode)
|
||||||
|
appendOptional("GRIDSQUARE", record.theirGrid)
|
||||||
|
appendOptional("MY_GRIDSQUARE", record.myGrid)
|
||||||
|
appendOptional("RST_SENT", record.sentReport)
|
||||||
|
appendOptional("RST_RCVD", record.receivedReport)
|
||||||
|
record.txFrequencyHz?.let { append(field("FREQ", hzToMhz(it))) }
|
||||||
|
record.rxFrequencyHz?.let { append(field("FREQ_RX", hzToMhz(it))) }
|
||||||
|
appendOptional("BAND", record.band)
|
||||||
|
appendOptional("BAND_RX", record.rxBand)
|
||||||
|
appendOptional("PROP_MODE", record.propagationMode.ifBlank { if (record.satelliteName.isNotBlank()) "SAT" else "" })
|
||||||
|
appendOptional("SAT_NAME", record.satelliteName)
|
||||||
|
appendOptional("SAT_MODE", record.satelliteMode)
|
||||||
|
if (record.lotwConfirmed) append(field("LOTW_QSL_RCVD", "Y"))
|
||||||
|
if (record.lotwReceived) append(field("LOTW_QSL_SENT", "Y"))
|
||||||
|
appendOptional("LOTW_QSLRDATE", record.lotwQslDate)
|
||||||
|
appendOptional("VUCC_GRIDS", record.vuccGrids.joinToString(","))
|
||||||
|
record.dxcc?.let { append(field("DXCC", it.toString())) }
|
||||||
|
appendOptional("COUNTRY", record.country)
|
||||||
|
record.cqZone?.let { append(field("CQZ", it.toString())) }
|
||||||
|
appendOptional("STATE", record.region)
|
||||||
|
appendOptional("COMMENT", record.comment)
|
||||||
|
appendOptional("APP_LOOK4SAT_TRANSPONDER", record.transponderName)
|
||||||
|
record.passAosUtcMillis?.let { append(field("APP_LOOK4SAT_PASS_AOS", it.toString())) }
|
||||||
|
append(field("APP_LOOK4SAT_AUTOMATIC", if (record.automatic) "Y" else "N"))
|
||||||
|
append(field("APP_LOOK4SAT_STATUS", record.status.name))
|
||||||
|
append("<EOR>\r\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun decode(content: String): List<QsoRecord> = decodeRecords(splitRecords(content))
|
||||||
|
|
||||||
|
fun decodeRecords(records: List<Map<String, String>>): List<QsoRecord> = records.mapNotNull { values ->
|
||||||
|
val call = values["CALL"].orEmpty().trim().uppercase(Locale.US)
|
||||||
|
val date = values["QSO_DATE"].orEmpty()
|
||||||
|
val time = values["TIME_ON"].orEmpty()
|
||||||
|
if (call.isBlank() || date.length != 8 || time.length !in setOf(4, 6)) return@mapNotNull null
|
||||||
|
val start = parse(date, time) ?: return@mapNotNull null
|
||||||
|
val end = values["QSO_DATE_OFF"]?.let { endDate ->
|
||||||
|
values["TIME_OFF"]?.let { endTime -> parse(endDate, endTime) }
|
||||||
|
}
|
||||||
|
QsoRecord(
|
||||||
|
startUtcMillis = start,
|
||||||
|
endUtcMillis = end,
|
||||||
|
theirCallsign = call,
|
||||||
|
myCallsign = values["STATION_CALLSIGN"].orEmpty().uppercase(Locale.US),
|
||||||
|
theirGrid = values["GRIDSQUARE"].orEmpty().uppercase(Locale.US),
|
||||||
|
myGrid = values["MY_GRIDSQUARE"].orEmpty().uppercase(Locale.US),
|
||||||
|
sentReport = values["RST_SENT"].orEmpty(),
|
||||||
|
receivedReport = values["RST_RCVD"].orEmpty(),
|
||||||
|
txFrequencyHz = mhzToHz(values["FREQ"]),
|
||||||
|
rxFrequencyHz = mhzToHz(values["FREQ_RX"]),
|
||||||
|
band = values["BAND"].orEmpty(),
|
||||||
|
rxBand = values["BAND_RX"].orEmpty(),
|
||||||
|
mode = values["MODE"].orEmpty(),
|
||||||
|
submode = values["SUBMODE"].orEmpty(),
|
||||||
|
satelliteName = values["SAT_NAME"].orEmpty(),
|
||||||
|
transponderName = values["APP_LOOK4SAT_TRANSPONDER"].orEmpty(),
|
||||||
|
satelliteMode = values["SAT_MODE"].orEmpty(),
|
||||||
|
passAosUtcMillis = values["APP_LOOK4SAT_PASS_AOS"]?.toLongOrNull(),
|
||||||
|
automatic = values["APP_LOOK4SAT_AUTOMATIC"].equals("Y", true),
|
||||||
|
status = values["APP_LOOK4SAT_STATUS"]?.let { runCatching { QsoStatus.valueOf(it) }.getOrNull() }
|
||||||
|
?: QsoStatus.COMPLETE,
|
||||||
|
propagationMode = values["PROP_MODE"].orEmpty().trim().uppercase(Locale.US),
|
||||||
|
lotwConfirmed = values["LOTW_QSL_RCVD"].equals("Y", true),
|
||||||
|
lotwReceived = values["LOTW_QSL_SENT"].equals("Y", true),
|
||||||
|
lotwQslDate = values["LOTW_QSLRDATE"].orEmpty(),
|
||||||
|
vuccGrids = values["VUCC_GRIDS"].orEmpty().split(',').map { it.trim().uppercase(Locale.US) }
|
||||||
|
.filter { it.matches(Regex("[A-R]{2}[0-9]{2}([A-X]{2}([0-9]{2})?)?")) }.distinct(),
|
||||||
|
dxcc = values["DXCC"]?.toIntOrNull(),
|
||||||
|
country = values["COUNTRY"].orEmpty(),
|
||||||
|
cqZone = values["CQZ"]?.toIntOrNull()?.takeIf { it in 1..40 },
|
||||||
|
region = values["STATE"].orEmpty().substringBefore(" // ").trim().uppercase(Locale.US),
|
||||||
|
comment = values["COMMENT"].orEmpty()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun splitRecords(content: String): List<Map<String, String>> {
|
||||||
|
val records = mutableListOf<Map<String, String>>()
|
||||||
|
var values = linkedMapOf<String, String>()
|
||||||
|
var index = 0
|
||||||
|
while (index < content.length) {
|
||||||
|
val start = content.indexOf('<', index)
|
||||||
|
if (start < 0) break
|
||||||
|
val end = content.indexOf('>', start + 1)
|
||||||
|
if (end < 0) break
|
||||||
|
val descriptor = content.substring(start + 1, end)
|
||||||
|
val parts = descriptor.split(':')
|
||||||
|
val name = parts.first().trim().uppercase(Locale.US)
|
||||||
|
index = end + 1
|
||||||
|
when (name) {
|
||||||
|
"EOH" -> values.clear()
|
||||||
|
"EOR" -> {
|
||||||
|
if (values.isNotEmpty()) records += values.toMap()
|
||||||
|
values = linkedMapOf()
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
val length = parts.getOrNull(1)?.toIntOrNull() ?: continue
|
||||||
|
if (length < 0 || index + length > content.length) continue
|
||||||
|
values[name] = content.substring(index, index + length)
|
||||||
|
index += length
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return records
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun StringBuilder.appendOptional(name: String, value: String) {
|
||||||
|
if (value.isNotBlank()) append(field(name, value))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun field(name: String, value: String): String = "<$name:${value.length}>$value"
|
||||||
|
|
||||||
|
private fun hzToMhz(value: Long): String = String.format(Locale.US, "%.6f", value / 1_000_000.0)
|
||||||
|
|
||||||
|
private fun mhzToHz(value: String?): Long? = value?.toDoubleOrNull()?.let { (it * 1_000_000.0).toLong() }
|
||||||
|
|
||||||
|
private fun format(value: Long, pattern: String): String =
|
||||||
|
SimpleDateFormat(pattern, Locale.US).apply { timeZone = utc }.format(Date(value))
|
||||||
|
|
||||||
|
private fun parse(date: String, time: String): Long? = runCatching {
|
||||||
|
val normalizedTime = if (time.length == 4) "${time}00" else time
|
||||||
|
SimpleDateFormat("yyyyMMddHHmmss", Locale.US).apply {
|
||||||
|
isLenient = false
|
||||||
|
timeZone = utc
|
||||||
|
}.parse(date + normalizedTime)?.time
|
||||||
|
}.getOrNull()
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
/*
|
||||||
|
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||||
|
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*/
|
||||||
|
package com.rtbishop.look4sat.core.domain.logbook
|
||||||
|
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
|
||||||
|
interface IQsoRepository {
|
||||||
|
val records: Flow<List<QsoRecord>>
|
||||||
|
|
||||||
|
suspend fun find(id: Long): QsoRecord?
|
||||||
|
suspend fun save(record: QsoRecord): Long
|
||||||
|
suspend fun delete(id: Long)
|
||||||
|
suspend fun exportAdi(ids: Set<Long>? = null, includeIncomplete: Boolean = false): String
|
||||||
|
suspend fun importAdi(content: String): AdifImportResult
|
||||||
|
suspend fun mergeConfirmed(records: List<QsoRecord>): AdifImportResult
|
||||||
|
suspend fun mergeLoTW(records: List<QsoRecord>): AdifImportResult
|
||||||
|
}
|
||||||
+25
@@ -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()
|
||||||
|
)
|
||||||
+65
@@ -0,0 +1,65 @@
|
|||||||
|
package com.rtbishop.look4sat.core.domain.logbook
|
||||||
|
|
||||||
|
import java.util.Locale
|
||||||
|
import kotlin.math.abs
|
||||||
|
|
||||||
|
/** LoTW official name vs SatNOGS name ("SO-50 (SaudiOSCAR 50)"): compare the
|
||||||
|
* parenthetical-free prefix so local records and downloaded confirmations match. */
|
||||||
|
fun satMatchKey(name: String): String = name.substringBefore('(').trim().uppercase(Locale.US)
|
||||||
|
|
||||||
|
/** LoTW omits frequency and may round time to a minute. Only merge an unambiguous contact. */
|
||||||
|
fun sameConfirmedContact(local: QsoRecord, remote: QsoRecord): Boolean =
|
||||||
|
local.theirCallsign.trim().equals(remote.theirCallsign.trim(), true) &&
|
||||||
|
(local.myCallsign.isBlank() || remote.myCallsign.isBlank() || local.myCallsign.equals(remote.myCallsign, true)) &&
|
||||||
|
satMatchKey(local.satelliteName) == satMatchKey(remote.satelliteName) &&
|
||||||
|
local.isSatellite == remote.isSatellite &&
|
||||||
|
local.displayMode == remote.displayMode &&
|
||||||
|
(local.band.isBlank() || remote.band.isBlank() || local.band.equals(remote.band, true)) &&
|
||||||
|
abs(local.startUtcMillis - remote.startUtcMillis) < 60_000L
|
||||||
|
|
||||||
|
fun QsoRecord.confirmationLookupKey(): String = listOf(
|
||||||
|
theirCallsign.trim().uppercase(Locale.US), satMatchKey(satelliteName), displayMode
|
||||||
|
).joinToString("|")
|
||||||
|
|
||||||
|
fun QsoRecord.withConfirmation(confirmed: QsoRecord): QsoRecord = copy(
|
||||||
|
myCallsign = myCallsign.ifBlank { confirmed.myCallsign },
|
||||||
|
theirGrid = confirmed.theirGrid.ifBlank { theirGrid },
|
||||||
|
myGrid = myGrid.ifBlank { confirmed.myGrid },
|
||||||
|
sentReport = sentReport.ifBlank { confirmed.sentReport },
|
||||||
|
receivedReport = receivedReport.ifBlank { confirmed.receivedReport },
|
||||||
|
txFrequencyHz = txFrequencyHz ?: confirmed.txFrequencyHz,
|
||||||
|
rxFrequencyHz = rxFrequencyHz ?: confirmed.rxFrequencyHz,
|
||||||
|
band = band.ifBlank { confirmed.band },
|
||||||
|
rxBand = rxBand.ifBlank { confirmed.rxBand },
|
||||||
|
propagationMode = propagationMode.ifBlank { confirmed.propagationMode },
|
||||||
|
status = QsoStatus.COMPLETE,
|
||||||
|
lotwConfirmed = lotwConfirmed || confirmed.lotwConfirmed,
|
||||||
|
lotwReceived = lotwReceived || confirmed.lotwReceived || confirmed.lotwConfirmed,
|
||||||
|
lotwQslDate = confirmed.lotwQslDate.ifBlank { lotwQslDate },
|
||||||
|
vuccGrids = confirmed.vuccGrids.ifEmpty { vuccGrids },
|
||||||
|
dxcc = confirmed.dxcc ?: dxcc,
|
||||||
|
country = confirmed.country.ifBlank { country },
|
||||||
|
cqZone = confirmed.cqZone ?: cqZone,
|
||||||
|
region = confirmed.region.ifBlank { region }
|
||||||
|
)
|
||||||
|
|
||||||
|
val QsoRecord.displayMode: String
|
||||||
|
get() = submode.ifBlank { mode }.trim().uppercase(Locale.US)
|
||||||
|
|
||||||
|
val QsoRecord.isSatellite: Boolean
|
||||||
|
get() = propagationMode.equals("SAT", true) || (propagationMode.isBlank() && satelliteName.isNotBlank())
|
||||||
|
|
||||||
|
fun frequencyBand(hz: Long?): String = when (hz) {
|
||||||
|
null -> ""
|
||||||
|
in 28_000_000L..29_700_000L -> "10M"
|
||||||
|
in 50_000_000L..54_000_000L -> "6M"
|
||||||
|
in 144_000_000L..148_000_000L -> "2M"
|
||||||
|
in 219_000_000L..225_000_000L -> "1.25M"
|
||||||
|
in 420_000_000L..450_000_000L -> "70CM"
|
||||||
|
in 902_000_000L..928_000_000L -> "33CM"
|
||||||
|
in 1_240_000_000L..1_300_000_000L -> "23CM"
|
||||||
|
in 2_300_000_000L..2_450_000_000L -> "13CM"
|
||||||
|
in 5_650_000_000L..5_925_000_000L -> "6CM"
|
||||||
|
in 10_000_000_000L..10_500_000_000L -> "3CM"
|
||||||
|
else -> ""
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
/*
|
||||||
|
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||||
|
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*/
|
||||||
|
package com.rtbishop.look4sat.core.domain.logbook
|
||||||
|
|
||||||
|
enum class QsoStatus { DRAFT, COMPLETE, ABORTED }
|
||||||
|
|
||||||
|
data class QsoRecord(
|
||||||
|
val id: Long = 0L,
|
||||||
|
val startUtcMillis: Long,
|
||||||
|
val endUtcMillis: Long? = null,
|
||||||
|
val theirCallsign: String,
|
||||||
|
val myCallsign: String,
|
||||||
|
val theirGrid: String = "",
|
||||||
|
val myGrid: String = "",
|
||||||
|
val sentReport: String = "",
|
||||||
|
val receivedReport: String = "",
|
||||||
|
val txFrequencyHz: Long? = null,
|
||||||
|
val rxFrequencyHz: Long? = null,
|
||||||
|
val band: String = "",
|
||||||
|
val rxBand: String = "",
|
||||||
|
val mode: String = "MFSK",
|
||||||
|
val submode: String = "FT4",
|
||||||
|
val satelliteName: String = "",
|
||||||
|
val transponderName: String = "",
|
||||||
|
val satelliteMode: String = "",
|
||||||
|
val passAosUtcMillis: Long? = null,
|
||||||
|
val automatic: Boolean = false,
|
||||||
|
val status: QsoStatus = QsoStatus.DRAFT,
|
||||||
|
val propagationMode: String = if (satelliteName.isNotBlank()) "SAT" else "",
|
||||||
|
val lotwConfirmed: Boolean = false,
|
||||||
|
val lotwReceived: Boolean = false,
|
||||||
|
val lotwQslDate: String = "",
|
||||||
|
val vuccGrids: List<String> = emptyList(),
|
||||||
|
val dxcc: Int? = null,
|
||||||
|
val country: String = "",
|
||||||
|
val cqZone: Int? = null,
|
||||||
|
val region: String = "",
|
||||||
|
val comment: String = ""
|
||||||
|
)
|
||||||
|
|
||||||
|
data class AdifImportResult(val imported: Int, val skipped: Int, val updated: Int = 0)
|
||||||
+74
@@ -0,0 +1,74 @@
|
|||||||
|
package com.rtbishop.look4sat.core.domain.repository
|
||||||
|
|
||||||
|
import com.rtbishop.look4sat.core.domain.logbook.QsoRecord
|
||||||
|
|
||||||
|
interface ILoTWUploadRepository {
|
||||||
|
suspend fun certificate(): LoTWCertificate?
|
||||||
|
suspend fun station(): LoTWStation?
|
||||||
|
suspend fun importCertificate(data: ByteArray, password: CharArray): LoTWCertificate
|
||||||
|
suspend fun saveCertificatePassword(password: CharArray): LoTWCertificate
|
||||||
|
suspend fun saveStation(station: LoTWStation): LoTWStation
|
||||||
|
suspend fun removeCertificate()
|
||||||
|
suspend fun audit(records: List<QsoRecord>): LoTWUploadAudit
|
||||||
|
suspend fun prepare(records: List<QsoRecord>, resubmit: Boolean): LoTWUploadPreview
|
||||||
|
suspend fun upload(previewId: String): LoTWUploadResult
|
||||||
|
fun discardPreview()
|
||||||
|
}
|
||||||
|
|
||||||
|
data class LoTWCertificate(
|
||||||
|
val callsign: String,
|
||||||
|
val dxcc: Int,
|
||||||
|
val serial: String,
|
||||||
|
val expires: String,
|
||||||
|
val firstQsoDate: String,
|
||||||
|
val lastQsoDate: String,
|
||||||
|
val passwordSaved: Boolean = false
|
||||||
|
)
|
||||||
|
|
||||||
|
/** The location is explicitly confirmed for the selected contacts, not inferred from today's GPS. */
|
||||||
|
data class LoTWStation(
|
||||||
|
val grid: String = "",
|
||||||
|
val cqZone: String = "",
|
||||||
|
val ituZone: String = "",
|
||||||
|
val region: String = "",
|
||||||
|
val county: String = "",
|
||||||
|
val iota: String = ""
|
||||||
|
)
|
||||||
|
|
||||||
|
data class LoTWUploadPreview(
|
||||||
|
val id: String,
|
||||||
|
val callsign: String,
|
||||||
|
val dxcc: Int,
|
||||||
|
val grid: String,
|
||||||
|
val count: Int,
|
||||||
|
val skipped: Int,
|
||||||
|
val firstUtc: String,
|
||||||
|
val lastUtc: String,
|
||||||
|
val contacts: List<String>,
|
||||||
|
val unknownSkipped: Int = 0
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Local comparison against downloaded LoTW receipt flags and this app's durable upload receipts. */
|
||||||
|
data class LoTWUploadAudit(
|
||||||
|
val total: Int,
|
||||||
|
val pending: Int,
|
||||||
|
val uploaded: Int,
|
||||||
|
val unknown: Int,
|
||||||
|
val unavailable: Int
|
||||||
|
)
|
||||||
|
|
||||||
|
sealed interface LoTWUploadResult {
|
||||||
|
data class Accepted(val count: Int) : LoTWUploadResult
|
||||||
|
data class Rejected(val message: String = "") : LoTWUploadResult
|
||||||
|
/** May have reached LoTW. Do not retry automatically. */
|
||||||
|
data object Unknown : LoTWUploadResult
|
||||||
|
data object ExpiredPreview : LoTWUploadResult
|
||||||
|
}
|
||||||
|
|
||||||
|
class LoTWOperationException(val reason: LoTWProblem, val detail: String = "") : Exception(reason.name)
|
||||||
|
|
||||||
|
enum class LoTWProblem {
|
||||||
|
CERTIFICATE_PASSWORD, CERTIFICATE_INVALID, CERTIFICATE_EXPIRED, CERTIFICATE_MISSING,
|
||||||
|
STORAGE, EMPTY_SELECTION, CALLSIGN_MISMATCH, QSO_DATE, STATION_GRID, STATION_REGION,
|
||||||
|
STATION_ZONE, STATION_IOTA, MODE, BAND, SATELLITE, INVALID_CONTACT, LOCATION_MISMATCH, TOO_MANY_CONTACTS
|
||||||
|
}
|
||||||
+3
@@ -16,6 +16,7 @@
|
|||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
package com.rtbishop.look4sat.core.domain.repository
|
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.model.SatRadio
|
||||||
import com.rtbishop.look4sat.core.domain.usecase.IAddToCalendar
|
import com.rtbishop.look4sat.core.domain.usecase.IAddToCalendar
|
||||||
import com.rtbishop.look4sat.core.domain.usecase.IAudioCapture
|
import com.rtbishop.look4sat.core.domain.usecase.IAudioCapture
|
||||||
@@ -34,6 +35,8 @@ interface IMainContainer {
|
|||||||
val updateRepo: IUpdateRepository
|
val updateRepo: IUpdateRepository
|
||||||
val wavelogRepo: IWavelogRepository
|
val wavelogRepo: IWavelogRepository
|
||||||
val lotwRepo: com.rtbishop.look4sat.core.domain.repository.ILoTWRepository
|
val lotwRepo: com.rtbishop.look4sat.core.domain.repository.ILoTWRepository
|
||||||
|
val qsoRepository: IQsoRepository
|
||||||
|
val lotwUploadRepository: ILoTWUploadRepository
|
||||||
val radioTrackingService: IRadioTrackingService
|
val radioTrackingService: IRadioTrackingService
|
||||||
val mutualPassData: StateFlow<MutualPassData>
|
val mutualPassData: StateFlow<MutualPassData>
|
||||||
fun setMutualPassData(data: MutualPassData)
|
fun setMutualPassData(data: MutualPassData)
|
||||||
|
|||||||
+6
@@ -89,6 +89,12 @@ interface ISettingsRepo {
|
|||||||
fun setSatelliteOffset(catnum: Int, offset: String)
|
fun setSatelliteOffset(catnum: Int, offset: String)
|
||||||
//endregion
|
//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
|
//region # AMSAT status report settings
|
||||||
fun getAmSatCallsign(): String
|
fun getAmSatCallsign(): String
|
||||||
fun setAmSatCallsign(callsign: String)
|
fun setAmSatCallsign(callsign: String)
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
/*
|
||||||
|
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||||
|
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*/
|
||||||
|
package com.rtbishop.look4sat.core.domain.logbook
|
||||||
|
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class AdifCodecTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun encodeDecode_roundTrip() {
|
||||||
|
val record = QsoRecord(
|
||||||
|
startUtcMillis = 1_700_000_000_000L,
|
||||||
|
endUtcMillis = 1_700_000_000_100L,
|
||||||
|
theirCallsign = "BG5JVM",
|
||||||
|
myCallsign = "BA7OPF",
|
||||||
|
theirGrid = "OM60",
|
||||||
|
myGrid = "OL62TI",
|
||||||
|
sentReport = "59",
|
||||||
|
receivedReport = "59",
|
||||||
|
txFrequencyHz = 145_850_000L,
|
||||||
|
rxFrequencyHz = 436_805_000L,
|
||||||
|
band = "2M",
|
||||||
|
rxBand = "70CM",
|
||||||
|
mode = "FM",
|
||||||
|
satelliteName = "SO-50",
|
||||||
|
satelliteMode = "FM",
|
||||||
|
propagationMode = "SAT",
|
||||||
|
status = QsoStatus.COMPLETE,
|
||||||
|
lotwConfirmed = true,
|
||||||
|
lotwQslDate = "2026-09-20",
|
||||||
|
vuccGrids = listOf("OM60", "OM50")
|
||||||
|
)
|
||||||
|
val decoded = AdifCodec.decode(AdifCodec.encode(listOf(record))).single()
|
||||||
|
assertEquals(record.startUtcMillis, decoded.startUtcMillis)
|
||||||
|
assertEquals(record.theirCallsign, decoded.theirCallsign)
|
||||||
|
assertEquals(record.myCallsign, decoded.myCallsign)
|
||||||
|
assertEquals(record.satelliteName, decoded.satelliteName)
|
||||||
|
assertEquals("2M", decoded.band)
|
||||||
|
assertEquals("70CM", decoded.rxBand)
|
||||||
|
assertTrue(decoded.lotwConfirmed)
|
||||||
|
assertEquals(listOf("OM60", "OM50"), decoded.vuccGrids)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun decode_lotwStyleReportFields() {
|
||||||
|
// LoTW report subset: lowercase eor, alphabetical fields, VUCC_GRIDS list.
|
||||||
|
val adi = """
|
||||||
|
<EOH>
|
||||||
|
<CALL:6>BH6RJD
|
||||||
|
<QSO_DATE:8>20260916
|
||||||
|
<TIME_ON:6>051300
|
||||||
|
<MODE:2>FM
|
||||||
|
<SAT_NAME:5>SO-50
|
||||||
|
<PROP_MODE:3>SAT
|
||||||
|
<BAND:3>2M
|
||||||
|
<BAND_RX:4>70CM
|
||||||
|
<GRIDSQUARE:4>OM60
|
||||||
|
<MY_GRIDSQUARE:8>OM60IL70
|
||||||
|
<VUCC_GRIDS:9>OM60,OM50
|
||||||
|
<STATION_CALLSIGN:6>BH6RJD
|
||||||
|
<DXCC:3>318
|
||||||
|
<COUNTRY:5>China
|
||||||
|
<LOTW_QSL_RCVD:1>Y
|
||||||
|
<eor>
|
||||||
|
""".trimIndent()
|
||||||
|
val records = AdifCodec.decode(adi)
|
||||||
|
assertEquals(1, records.size)
|
||||||
|
val record = records.single()
|
||||||
|
assertEquals("BH6RJD", record.theirCallsign)
|
||||||
|
assertEquals("SO-50", record.satelliteName)
|
||||||
|
assertEquals("FM", record.displayMode)
|
||||||
|
assertEquals("OM60IL70", record.myGrid)
|
||||||
|
assertEquals(listOf("OM60", "OM50"), record.vuccGrids)
|
||||||
|
assertTrue(record.lotwConfirmed)
|
||||||
|
assertTrue(record.isSatellite)
|
||||||
|
}
|
||||||
|
}
|
||||||
+126
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+2
@@ -285,6 +285,8 @@ class LoTWGridSyncTest {
|
|||||||
override fun updateDataSourcesStatus(status: Map<String, Int>) = TODO()
|
override fun updateDataSourcesStatus(status: Map<String, Int>) = TODO()
|
||||||
override fun updateRadioControlSettings(settings: RadioControlSettings) = TODO()
|
override fun updateRadioControlSettings(settings: RadioControlSettings) = TODO()
|
||||||
override fun getSatelliteOffset(catnum: Int): String = ""
|
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 setSatelliteOffset(catnum: Int, offset: String) = TODO()
|
||||||
override fun getAmSatCallsign(): String = ""
|
override fun getAmSatCallsign(): String = ""
|
||||||
override fun setAmSatCallsign(callsign: String) = TODO()
|
override fun setAmSatCallsign(callsign: String) = TODO()
|
||||||
|
|||||||
@@ -191,6 +191,24 @@
|
|||||||
<string name="lotw_sync_progress_qso">本次同步 %1$d 条 QSO,预计还需 %2$d 秒</string>
|
<string name="lotw_sync_progress_qso">本次同步 %1$d 条 QSO,预计还需 %2$d 秒</string>
|
||||||
<string name="prefs_locator_text">使用梅登黑德网格设置站点位置</string>
|
<string name="prefs_locator_text">使用梅登黑德网格设置站点位置</string>
|
||||||
|
|
||||||
|
<string name="prefs_logbook_title">日志本</string>
|
||||||
|
<string name="prefs_logbook_count">%1$d 条记录 — 本地通联与 LoTW 确认</string>
|
||||||
|
<string name="prefs_logbook_empty">暂无记录 — 在雷达页 Log 标签记录通联</string>
|
||||||
|
<string name="prefs_logbook_close">关闭</string>
|
||||||
|
<string name="prefs_lotw_upload_title">LoTW 上传</string>
|
||||||
|
<string name="prefs_lotw_upload_configured">证书已导入 · 台址网格 %1$s</string>
|
||||||
|
<string name="prefs_lotw_upload_not_configured">未配置 — 导入 TrustedQSL 证书后即可上传通联</string>
|
||||||
|
<string name="prefs_lotw_upload_busy">处理中…</string>
|
||||||
|
<string name="prefs_lotw_upload_cert_hint">导入你的 TrustedQSL .p12 证书(与 TQSL 同款)用于签名上传通联。密码仅加密保存在手机本地。</string>
|
||||||
|
<string name="prefs_lotw_upload_password">证书密码</string>
|
||||||
|
<string name="prefs_lotw_upload_import">选择 .p12 文件</string>
|
||||||
|
<string name="prefs_lotw_upload_cert_info">%1$s · DXCC %2$d · 有效期至 %3$s</string>
|
||||||
|
<string name="prefs_lotw_upload_remove">移除证书</string>
|
||||||
|
<string name="prefs_lotw_upload_station_title">台址</string>
|
||||||
|
<string name="prefs_lotw_upload_grid">网格(逗号分隔,可多格)</string>
|
||||||
|
<string name="prefs_lotw_upload_save">保存</string>
|
||||||
|
<string name="prefs_lotw_upload_close">关闭</string>
|
||||||
|
|
||||||
<string name="prefs_data_title">卫星数据更新</string>
|
<string name="prefs_data_title">卫星数据更新</string>
|
||||||
<string name="prefs_data_entries">卫星:%s</string>
|
<string name="prefs_data_entries">卫星:%s</string>
|
||||||
<string name="prefs_data_radios">收发器:%s</string>
|
<string name="prefs_data_radios">收发器:%s</string>
|
||||||
|
|||||||
@@ -222,6 +222,24 @@
|
|||||||
<string name="lotw_sync_progress_qso">Syncing %1$d QSOs, ~%2$d s remaining</string>
|
<string name="lotw_sync_progress_qso">Syncing %1$d QSOs, ~%2$d s remaining</string>
|
||||||
<string name="prefs_locator_text">Set station\'s position using locator</string>
|
<string name="prefs_locator_text">Set station\'s position using locator</string>
|
||||||
|
|
||||||
|
<string name="prefs_logbook_title">Logbook</string>
|
||||||
|
<string name="prefs_logbook_count">%1$d records — local QSOs and LoTW confirmations</string>
|
||||||
|
<string name="prefs_logbook_empty">No records yet — record QSOs from the Radar Log tab</string>
|
||||||
|
<string name="prefs_logbook_close">Close</string>
|
||||||
|
<string name="prefs_lotw_upload_title">LoTW upload</string>
|
||||||
|
<string name="prefs_lotw_upload_configured">Certificate imported · station grid %1$s</string>
|
||||||
|
<string name="prefs_lotw_upload_not_configured">Not configured — import your TrustedQSL certificate to upload QSOs</string>
|
||||||
|
<string name="prefs_lotw_upload_busy">Working…</string>
|
||||||
|
<string name="prefs_lotw_upload_cert_hint">Import your TrustedQSL .p12 certificate (same one you use in TQSL) to sign and upload QSOs. The password is stored encrypted on this device only.</string>
|
||||||
|
<string name="prefs_lotw_upload_password">Certificate password</string>
|
||||||
|
<string name="prefs_lotw_upload_import">Choose .p12 file</string>
|
||||||
|
<string name="prefs_lotw_upload_cert_info">%1$s · DXCC %2$d · expires %3$s</string>
|
||||||
|
<string name="prefs_lotw_upload_remove">Remove certificate</string>
|
||||||
|
<string name="prefs_lotw_upload_station_title">Station location</string>
|
||||||
|
<string name="prefs_lotw_upload_grid">Grid(s), comma-separated</string>
|
||||||
|
<string name="prefs_lotw_upload_save">Save</string>
|
||||||
|
<string name="prefs_lotw_upload_close">Close</string>
|
||||||
|
|
||||||
<string name="prefs_data_title">Satellite data</string>
|
<string name="prefs_data_title">Satellite data</string>
|
||||||
<string name="prefs_data_entries">Satellites: %s</string>
|
<string name="prefs_data_entries">Satellites: %s</string>
|
||||||
<string name="prefs_data_radios">Transceivers: %s</string>
|
<string name="prefs_data_radios">Transceivers: %s</string>
|
||||||
|
|||||||
@@ -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") }
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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<List<QsoRecord>> = qsoRepository.records
|
||||||
|
|
||||||
|
private val _uiState = MutableStateFlow(LogUiState())
|
||||||
|
val uiState: StateFlow<LogUiState> = _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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -83,6 +83,7 @@ import kotlin.math.PI
|
|||||||
private enum class RadarPage(val title: String) {
|
private enum class RadarPage(val title: String) {
|
||||||
Transceivers("Transceivers"),
|
Transceivers("Transceivers"),
|
||||||
Calculator("Calculator"),
|
Calculator("Calculator"),
|
||||||
|
Log("Log"),
|
||||||
Sstv("SSTV")
|
Sstv("SSTV")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -91,6 +92,7 @@ fun RadarDestination(navigateUp: () -> Unit) {
|
|||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val container = (context.applicationContext as IContainerProvider).getMainContainer()
|
val container = (context.applicationContext as IContainerProvider).getMainContainer()
|
||||||
val viewModel: RadarViewModel = viewModel(factory = RadarViewModel.factory(container))
|
val viewModel: RadarViewModel = viewModel(factory = RadarViewModel.factory(container))
|
||||||
|
val logViewModel: LogViewModel = viewModel(factory = LogViewModel.factory(container))
|
||||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
val mutualData by container.mutualPassData.collectAsStateWithLifecycle()
|
val mutualData by container.mutualPassData.collectAsStateWithLifecycle()
|
||||||
val navigateUpAndClearMutual = {
|
val navigateUpAndClearMutual = {
|
||||||
@@ -127,7 +129,7 @@ fun RadarDestination(navigateUp: () -> Unit) {
|
|||||||
viewModel.onAction(RadarAction.SstvPermissionResult(granted))
|
viewModel.onAction(RadarAction.SstvPermissionResult(granted))
|
||||||
viewModel.onAction(RadarAction.CwPermissionResult(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)
|
permissionLauncher.launch(Manifest.permission.RECORD_AUDIO)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -138,6 +140,7 @@ private fun RadarScreen(
|
|||||||
onAction: (RadarAction) -> Unit,
|
onAction: (RadarAction) -> Unit,
|
||||||
navigateUp: () -> Unit,
|
navigateUp: () -> Unit,
|
||||||
mutualData: MutualPassData,
|
mutualData: MutualPassData,
|
||||||
|
logViewModel: LogViewModel,
|
||||||
requestMicPermission: () -> Unit
|
requestMicPermission: () -> Unit
|
||||||
) {
|
) {
|
||||||
val upcomingPass = uiState.currentPass ?: getDefaultPass()
|
val upcomingPass = uiState.currentPass ?: getDefaultPass()
|
||||||
@@ -194,11 +197,11 @@ private fun RadarScreen(
|
|||||||
}
|
}
|
||||||
if (isVertical) {
|
if (isVertical) {
|
||||||
RadarCard(uiState, trackB, trackBPosition, Modifier.weight(1f))
|
RadarCard(uiState, trackB, trackBPosition, Modifier.weight(1f))
|
||||||
PagerCard(uiState, onAction, requestMicPermission, Modifier.weight(1f))
|
PagerCard(uiState, onAction, logViewModel, requestMicPermission, Modifier.weight(1f))
|
||||||
} else {
|
} else {
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||||
RadarCard(uiState, trackB, trackBPosition, Modifier.weight(1f))
|
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(
|
private fun PagerCard(
|
||||||
uiState: RadarState,
|
uiState: RadarState,
|
||||||
onAction: (RadarAction) -> Unit,
|
onAction: (RadarAction) -> Unit,
|
||||||
|
logViewModel: LogViewModel,
|
||||||
requestMicPermission: () -> Unit,
|
requestMicPermission: () -> Unit,
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
) {
|
) {
|
||||||
@@ -218,6 +222,7 @@ private fun PagerCard(
|
|||||||
buildList {
|
buildList {
|
||||||
add(RadarPage.Transceivers)
|
add(RadarPage.Transceivers)
|
||||||
if (hasCalculatorPage) add(RadarPage.Calculator)
|
if (hasCalculatorPage) add(RadarPage.Calculator)
|
||||||
|
add(RadarPage.Log)
|
||||||
add(RadarPage.Sstv)
|
add(RadarPage.Sstv)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -261,6 +266,10 @@ private fun PagerCard(
|
|||||||
onAction = onAction,
|
onAction = onAction,
|
||||||
requestMicPermission = requestMicPermission
|
requestMicPermission = requestMicPermission
|
||||||
)
|
)
|
||||||
|
RadarPage.Log -> LogPage(
|
||||||
|
uiState = uiState,
|
||||||
|
logViewModel = logViewModel
|
||||||
|
)
|
||||||
RadarPage.Sstv -> SstvPage(
|
RadarPage.Sstv -> SstvPage(
|
||||||
sstv = uiState.sstv,
|
sstv = uiState.sstv,
|
||||||
dopplerFrequency = uiState.transceivers.selectedFrequency?.let { formatFrequency(it) },
|
dopplerFrequency = uiState.transceivers.selectedFrequency?.let { formatFrequency(it) },
|
||||||
|
|||||||
+178
@@ -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)) } }
|
||||||
|
)
|
||||||
|
}
|
||||||
+117
@@ -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<QsoRecord>,
|
||||||
|
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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
+36
-1
@@ -261,6 +261,24 @@ private fun SettingsScreen(uiState: SettingsState, onAction: (SettingsAction) ->
|
|||||||
onSyncIncremental = { onAction(SettingsAction.SyncLoTWGrids(it, LoTWSyncMode.Incremental)) }
|
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
|
// URLs for top bar
|
||||||
val uriHandler = LocalUriHandler.current
|
val uriHandler = LocalUriHandler.current
|
||||||
@@ -365,6 +383,19 @@ private fun SettingsScreen(uiState: SettingsState, onAction: (SettingsAction) ->
|
|||||||
onRadioControlClick = { dialogs.radioControl = true }
|
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 {
|
item {
|
||||||
LoTWCard(
|
LoTWCard(
|
||||||
settings = uiState.lotwSettings,
|
settings = uiState.lotwSettings,
|
||||||
@@ -895,6 +926,8 @@ private class DialogVisibility {
|
|||||||
var wavelog by mutableStateOf(false)
|
var wavelog by mutableStateOf(false)
|
||||||
var lotw by mutableStateOf(false)
|
var lotw by mutableStateOf(false)
|
||||||
var compassCalibration by mutableStateOf(false)
|
var compassCalibration by mutableStateOf(false)
|
||||||
|
var logbook by mutableStateOf(false)
|
||||||
|
var lotwUpload by mutableStateOf(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -902,7 +935,7 @@ private fun rememberDialogVisibility(): DialogVisibility {
|
|||||||
return rememberSaveable(saver = run {
|
return rememberSaveable(saver = run {
|
||||||
androidx.compose.runtime.saveable.Saver(
|
androidx.compose.runtime.saveable.Saver(
|
||||||
save = {
|
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 = {
|
restore = {
|
||||||
DialogVisibility().apply {
|
DialogVisibility().apply {
|
||||||
@@ -910,6 +943,8 @@ private fun rememberDialogVisibility(): DialogVisibility {
|
|||||||
network = it[3]; bluetooth = it[4]; radioControl = it[5]; wavelog = it[6]
|
network = it[3]; bluetooth = it[4]; radioControl = it[5]; wavelog = it[6]
|
||||||
lotw = it.getOrElse(7) { false }
|
lotw = it.getOrElse(7) { false }
|
||||||
compassCalibration = it.getOrElse(8) { false }
|
compassCalibration = it.getOrElse(8) { false }
|
||||||
|
logbook = it.getOrElse(9) { false }
|
||||||
|
lotwUpload = it.getOrElse(10) { false }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
+17
@@ -68,6 +68,13 @@ data class SettingsState(
|
|||||||
val lotwError: LoTWError? = null,
|
val lotwError: LoTWError? = null,
|
||||||
/** Epoch ms of the last successful LoTW sync (0 = never) — shown like the ephemeris update time. */
|
/** Epoch ms of the last successful LoTW sync (0 = never) — shown like the ephemeris update time. */
|
||||||
val lotwLastSyncEpochMs: Long = 0L,
|
val lotwLastSyncEpochMs: Long = 0L,
|
||||||
|
/** Logbook (QSO records + LoTW confirmations), newest first. */
|
||||||
|
val logbookRecords: List<com.rtbishop.look4sat.core.domain.logbook.QsoRecord> = 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,
|
val compassAccuracy: CompassAccuracy = CompassAccuracy.UNRELIABLE,
|
||||||
/** 校正后航向(度, 含磁偏角+手动偏置), 校准对话框实时显示. */
|
/** 校正后航向(度, 含磁偏角+手动偏置), 校准对话框实时显示. */
|
||||||
@@ -130,6 +137,16 @@ sealed interface SettingsAction {
|
|||||||
/** Abort an in-flight LoTW sync (wrong button / changed mind). */
|
/** Abort an in-flight LoTW sync (wrong button / changed mind). */
|
||||||
data object CancelLoTWSync : SettingsAction
|
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
|
// Update checker
|
||||||
data object CheckForUpdate : SettingsAction
|
data object CheckForUpdate : SettingsAction
|
||||||
data object DownloadUpdate : SettingsAction
|
data object DownloadUpdate : SettingsAction
|
||||||
|
|||||||
+75
@@ -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.repository.IWavelogRepository
|
||||||
import com.rtbishop.look4sat.core.domain.usecase.IShowToast
|
import com.rtbishop.look4sat.core.domain.usecase.IShowToast
|
||||||
import com.rtbishop.look4sat.core.domain.utility.VersionComparator
|
import com.rtbishop.look4sat.core.domain.utility.VersionComparator
|
||||||
|
import com.rtbishop.look4sat.core.domain.logbook.toConfirmedRecord
|
||||||
import com.rtbishop.look4sat.core.presentation.R
|
import com.rtbishop.look4sat.core.presentation.R
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import java.io.File
|
import java.io.File
|
||||||
@@ -50,6 +52,8 @@ class SettingsViewModel(
|
|||||||
private val updateRepo: IUpdateRepository,
|
private val updateRepo: IUpdateRepository,
|
||||||
private val wavelogRepo: IWavelogRepository,
|
private val wavelogRepo: IWavelogRepository,
|
||||||
private val lotwRepo: com.rtbishop.look4sat.core.domain.repository.ILoTWRepository,
|
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 sensorsRepo: ISensorsRepo,
|
||||||
private val apkFile: File,
|
private val apkFile: File,
|
||||||
private val showToast: IShowToast
|
private val showToast: IShowToast
|
||||||
@@ -154,6 +158,11 @@ class SettingsViewModel(
|
|||||||
_uiState.update { it.copy(lotwSettings = settings) }
|
_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.UpdateLoTW -> settingsRepo.updateLoTWSettings(action.settings)
|
||||||
is SettingsAction.SyncLoTWGrids -> syncLoTWGrids(action.settings, action.mode)
|
is SettingsAction.SyncLoTWGrids -> syncLoTWGrids(action.settings, action.mode)
|
||||||
SettingsAction.CancelLoTWSync -> cancelLoTWSync()
|
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
|
// Update checker
|
||||||
SettingsAction.CheckForUpdate -> checkForUpdate()
|
SettingsAction.CheckForUpdate -> checkForUpdate()
|
||||||
SettingsAction.DownloadUpdate -> downloadUpdate()
|
SettingsAction.DownloadUpdate -> downloadUpdate()
|
||||||
@@ -263,6 +280,13 @@ class SettingsViewModel(
|
|||||||
// by call + QSO time); full: the fresh report replaces it all.
|
// by call + QSO time); full: the fresh report replaces it all.
|
||||||
// Shared with the automatic sync on app start (MainApplication).
|
// Shared with the automatic sync on app start (MainApplication).
|
||||||
val mergedGridsCount = applyLoTWGridResult(settingsRepo, result, effectiveMode, callsign)
|
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 ->
|
_uiState.update { state ->
|
||||||
state.copy(
|
state.copy(
|
||||||
lotwSyncing = false, lotwSyncMode = null, lotwProgress = null,
|
lotwSyncing = false, lotwSyncMode = null, lotwProgress = null,
|
||||||
@@ -312,6 +336,55 @@ class SettingsViewModel(
|
|||||||
|
|
||||||
// endregion
|
// 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
|
// region Update checker
|
||||||
|
|
||||||
private fun checkForUpdate() {
|
private fun checkForUpdate() {
|
||||||
@@ -447,6 +520,8 @@ class SettingsViewModel(
|
|||||||
updateRepo = container.updateRepo,
|
updateRepo = container.updateRepo,
|
||||||
wavelogRepo = container.wavelogRepo,
|
wavelogRepo = container.wavelogRepo,
|
||||||
lotwRepo = container.lotwRepo,
|
lotwRepo = container.lotwRepo,
|
||||||
|
qsoRepository = container.qsoRepository,
|
||||||
|
lotwUploadRepository = container.lotwUploadRepository,
|
||||||
sensorsRepo = container.provideSensorsRepo(),
|
sensorsRepo = container.provideSensorsRepo(),
|
||||||
apkFile = File(context.cacheDir, "look4sat-update.apk"),
|
apkFile = File(context.cacheDir, "look4sat-update.apk"),
|
||||||
showToast = container.provideShowToast()
|
showToast = container.provideShowToast()
|
||||||
|
|||||||
Reference in new issue
Block a user