mirror of
https://github.com/atsunatsu/Look4Sat.git
synced 2026-10-02 03:15:37 +00:00
Compare commits
22
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
474762cbd3 | ||
|
|
8fe88fbc93 | ||
|
|
7728b5c38f | ||
|
|
a769f7dea7 | ||
|
|
3be2e4b9e8 | ||
|
|
5957f30af2 | ||
|
|
d8d0825f54 | ||
|
|
bc5a8e4bba | ||
|
|
851bc25d73 | ||
|
|
238511aa78 | ||
|
|
e606afb0f3 | ||
|
|
e19f098aa5 | ||
|
|
797b1c19f7 | ||
|
|
8e549cd43c | ||
|
|
d33629f015 | ||
|
|
7081ffaa60 | ||
|
|
3b5b5fe627 | ||
|
|
ebe32ed153 | ||
|
|
091d3e867b | ||
|
|
26e50ddab5 | ||
|
|
5f90eebab4 | ||
|
|
70ada0d608 |
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.LoTWSyncMode
|
||||
import com.rtbishop.look4sat.core.domain.repository.LoTWResult
|
||||
import com.rtbishop.look4sat.core.domain.logbook.toConfirmedRecord
|
||||
import com.rtbishop.look4sat.core.domain.repository.applyLoTWGridResult
|
||||
import com.rtbishop.look4sat.core.domain.repository.lotwCursorApi
|
||||
import com.rtbishop.look4sat.core.domain.repository.lotwSyncToday
|
||||
@@ -116,6 +117,11 @@ class MainApplication : Application(), IContainerProvider {
|
||||
val result = container.lotwRepo.fetchConfirmedGridQsos(callsign, lotwSettings.password, since)
|
||||
if (result is LoTWResult.Success) {
|
||||
val count = applyLoTWGridResult(settingsRepo, result, mode, callsign, timeNow)
|
||||
// Feed the logbook so confirmations appear and local uploads get matched.
|
||||
runCatching {
|
||||
val confirmed = result.qsos.values.flatten().map { it.toConfirmedRecord(callsign) }
|
||||
container.qsoRepository.mergeLoTW(confirmed)
|
||||
}
|
||||
println("Periodic LoTW grid sync finished: $count worked grids")
|
||||
} else {
|
||||
println("Periodic LoTW grid sync skipped (${result::class.simpleName})")
|
||||
|
||||
Binary file not shown.
@@ -0,0 +1,50 @@
|
||||
/*
|
||||
* 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)
|
||||
|
||||
@Query("UPDATE qso_records SET lotwUploaded = 1 WHERE id IN (:ids)")
|
||||
suspend fun markUploaded(ids: List<Long>)
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
/*
|
||||
* 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 androidx.room.migration.Migration
|
||||
import androidx.sqlite.db.SupportSQLiteDatabase
|
||||
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 = 2, exportSchema = false)
|
||||
abstract class QsoDatabase : RoomDatabase() {
|
||||
abstract fun qsoDao(): QsoDao
|
||||
}
|
||||
|
||||
/** v1 → v2: track whether a QSO was uploaded to LoTW (distinct from confirmed). */
|
||||
val MIGRATION_QSO_1_2 = object : Migration(1, 2) {
|
||||
override fun migrate(db: SupportSQLiteDatabase) {
|
||||
db.execSQL("ALTER TABLE qso_records ADD COLUMN lotwUploaded INTEGER NOT NULL DEFAULT 0")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
/*
|
||||
* 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 lotwUploaded: 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,17 @@ import androidx.room.Room
|
||||
import com.rtbishop.look4sat.core.data.database.Look4SatDb
|
||||
import com.rtbishop.look4sat.core.data.database.MIGRATION_1_2
|
||||
import com.rtbishop.look4sat.core.data.database.MIGRATION_2_3
|
||||
import com.rtbishop.look4sat.core.data.database.MIGRATION_QSO_1_2
|
||||
import com.rtbishop.look4sat.core.data.database.QsoDatabase
|
||||
import com.rtbishop.look4sat.core.data.framework.BluetoothReporter
|
||||
import com.rtbishop.look4sat.core.data.framework.Ft817Controller
|
||||
import com.rtbishop.look4sat.core.data.framework.Ic705Controller
|
||||
import com.rtbishop.look4sat.core.data.framework.NetworkReporter
|
||||
import com.rtbishop.look4sat.core.data.framework.RadioTrackingService
|
||||
import com.rtbishop.look4sat.core.data.lotw.LoTWUploadRepository
|
||||
import com.rtbishop.look4sat.core.data.repository.AmSatRepository
|
||||
import com.rtbishop.look4sat.core.data.repository.DatabaseRepo
|
||||
import com.rtbishop.look4sat.core.data.repository.QsoRepository
|
||||
import com.rtbishop.look4sat.core.data.repository.SatelliteRepo
|
||||
import com.rtbishop.look4sat.core.data.repository.SelectionRepo
|
||||
import com.rtbishop.look4sat.core.data.repository.SensorsRepo
|
||||
@@ -46,8 +50,10 @@ import com.rtbishop.look4sat.core.data.usecase.AddToCalendar
|
||||
import com.rtbishop.look4sat.core.data.usecase.AudioCapture
|
||||
import com.rtbishop.look4sat.core.data.usecase.SaveImage
|
||||
import com.rtbishop.look4sat.core.data.usecase.ShowToast
|
||||
import com.rtbishop.look4sat.core.domain.logbook.IQsoRepository
|
||||
import com.rtbishop.look4sat.core.domain.model.RadioControlSettings
|
||||
import com.rtbishop.look4sat.core.domain.repository.IDatabaseRepo
|
||||
import com.rtbishop.look4sat.core.domain.repository.ILoTWUploadRepository
|
||||
import com.rtbishop.look4sat.core.domain.repository.IMainContainer
|
||||
import com.rtbishop.look4sat.core.domain.repository.IRadioController
|
||||
import com.rtbishop.look4sat.core.domain.repository.IRadioTrackingService
|
||||
@@ -90,6 +96,13 @@ class MainContainer(private val context: Context) : IMainContainer {
|
||||
override val updateRepo by lazy { UpdateRepository(remoteSource) }
|
||||
override val wavelogRepo: IWavelogRepository by lazy { WavelogRepository() }
|
||||
override val lotwRepo: ILoTWRepository by lazy { LoTWRepository() }
|
||||
override val qsoRepository: IQsoRepository by lazy {
|
||||
val database = Room.databaseBuilder(context, QsoDatabase::class.java, "Look4SatQsoDB")
|
||||
.addMigrations(MIGRATION_QSO_1_2)
|
||||
.build()
|
||||
QsoRepository(database.qsoDao(), Dispatchers.IO)
|
||||
}
|
||||
override val lotwUploadRepository: ILoTWUploadRepository by lazy { LoTWUploadRepository(context) }
|
||||
override val radioTrackingService: IRadioTrackingService by lazy {
|
||||
val manager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
|
||||
RadioTrackingService(appScope, manager, satelliteRepo, settingsRepo)
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
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.LoTWRegionField
|
||||
import com.rtbishop.look4sat.core.domain.repository.LoTWRegionOption
|
||||
import com.rtbishop.look4sat.core.domain.repository.LoTWStation
|
||||
import com.rtbishop.look4sat.core.domain.repository.LoTWStationMeta
|
||||
import com.rtbishop.look4sat.core.domain.repository.LoTWZonePair
|
||||
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 primaryRegionFields = setOf("US_STATE", "CA_PROVINCE", "RU_OBLAST", "CN_PROVINCE", "AU_STATE", "JA_PREFECTURE", "FI_KUNTA")
|
||||
private val secondaryRegionFields = setOf("US_COUNTY", "JA_CITY_GUN_KU")
|
||||
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
|
||||
}
|
||||
|
||||
/** Region-field metadata and the national zonemap for one DXCC entity. */
|
||||
fun stationMeta(dxcc: Int): LoTWStationMeta {
|
||||
val pageFields = config.elements("page").filter { it.getAttribute("dependency") == dxcc.toString() }
|
||||
.flatMap { it.elements("pageField") }.map { it.textContent }
|
||||
val primary = pageFields.firstOrNull { it in primaryRegionFields }
|
||||
val regionField = primary?.let { id ->
|
||||
val field = config.elements("field").single { it.getAttribute("Id") == id }
|
||||
val dependency = if (field.getAttribute("dependsOn") == "DXCC") dxcc.toString() else null
|
||||
val options = field.elements("enums").filter {
|
||||
it.getAttribute("dependency").isBlank() || it.getAttribute("dependency") == dependency
|
||||
}.flatMap { it.elements("enum") }.map { enum ->
|
||||
LoTWRegionOption(
|
||||
code = enum.getAttribute("value").uppercase(Locale.US),
|
||||
name = enum.textContent.trim().ifBlank { enum.getAttribute("value") },
|
||||
zones = parseZonemap(enum.getAttribute("zonemap"))
|
||||
)
|
||||
}
|
||||
LoTWRegionField(id = id, label = field.getAttribute("label"), options = options)
|
||||
}
|
||||
val entity = config.elements("dxcc").flatMap { it.elements("entity") }
|
||||
.firstOrNull { it.getAttribute("arrlId") == dxcc.toString() }
|
||||
val countryZones = entity?.getAttribute("zonemap")?.let(::parseZonemap).orEmpty()
|
||||
return LoTWStationMeta(regionField, countryZones)
|
||||
}
|
||||
|
||||
private fun parseZonemap(zonemap: String): List<LoTWZonePair> = zonemap.split(',').mapNotNull { pair ->
|
||||
val parts = pair.split(':')
|
||||
if (parts.size != 2) return@mapNotNull null
|
||||
val itu = parts[0].trim().toIntOrNull() ?: return@mapNotNull null
|
||||
val cq = parts[1].trim().toIntOrNull() ?: return@mapNotNull null
|
||||
LoTWZonePair(itu, cq)
|
||||
}
|
||||
|
||||
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 primaryRegionFields }
|
||||
val secondary = pageFields.firstOrNull { it in secondaryRegionFields }
|
||||
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,145 @@
|
||||
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 key: PrivateKey
|
||||
val cert: X509Certificate
|
||||
val store = try {
|
||||
KeyStore.getInstance("PKCS12").apply { bytes.inputStream().use { load(it, password) } }
|
||||
} catch (_: Exception) {
|
||||
// Modern TQSL / OpenSSL 3 exports use PBES2+AES-CBC which Android's legacy
|
||||
// Bouncy Castle parser cannot read. Fall back to our own PBES2 reader; if
|
||||
// that fails too, report the real reason (format vs password).
|
||||
if (isPbes2(bytes)) {
|
||||
try {
|
||||
val parsed = Pkcs12Reader.read(bytes, password)
|
||||
parsed.first to parsed.second
|
||||
} catch (_: Exception) {
|
||||
fail(if (password.isNotEmpty()) LoTWProblem.CERTIFICATE_FORMAT else LoTWProblem.CERTIFICATE_PASSWORD)
|
||||
}
|
||||
} else {
|
||||
fail(LoTWProblem.CERTIFICATE_PASSWORD)
|
||||
}
|
||||
}
|
||||
if (store is Pair<*, *>) {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
key = store.first as PrivateKey
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
cert = store.second as X509Certificate
|
||||
} else {
|
||||
val ks = store as KeyStore
|
||||
val aliases = Collections.list(ks.aliases()).filter { ks.isKeyEntry(it) }
|
||||
if (aliases.size != 1) fail(LoTWProblem.CERTIFICATE_INVALID)
|
||||
val alias = aliases.single()
|
||||
key = try { ks.getKey(alias, password) as? PrivateKey }
|
||||
catch (_: Exception) { fail(LoTWProblem.CERTIFICATE_PASSWORD) }
|
||||
?: fail(LoTWProblem.CERTIFICATE_INVALID)
|
||||
cert = ks.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)
|
||||
}
|
||||
|
||||
/** True when the PKCS12 uses PBES2 (OID 1.2.840.113549.1.5.13), the default
|
||||
* algorithm of OpenSSL 3 / modern TQSL. Android's legacy BC parser can't read it. */
|
||||
private fun isPbes2(bytes: ByteArray): Boolean {
|
||||
val oid = byteArrayOf(0x2a, 0x86.toByte(), 0x48, 0x86.toByte(), 0xf7.toByte(), 0x0d, 0x01, 0x05, 0x0d)
|
||||
if (bytes.size < oid.size) return false
|
||||
outer@ for (i in 0..bytes.size - oid.size) {
|
||||
for (j in oid.indices) if (bytes[i + j] != oid[j]) continue@outer
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
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,384 @@
|
||||
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.LoTWStationMeta
|
||||
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)
|
||||
} catch (e: LoTWOperationException) {
|
||||
// A region saved under a different certificate no longer applies to
|
||||
// this DXCC entity — drop it instead of failing the whole save.
|
||||
if (e.reason != LoTWProblem.STATION_REGION) throw e
|
||||
val cleaned = normalized.copy(region = "", county = "")
|
||||
config().stationFields(cleaned, bundle.info.dxcc)
|
||||
writeStation(cleaned)
|
||||
discardPreview()
|
||||
return@withLock cleaned
|
||||
} 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 stationMeta(dxcc: Int): LoTWStationMeta = withContext(Dispatchers.IO) {
|
||||
config().stationMeta(dxcc)
|
||||
}
|
||||
|
||||
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,255 @@
|
||||
/*
|
||||
* 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.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.rtbishop.look4sat.core.data.lotw
|
||||
|
||||
import java.io.ByteArrayInputStream
|
||||
import java.security.KeyFactory
|
||||
import java.security.PrivateKey
|
||||
import java.security.cert.CertificateFactory
|
||||
import java.security.cert.X509Certificate
|
||||
import java.security.spec.PKCS8EncodedKeySpec
|
||||
import javax.crypto.Cipher
|
||||
import javax.crypto.SecretKeyFactory
|
||||
import javax.crypto.spec.IvParameterSpec
|
||||
import javax.crypto.spec.PBEKeySpec
|
||||
import javax.crypto.spec.SecretKeySpec
|
||||
|
||||
/**
|
||||
* Minimal PKCS#12 reader for the PBES2/AES-CBC format that OpenSSL 3 and modern
|
||||
* TQSL produce by default. Android's legacy bundled Bouncy Castle PKCS12 parser
|
||||
* cannot handle PBES2, so we parse the DER structure ourselves and decrypt with
|
||||
* the platform JCE (PBKDF2WithHmacSHA1/256 + AES/CBC), which ships on Android.
|
||||
*
|
||||
* Handles both layouts:
|
||||
* - OpenSSL `-certpbe NONE`: plaintext certificate bags + a PBES2-shrouded key
|
||||
* inside a plaintext `data` ContentInfo.
|
||||
* - Modern TQSL: the certificate SafeContents wrapped in an `encryptedData`
|
||||
* ContentInfo (PBES2), plus the PBES2-shrouded key in a plaintext `data`
|
||||
* ContentInfo.
|
||||
*/
|
||||
internal object Pkcs12Reader {
|
||||
|
||||
private val OID_DATA = byteArrayOf(0x2a, 0x86.toByte(), 0x48, 0x86.toByte(), 0xf7.toByte(), 0x0d, 0x01, 0x07, 0x01)
|
||||
private val OID_ENCRYPTED_DATA = byteArrayOf(0x2a, 0x86.toByte(), 0x48, 0x86.toByte(), 0xf7.toByte(), 0x0d, 0x01, 0x07, 0x06)
|
||||
private val OID_PKCS8_SHROUDED_KEY_BAG = byteArrayOf(0x2a, 0x86.toByte(), 0x48, 0x86.toByte(), 0xf7.toByte(), 0x0d, 0x01, 0x0c, 0x0a, 0x01, 0x02)
|
||||
private val OID_CERT_BAG = byteArrayOf(0x2a, 0x86.toByte(), 0x48, 0x86.toByte(), 0xf7.toByte(), 0x0d, 0x01, 0x0c, 0x0a, 0x01, 0x03)
|
||||
private val OID_X509_CERT = byteArrayOf(0x2a, 0x86.toByte(), 0x48, 0x86.toByte(), 0xf7.toByte(), 0x0d, 0x01, 0x09, 0x16, 0x01)
|
||||
private val OID_PBES2 = byteArrayOf(0x2a, 0x86.toByte(), 0x48, 0x86.toByte(), 0xf7.toByte(), 0x0d, 0x01, 0x05, 0x0d)
|
||||
private val OID_HMAC_SHA1 = byteArrayOf(0x2a, 0x86.toByte(), 0x48, 0x86.toByte(), 0xf7.toByte(), 0x0d, 0x02, 0x07)
|
||||
private val OID_AES_256_CBC = byteArrayOf(0x60, 0x86.toByte(), 0x48, 0x01, 0x65, 0x03, 0x04, 0x01, 0x2a)
|
||||
private val OID_AES_128_CBC = byteArrayOf(0x60, 0x86.toByte(), 0x48, 0x01, 0x65, 0x03, 0x04, 0x01, 0x02)
|
||||
|
||||
fun read(bytes: ByteArray, password: CharArray): Pair<PrivateKey, X509Certificate> {
|
||||
val pfx = DerReader.read(bytes)
|
||||
require(pfx.tag == 0x30) { "not a PFX" }
|
||||
val pfxChildren = DerReader.children(pfx.content)
|
||||
require(pfxChildren.size >= 2) { "PFX too small" }
|
||||
// authSafe ContentInfo
|
||||
val authSafeCi = DerReader.children(pfxChildren[1].content)
|
||||
require(authSafeCi.size >= 2) { "authSafe malformed" }
|
||||
// content: [0] EXPLICIT OCTET STRING -> AuthenticatedSafe
|
||||
val explicit = DerReader.children(authSafeCi[1].content).first()
|
||||
val octet = DerReader.read(explicit.content)
|
||||
// octet.content is the AuthenticatedSafe body: a sequence of ContentInfo.
|
||||
var privateKey: PrivateKey? = null
|
||||
val certs = mutableListOf<X509Certificate>()
|
||||
for (info in DerReader.children(octet.content)) {
|
||||
val parts = DerReader.children(info.content)
|
||||
if (parts.isEmpty()) continue
|
||||
when {
|
||||
parts[0].content.contentEquals(OID_DATA) ->
|
||||
parseSafeContentsContent(parts, password, certs) { privateKey = it }
|
||||
parts[0].content.contentEquals(OID_ENCRYPTED_DATA) ->
|
||||
parseEncryptedData(parts, password, certs)
|
||||
}
|
||||
}
|
||||
val key = privateKey ?: error("no private key bag found")
|
||||
require(certs.isNotEmpty()) { "no certificate bag found" }
|
||||
return key to certs[0]
|
||||
}
|
||||
|
||||
/** A plaintext ContentInfo: [0] EXPLICIT OCTET STRING -> full SafeContents DER. */
|
||||
private fun parseSafeContentsContent(
|
||||
parts: List<Der>,
|
||||
password: CharArray,
|
||||
certs: MutableList<X509Certificate>,
|
||||
onKey: (PrivateKey) -> Unit
|
||||
) {
|
||||
val explicit = DerReader.children(parts[1].content).first()
|
||||
parseSafeBags(explicit.content, password, certs, onKey)
|
||||
}
|
||||
|
||||
/** EncryptedData ContentInfo: version, EncryptedContentInfo{ oid, PBES2 alg, [0] IMPLICIT OCTET }. */
|
||||
private fun parseEncryptedData(
|
||||
parts: List<Der>,
|
||||
password: CharArray,
|
||||
certs: MutableList<X509Certificate>
|
||||
) {
|
||||
// parts[0] = encryptedData OID; parts[1] = [0] EXPLICIT EncryptedData SEQ
|
||||
val explicit = DerReader.children(parts[1].content).first()
|
||||
val eciParts = DerReader.children(explicit.content)
|
||||
// eciParts = [version INT, EncryptedContentInfo SEQ]
|
||||
require(eciParts.size >= 2) { "EncryptedData malformed" }
|
||||
val eci = DerReader.children(eciParts[1].content)
|
||||
// eci = [contentType OID, contentEncryptionAlgorithm, [0] IMPLICIT OCTET STRING]
|
||||
require(eci.size >= 3) { "EncryptedContentInfo malformed" }
|
||||
val alg = eci[1]
|
||||
val ciphertext = eci[2].content // [0] IMPLICIT OCTET STRING -> raw bytes
|
||||
val safeContents = decryptPbes2(alg, ciphertext, password)
|
||||
// The decrypted SafeContents holds certificate bags.
|
||||
parseSafeBags(safeContents, password, certs) {}
|
||||
}
|
||||
|
||||
/** Decrypt a PBES2-encrypted blob given its AlgorithmIdentifier. */
|
||||
private fun decryptPbes2(algorithm: Der, encrypted: ByteArray, password: CharArray): ByteArray {
|
||||
val algParts = DerReader.children(algorithm.content)
|
||||
require(algParts.size >= 2 && algParts[0].content.contentEquals(OID_PBES2)) { "not PBES2" }
|
||||
// PBES2-params ::= SEQUENCE { kdf AlgorithmIdentifier, enc AlgorithmIdentifier }
|
||||
val pbes2 = DerReader.children(algParts[1].content)
|
||||
val kdf = DerReader.children(pbes2[0].content)
|
||||
val kdfParams = DerReader.children(kdf[1].content)
|
||||
val salt = kdfParams[0].content
|
||||
val iterations = readInt(kdfParams[1].content)
|
||||
require(iterations in 1..10_000_000) { "implausible PBKDF2 iteration count" }
|
||||
// Optional PBKDF2-params elements: keyLength (INTEGER) and/or prf (SEQUENCE),
|
||||
// in either order. Distinguish by DER tag — mistaking prf for keyLength yields
|
||||
// an absurd key size and a PBKDF2 that runs for hours.
|
||||
var keyBits = 256
|
||||
var prfName = "PBKDF2WithHmacSHA1"
|
||||
for (i in 2 until kdfParams.size) {
|
||||
val param = kdfParams[i]
|
||||
when (param.tag) {
|
||||
0x02 -> keyBits = readInt(param.content) * 8
|
||||
0x30 -> {
|
||||
val prf = DerReader.children(param.content)
|
||||
prfName = if (prf.isNotEmpty() && prf[0].content.contentEquals(OID_HMAC_SHA1))
|
||||
"PBKDF2WithHmacSHA1" else "PBKDF2WithHmacSHA256"
|
||||
}
|
||||
}
|
||||
}
|
||||
require(keyBits in 128..512) { "implausible PBKDF2 key size" }
|
||||
val enc = DerReader.children(pbes2[1].content)
|
||||
val encOid = enc[0].content
|
||||
val iv = enc[1].content
|
||||
require(encOid.contentEquals(OID_AES_256_CBC) || encOid.contentEquals(OID_AES_128_CBC)) { "unsupported cipher" }
|
||||
|
||||
val spec = PBEKeySpec(password, salt, iterations, keyBits)
|
||||
val secretKey = SecretKeyFactory.getInstance(prfName).generateSecret(spec)
|
||||
// PBKDF2 factories return a PBE key; wrap the raw bytes as an AES key.
|
||||
val aesKey = SecretKeySpec(secretKey.encoded, "AES")
|
||||
val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding")
|
||||
cipher.init(Cipher.DECRYPT_MODE, aesKey, IvParameterSpec(iv))
|
||||
return cipher.doFinal(encrypted)
|
||||
}
|
||||
|
||||
private fun parseSafeBags(
|
||||
safeContentsDer: ByteArray,
|
||||
password: CharArray,
|
||||
certs: MutableList<X509Certificate>,
|
||||
onKey: (PrivateKey) -> Unit
|
||||
) {
|
||||
val safeContents = DerReader.read(safeContentsDer)
|
||||
require(safeContents.tag == 0x30) { "SafeContents malformed" }
|
||||
for (bag in DerReader.children(safeContents.content)) {
|
||||
val bagParts = DerReader.children(bag.content)
|
||||
if (bagParts.size < 2) continue
|
||||
val bagOid = bagParts[0].content
|
||||
val bagValue = DerReader.children(bagParts[1].content).first()
|
||||
when {
|
||||
bagOid.contentEquals(OID_PKCS8_SHROUDED_KEY_BAG) ->
|
||||
onKey(decryptShroudedKeyBag(bagValue, password))
|
||||
bagOid.contentEquals(OID_CERT_BAG) -> parseCertBag(bagValue, certs)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun decryptShroudedKeyBag(bagValue: Der, password: CharArray): PrivateKey {
|
||||
// bagValue = the SafeBag value SEQ (EncryptedPrivateKeyInfo): children are
|
||||
// [AlgorithmIdentifier, encryptedData OCTET STRING].
|
||||
val parts = DerReader.children(bagValue.content)
|
||||
require(parts.size == 2) { "EncryptedPrivateKeyInfo malformed" }
|
||||
val pkcs8 = decryptPbes2(parts[0], parts[1].content, password)
|
||||
return KeyFactory.getInstance("RSA").generatePrivate(PKCS8EncodedKeySpec(pkcs8))
|
||||
}
|
||||
|
||||
private fun parseCertBag(bagValue: Der, certs: MutableList<X509Certificate>) {
|
||||
// bagValue = the SafeBag value SEQ (CertBag): children are
|
||||
// [certType OID, [0] EXPLICIT OCTET STRING (full X.509 DER)].
|
||||
val parts = DerReader.children(bagValue.content)
|
||||
if (parts.size < 2) return
|
||||
if (!parts[0].content.contentEquals(OID_X509_CERT)) return
|
||||
// [0] EXPLICIT -> OCTET STRING -> full X.509 certificate DER.
|
||||
val explicit = DerReader.children(parts[1].content).first()
|
||||
val certDer = explicit.content
|
||||
val factory = CertificateFactory.getInstance("X.509")
|
||||
certs.add(factory.generateCertificate(ByteArrayInputStream(certDer)) as X509Certificate)
|
||||
}
|
||||
|
||||
private fun readInt(bytes: ByteArray): Int {
|
||||
var value = 0
|
||||
var start = 0
|
||||
if (bytes.size > 1 && bytes[0].toInt() == 0x00) start = 1 // strip leading zero for positive
|
||||
for (i in start until bytes.size) value = (value shl 8) or (bytes[i].toInt() and 0xff)
|
||||
return value
|
||||
}
|
||||
|
||||
/** Minimal DER element parser. */
|
||||
private data class Der(val tag: Int, val content: ByteArray)
|
||||
|
||||
private object DerReader {
|
||||
fun read(der: ByteArray, offset: Int = 0): Der {
|
||||
require(offset < der.size) { "DER truncated" }
|
||||
val tag = der[offset].toInt() and 0xff
|
||||
var i = offset + 1
|
||||
var len = der[i].toInt() and 0xff
|
||||
i++
|
||||
if (len and 0x80 != 0) {
|
||||
val numBytes = len and 0x7f
|
||||
len = 0
|
||||
repeat(numBytes) {
|
||||
len = (len shl 8) or (der[i].toInt() and 0xff)
|
||||
i++
|
||||
}
|
||||
}
|
||||
require(i + len <= der.size) { "DER length overflow" }
|
||||
return Der(tag, der.copyOfRange(i, i + len))
|
||||
}
|
||||
|
||||
fun children(content: ByteArray): List<Der> {
|
||||
val out = mutableListOf<Der>()
|
||||
var off = 0
|
||||
while (off < content.size) {
|
||||
val d = read(content, off)
|
||||
out.add(d)
|
||||
val step = headerSize(content, off) + d.content.size
|
||||
if (step <= 0) break // defensive: never spin on malformed input
|
||||
off += step
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
private fun headerSize(der: ByteArray, offset: Int): Int {
|
||||
var i = offset + 1
|
||||
var len = der[i].toInt() and 0xff
|
||||
i++
|
||||
if (len and 0x80 != 0) i += len and 0x7f
|
||||
return i - offset
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -116,107 +116,12 @@ class DatabaseRepo(
|
||||
localSource.insertRadios(importedRadios)
|
||||
}
|
||||
if (importedEntries.isNotEmpty()) localSource.insertEntries(importedEntries)
|
||||
updateAmSatLiveLists()
|
||||
setUpdateSuccessful(System.currentTimeMillis())
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh the AMSAT Live FM/Linear satellite lists (transponders currently
|
||||
* on the air). Best effort: any failure keeps the previous lists and never
|
||||
* blocks the rest of the data update, so the mutual-match filter simply
|
||||
* falls back to the last successful snapshot.
|
||||
*/
|
||||
private suspend fun updateAmSatLiveLists() = withContext(dispatcher) {
|
||||
runCatching {
|
||||
val entries = localSource.getEntriesList() // catnum -> name
|
||||
val jobs = Sources.amSatLiveUrls.map { (type, url) ->
|
||||
async { type to remoteSource.getNetworkStream(url) }
|
||||
} + async { "Active" to fetchAmSatActiveStream() }
|
||||
val results = jobs.awaitAll()
|
||||
val fmNames = results.firstOrNull { it.first == "FM" }?.second?.stream
|
||||
?.let { dataParser.parseAmSatLivePage(it) }.orEmpty()
|
||||
val linearNames = results.firstOrNull { it.first == "Linear" }?.second?.stream
|
||||
?.let { dataParser.parseAmSatLivePage(it) }.orEmpty()
|
||||
// Amateur whitelist: a failed fetch yields an empty result, which
|
||||
// must NOT wipe out the last good snapshot. Fall back to the
|
||||
// persisted whitelist, and only persist when this fetch actually
|
||||
// returned a stream.
|
||||
val activeStream = results.firstOrNull { it.first == "Active" }?.second?.stream
|
||||
val activeCatnums = activeStream
|
||||
?.let { dataParser.parseAmSatActiveCatnums(it) }
|
||||
.orEmpty()
|
||||
.ifEmpty { settingsRepo.getAmSatActiveCatnums() }
|
||||
if (activeStream != null) settingsRepo.setAmSatActiveCatnums(activeCatnums)
|
||||
val nameToCatnum = entries.associate { it.name.uppercase() to it.catnum }
|
||||
// Resolve every matching local entry per AMSAT name. A single match
|
||||
// is kept as-is (so satellites absent from the amateur whitelist,
|
||||
// e.g. JO-97/TO-108, are never dropped). Only when several local
|
||||
// entries share the name (ISS station modules ZARYA/UNITY/ZVEZDA/
|
||||
// DESTINY/NAUKA) is the whitelist used to pick the primary one.
|
||||
fun resolvePerName(name: String): Set<Int> {
|
||||
val keys = dataParser.normalizeAmSatName(name)
|
||||
val all = nameToCatnum.filter { (localName, _) ->
|
||||
dataParser.matchesAmSatName(localName, keys)
|
||||
}.values.toSet()
|
||||
// No local entry matches this AMSAT name (e.g. a brand-new
|
||||
// satellite not yet in the local TLE, like TEVEL2/RS95S):
|
||||
// skip it. Returning empty is correct — crashing here (via
|
||||
// all.first()) aborted the WHOLE list update and kept the
|
||||
// stale pre-whitelist FM list with all five ISS modules.
|
||||
if (all.isEmpty()) return emptySet()
|
||||
if (all.size <= 1) return all
|
||||
val preferred = all.intersect(activeCatnums)
|
||||
if (preferred.isNotEmpty()) return preferred
|
||||
// Whitelist unavailable: keep only the primary entry (smallest
|
||||
// catnum — ISS ZARYA=25544 is the smallest of its five module
|
||||
// entries) instead of admitting every alias (DESTINY etc.).
|
||||
return setOf(all.minOrNull() ?: all.first())
|
||||
}
|
||||
// A failed FM/Linear page fetch yields an empty list here; do NOT
|
||||
// keep the stale list verbatim (it may predate the whitelist and
|
||||
// still contain ISS module aliases like DESTINY). Instead re-run
|
||||
// the multi-match disambiguation on the previous catnums via their
|
||||
// local names, so the whitelist / smallest-catnum rules clean it.
|
||||
fun cleanStaleList(previous: Set<Int>): Set<Int> =
|
||||
previous.mapNotNull { catnum ->
|
||||
nameToCatnum.entries.firstOrNull { it.value == catnum }?.key
|
||||
}.flatMap { resolvePerName(it) }.toSet()
|
||||
// Last-resort fallback when the AMSAT page is down AND there is no
|
||||
// previous list to clean (e.g. right after clearing data): derive
|
||||
// the FM/Linear sets from the local transceivers intersected with
|
||||
// the amateur whitelist, so a network timeout never wipes the
|
||||
// filters to empty.
|
||||
val fmFallback = localSource.getIdsWithModes(listOf("FM")).toSet().intersect(activeCatnums)
|
||||
val linearFallback = localSource
|
||||
.getIdsWithModes(listOf("SSB", "CW", "USB", "LSB")).toSet().intersect(activeCatnums)
|
||||
val fmCatnums = fmNames.flatMap { resolvePerName(it) }.toSet()
|
||||
.ifEmpty { cleanStaleList(settingsRepo.getAmSatFmCatnums()) }
|
||||
.ifEmpty { fmFallback }
|
||||
val linearCatnums = linearNames.flatMap { resolvePerName(it) }.toSet()
|
||||
.ifEmpty { cleanStaleList(settingsRepo.getAmSatLinearCatnums()) }
|
||||
.ifEmpty { linearFallback }
|
||||
settingsRepo.setAmSatCatnums(fmCatnums, linearCatnums)
|
||||
println("AMSAT live lists updated: FM=${fmCatnums.size}, Linear=${linearCatnums.size}, Active=${activeCatnums.size}")
|
||||
}.onFailure {
|
||||
// Keep the previous lists; the mutual filter stays on the last good snapshot.
|
||||
println("AMSAT live lists update failed: $it")
|
||||
}
|
||||
}
|
||||
|
||||
/** Try the amateur-whitelist sources in order and return the first
|
||||
* NetworkResult that carried a stream (or null if all failed). */
|
||||
private suspend fun fetchAmSatActiveStream(): NetworkResult? {
|
||||
for (url in Sources.amSatActiveUrls) {
|
||||
val result = remoteSource.getNetworkStream(url)
|
||||
if (result.stream != null) return result
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
override suspend fun clearAllData() = withContext(dispatcher) {
|
||||
localSource.deleteEntries()
|
||||
localSource.deleteRadios()
|
||||
settingsRepo.setAmSatCatnums(emptySet(), emptySet())
|
||||
setUpdateSuccessful(0L)
|
||||
}
|
||||
|
||||
|
||||
+73
-11
@@ -252,17 +252,40 @@ class LoTWRepository : ILoTWRepository {
|
||||
var country: String? = null
|
||||
var cqz: Int? = null
|
||||
var state: String? = null
|
||||
var myGrid: String? = null
|
||||
// Own-station (台址) fields: MY_GRIDSQUARE + MY_VUCC_GRIDS give the
|
||||
// full grid set of the station location; the remaining MY_* fields
|
||||
// together identify the station location (one callsign can own
|
||||
// several locations, and one location can span several grids).
|
||||
var myGrids = mutableSetOf<String>()
|
||||
var myCallsign: String? = null
|
||||
var myDxcc: String? = null
|
||||
var myState: String? = null
|
||||
var myCq: String? = null
|
||||
var myItu: String? = null
|
||||
var myIota: String? = null
|
||||
var myCountry: String? = null
|
||||
val gridsInRecord = mutableListOf<String>()
|
||||
|
||||
fun emitRecord() {
|
||||
if (propMode != "SAT" || gridsInRecord.isEmpty()) return
|
||||
val epochMs = adifTimestampToEpoch(qsoDate, timeOn)
|
||||
val stationKey = buildString {
|
||||
append(myCallsign ?: "")
|
||||
append('|').append(myDxcc ?: "")
|
||||
append('|').append(myState ?: "")
|
||||
append('|').append(myCq ?: "")
|
||||
append('|').append(myItu ?: "")
|
||||
append('|').append(myIota ?: "")
|
||||
append('|').append(myCountry ?: "")
|
||||
}.ifBlank { null }
|
||||
val qso = com.rtbishop.look4sat.core.domain.model.GridQso(
|
||||
call = call, epochMs = epochMs, satName = satName,
|
||||
mode = mode, bandUp = bandUp, bandDown = bandDown,
|
||||
dxcc = dxcc, country = country, cqz = cqz, state = state,
|
||||
myGrid = myGrid
|
||||
myGrid = myGrids.firstOrNull(),
|
||||
myGrids = myGrids,
|
||||
myCallsign = myCallsign,
|
||||
stationKey = stationKey
|
||||
)
|
||||
for (grid in gridsInRecord) {
|
||||
result.getOrPut(grid) { mutableListOf() }.add(qso)
|
||||
@@ -273,7 +296,9 @@ class LoTWRepository : ILoTWRepository {
|
||||
propMode = null; call = ""; qsoDate = ""; timeOn = ""
|
||||
satName = ""; mode = ""; bandUp = ""; bandDown = ""
|
||||
dxcc = null; country = null; cqz = null; state = null
|
||||
myGrid = null
|
||||
myGrids = mutableSetOf()
|
||||
myCallsign = null; myDxcc = null; myState = null
|
||||
myCq = null; myItu = null; myIota = null; myCountry = null
|
||||
gridsInRecord.clear()
|
||||
}
|
||||
|
||||
@@ -311,10 +336,40 @@ class LoTWRepository : ILoTWRepository {
|
||||
line.startsWith("<MY_GRIDSQUARE:") -> {
|
||||
// Own-station grid (must not be confused with GRIDSQUARE —
|
||||
// the opposite station's grid). Recorded per QSO so awards
|
||||
// can be counted per operated grid.
|
||||
// can be counted per operated grid. A station location may
|
||||
// also carry MY_VUCC_GRIDS (below) for multi-grid roaming.
|
||||
val value = adifValue(line)
|
||||
if (value.length >= 4) myGrid = value.take(4).uppercase()
|
||||
if (value.length >= 4) myGrids.add(value.take(4).uppercase())
|
||||
}
|
||||
line.startsWith("<MY_VUCC_GRIDS:") -> {
|
||||
// Own-station VUCC grids: a comma-separated list LoTW emits
|
||||
// when one station location spans several grid squares
|
||||
// (e.g. "OM60,OM50" with MY_GRIDSQUARE absent). Without
|
||||
// this field the extra grids of a multi-grid 台址 silently
|
||||
// vanished from both the blue stripes and the per-台址 VUCC
|
||||
// count. Same split/truncate as VUCC_GRIDS below.
|
||||
adifValue(line).split(',').forEach { grid ->
|
||||
val field = grid.trim().uppercase()
|
||||
if (field.length >= 4) myGrids.add(field.take(4))
|
||||
}
|
||||
}
|
||||
// Remaining MY_* fields identify the station location (台址):
|
||||
// grouped together they form the stationKey used by the map's
|
||||
// operated-grid selector. One callsign can own several 台址.
|
||||
line.startsWith("<STATION_CALLSIGN:") ->
|
||||
myCallsign = adifValue(line).trim().uppercase().ifBlank { null }
|
||||
line.startsWith("<MY_DXCC:") ->
|
||||
myDxcc = adifValue(line).trim().ifBlank { null }
|
||||
line.startsWith("<MY_STATE:") ->
|
||||
myState = adifValue(line).trim().ifBlank { null }?.let { normalizeState(it) }
|
||||
line.startsWith("<MY_CQ_ZONE:") ->
|
||||
myCq = adifValue(line).trim().ifBlank { null }
|
||||
line.startsWith("<MY_ITU_ZONE:") ->
|
||||
myItu = adifValue(line).trim().ifBlank { null }
|
||||
line.startsWith("<MY_IOTA:") ->
|
||||
myIota = adifValue(line).trim().uppercase().ifBlank { null }
|
||||
line.startsWith("<MY_COUNTRY:") ->
|
||||
myCountry = adifValue(line).trim().ifBlank { null }
|
||||
line.startsWith("<GRIDSQUARE:") || line.startsWith("<VUCC_GRIDS:") -> {
|
||||
// VUCC_GRIDS holds a comma-separated list of grids
|
||||
// ("EN52en,EN53fa"), up to four for contacts spanning
|
||||
@@ -420,23 +475,30 @@ class LoTWRepository : ILoTWRepository {
|
||||
// would silently drop every own grid on real reports; buffer the record
|
||||
// and decide at <EOR> instead.
|
||||
var propMode: String? = null
|
||||
var myGrid: String? = null
|
||||
var myGrids = mutableSetOf<String>()
|
||||
for (raw in body.lineSequence()) {
|
||||
val line = raw.trim()
|
||||
when {
|
||||
line.equals("<EOR>", ignoreCase = true) -> {
|
||||
if (propMode == "SAT" && myGrid != null) grids.add(myGrid)
|
||||
if (propMode == "SAT" && myGrids.isNotEmpty()) grids.addAll(myGrids)
|
||||
propMode = null
|
||||
myGrid = null
|
||||
myGrids = mutableSetOf()
|
||||
}
|
||||
line.startsWith("<PROP_MODE:") -> {
|
||||
propMode = adifValue(line).uppercase()
|
||||
}
|
||||
line.startsWith("<MY_GRIDSQUARE:") -> {
|
||||
// MY_GRIDSQUARE must not be mistaken for GRIDSQUARE (the
|
||||
// opposite station's grid) — only own-station grids count.
|
||||
val value = adifValue(line)
|
||||
if (value.length >= 4) myGrid = value.take(4)
|
||||
if (value.length >= 4) myGrids.add(value.take(4).uppercase())
|
||||
}
|
||||
line.startsWith("<MY_VUCC_GRIDS:") -> {
|
||||
// Multi-grid 台址: each field of MY_VUCC_GRIDS is a grid the
|
||||
// station location covered; without this the extra grids
|
||||
// never reached the blue stripes.
|
||||
adifValue(line).split(',').forEach { grid ->
|
||||
val field = grid.trim().uppercase()
|
||||
if (field.length >= 4) myGrids.add(field.take(4))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
/*
|
||||
* 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 markUploaded(ids: List<Long>) = withContext(dispatcher) {
|
||||
if (ids.isEmpty()) return@withContext
|
||||
dao.markUploaded(ids)
|
||||
}
|
||||
|
||||
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,
|
||||
lotwUploaded = lotwUploaded,
|
||||
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,
|
||||
lotwUploaded = lotwUploaded,
|
||||
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("|")
|
||||
+18
-20
@@ -25,7 +25,6 @@ import com.rtbishop.look4sat.core.domain.source.Sources
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.map
|
||||
@@ -45,22 +44,19 @@ class SelectionRepo(
|
||||
// Resolve type IDs once when types change, then filter items reactively.
|
||||
// The HashSet gives O(1) catnum lookups instead of O(n) with a List.
|
||||
// The three virtual types ("AMSAT Live FM", "AMSAT Live Linear", "Live SSTV")
|
||||
// are resolved from live transponder data instead of SharedPreferences.
|
||||
// The AMSAT list version counter re-triggers resolution when a background
|
||||
// data sync rewrites the FM/Linear lists, so the UI reflects the new list
|
||||
// without a restart or type toggle.
|
||||
private val itemsWithTypes = combine(currentTypes, settingsRepo.amSatListsVersion) { types, _ -> types }
|
||||
.flatMapLatest { types: List<String> ->
|
||||
val catnumSet: Set<Int>? = if (types.isEmpty()) {
|
||||
null // null = no filtering
|
||||
} else {
|
||||
val ids = resolveTypeIds(types)
|
||||
if (ids.isEmpty()) emptySet() else ids.toHashSet()
|
||||
}
|
||||
currentItems.map { items ->
|
||||
if (catnumSet == null) items else items.filter { it.catnum in catnumSet }
|
||||
}
|
||||
// are resolved from hardcoded FM/Linear catnum sets (Sources) or live
|
||||
// SSTV transponder data instead of SharedPreferences.
|
||||
private val itemsWithTypes = currentTypes.flatMapLatest { types: List<String> ->
|
||||
val catnumSet: Set<Int>? = if (types.isEmpty()) {
|
||||
null // null = no filtering
|
||||
} else {
|
||||
val ids = resolveTypeIds(types)
|
||||
if (ids.isEmpty()) emptySet() else ids.toHashSet()
|
||||
}
|
||||
currentItems.map { items ->
|
||||
if (catnumSet == null) items else items.filter { it.catnum in catnumSet }
|
||||
}
|
||||
}
|
||||
|
||||
private val itemsWithQuery = currentQuery.flatMapLatest { query ->
|
||||
itemsWithTypes.map { items -> filterByQuery(items, query) }
|
||||
@@ -70,15 +66,17 @@ class SelectionRepo(
|
||||
|
||||
/**
|
||||
* Resolves a list of type names to satellite catnums. The three virtual
|
||||
* transponder/activity types are resolved from live data; all other types
|
||||
* come from the per-type ID lists persisted by [ISettingsRepo].
|
||||
* transponder/activity types are resolved from hardcoded FM/Linear lists
|
||||
* (Sources.amSatFmCatnums / amSatLinearCatnums) or local SSTV transponder
|
||||
* data; all other types come from the per-type ID lists persisted by
|
||||
* [ISettingsRepo].
|
||||
*/
|
||||
private suspend fun resolveTypeIds(types: List<String>): List<Int> {
|
||||
val idsSet = mutableSetOf<Int>()
|
||||
types.forEach { type ->
|
||||
when (type) {
|
||||
Sources.virtualTypeNames[0] -> idsSet.addAll(settingsRepo.getAmSatFmCatnums())
|
||||
Sources.virtualTypeNames[1] -> idsSet.addAll(settingsRepo.getAmSatLinearCatnums())
|
||||
Sources.virtualTypeNames[0] -> idsSet.addAll(Sources.amSatFmCatnums)
|
||||
Sources.virtualTypeNames[1] -> idsSet.addAll(Sources.amSatLinearCatnums)
|
||||
// Live SSTV: mode=SSTV transponder records whose service class
|
||||
// is "Amateur", excluding launcher debris / rocket bodies
|
||||
// (names ending in "R/B" or "DEB" — e.g. Ariane 6 R/B) that
|
||||
|
||||
+81
-41
@@ -25,6 +25,7 @@ import androidx.core.location.LocationListenerCompat
|
||||
import androidx.core.location.LocationManagerCompat
|
||||
import com.rtbishop.look4sat.core.domain.model.DataSourcesSettings
|
||||
import com.rtbishop.look4sat.core.domain.model.DatabaseState
|
||||
import com.rtbishop.look4sat.core.domain.model.MapSource
|
||||
import com.rtbishop.look4sat.core.domain.model.OtherSettings
|
||||
import com.rtbishop.look4sat.core.domain.model.PassesSettings
|
||||
import com.rtbishop.look4sat.core.domain.model.RCSettings
|
||||
@@ -98,6 +99,8 @@ class SettingsRepo(
|
||||
private val keySstvMode = "sstvMode"
|
||||
private val keyLowElevation = "lowElevation"
|
||||
private val keyHighElevation = "highElevation"
|
||||
private val keyMapSource = "mapSource"
|
||||
private val keyTiandituKey = "tiandituKey"
|
||||
private val keyUseCustomTle = "useCustomTle"
|
||||
private val keyUseCustomTransceivers = "useCustomTransceivers"
|
||||
private val keyTleUrl = "tleUrl"
|
||||
@@ -107,9 +110,7 @@ class SettingsRepo(
|
||||
private val keySatelliteEnabled = "satelliteEnabled"
|
||||
private val keyTransceiversEnabled = "transceiversEnabled"
|
||||
private val keySatnogsTleSourceMigration = "satnogsTleSourceMigration"
|
||||
private val keyAmSatFmCatnums = "amSatFmCatnums"
|
||||
private val keyAmSatLinearCatnums = "amSatLinearCatnums"
|
||||
private val keyAmSatActiveCatnums = "amSatActiveCatnums"
|
||||
private val keyAutoTleSourceMigration = "autoTleSourceMigration"
|
||||
private val separatorComma = ","
|
||||
private val separatorUrl = "\n"
|
||||
private val legacyCelestrakSatnogsUrl =
|
||||
@@ -213,7 +214,20 @@ class SettingsRepo(
|
||||
cqz = o.optInt("cq", 0).takeIf { it > 0 },
|
||||
state = o.optString("st").ifBlank { null },
|
||||
// Own-grid field: absent in pre-myGrid data -> null.
|
||||
myGrid = o.optString("mg").ifBlank { null }
|
||||
myGrid = o.optString("mg").ifBlank { null },
|
||||
// Multi-grid 台址 fields (v4.4.7-ba7opf.16+): "mgs" is
|
||||
// the full grid set (MY_GRIDSQUARE + MY_VUCC_GRIDS);
|
||||
// pre-multi-grid data has only "mg" and falls back to a
|
||||
// single-element set so every consumer sees myGrids.
|
||||
myGrids = o.optJSONArray("mgs")?.let { arr ->
|
||||
(0 until arr.length()).mapNotNull { i ->
|
||||
arr.optString(i).takeIf { it.isNotBlank() }
|
||||
}.toSet()
|
||||
}?.takeIf { it.isNotEmpty() }
|
||||
?: o.optString("mg").ifBlank { null }?.let { setOf(it) }
|
||||
?: emptySet(),
|
||||
myCallsign = o.optString("mc").ifBlank { null },
|
||||
stationKey = o.optString("sk").ifBlank { null }
|
||||
)
|
||||
}
|
||||
if (list.isNotEmpty()) result[grid] = list
|
||||
@@ -242,6 +256,9 @@ class SettingsRepo(
|
||||
.put("cq", q.cqz ?: 0)
|
||||
.put("st", q.state ?: "")
|
||||
.put("mg", q.myGrid ?: "")
|
||||
.put("mgs", org.json.JSONArray(q.myGrids.sorted()))
|
||||
.put("mc", q.myCallsign ?: "")
|
||||
.put("sk", q.stationKey ?: "")
|
||||
)
|
||||
}
|
||||
root.put(grid, array)
|
||||
@@ -600,7 +617,12 @@ class SettingsRepo(
|
||||
|
||||
override fun updateOtherSettings(transform: (OtherSettings) -> OtherSettings) {
|
||||
_otherSettings.update { current ->
|
||||
val new = transform(current)
|
||||
val new = transform(current).let {
|
||||
it.copy(
|
||||
mapSource = MapSource.normalize(it.mapSource),
|
||||
tiandituKey = it.tiandituKey.trim()
|
||||
)
|
||||
}
|
||||
preferences.edit {
|
||||
putBoolean(keyStateOfAutoUpdate, new.stateOfAutoUpdate)
|
||||
putBoolean(keyStateOfAutoLotwSync, new.stateOfAutoLotwSync)
|
||||
@@ -617,6 +639,8 @@ class SettingsRepo(
|
||||
putString(keySstvMode, new.sstvMode)
|
||||
putLong(keyLowElevation, new.lowElevation.toRawBits())
|
||||
putLong(keyHighElevation, new.highElevation.toRawBits())
|
||||
putString(keyMapSource, new.mapSource)
|
||||
putString(keyTiandituKey, new.tiandituKey)
|
||||
}
|
||||
new
|
||||
}
|
||||
@@ -637,7 +661,9 @@ class SettingsRepo(
|
||||
shouldSeeWhatsNew = preferences.getBoolean(keyShouldSeeWhatsNew, true),
|
||||
sstvMode = preferences.getString(keySstvMode, null) ?: "Auto",
|
||||
lowElevation = Double.fromBits(preferences.getLong(keyLowElevation, 15.0.toRawBits())),
|
||||
highElevation = Double.fromBits(preferences.getLong(keyHighElevation, 45.0.toRawBits()))
|
||||
highElevation = Double.fromBits(preferences.getLong(keyHighElevation, 45.0.toRawBits())),
|
||||
mapSource = MapSource.normalize(preferences.getString(keyMapSource, null).orEmpty()),
|
||||
tiandituKey = preferences.getString(keyTiandituKey, null).orEmpty().trim()
|
||||
)
|
||||
//endregion
|
||||
|
||||
@@ -659,6 +685,7 @@ class SettingsRepo(
|
||||
putString(keySatelliteEnabled, normalized.satelliteEnabled.joinToString(separatorComma))
|
||||
putString(keyTransceiversEnabled, normalized.transceiversEnabled.joinToString(separatorComma))
|
||||
putBoolean(keySatnogsTleSourceMigration, true)
|
||||
putBoolean(keyAutoTleSourceMigration, true)
|
||||
}
|
||||
_dataSourcesSettings.value = normalized
|
||||
}
|
||||
@@ -669,7 +696,7 @@ class SettingsRepo(
|
||||
defaultUrls = Sources.satelliteDataUrls.values.filter { it.isNotBlank() },
|
||||
legacyEnabledKey = keyUseCustomTle,
|
||||
legacyUrlKey = keyTleUrl
|
||||
).migrateSatnogsTleSource(),
|
||||
).migrateSatnogsTleSource().migrateAutoTleSources(),
|
||||
transceiversUrls = getDataSourceUrls(
|
||||
key = keyTransceiversUrls,
|
||||
defaultUrls = Sources.transceiversDataUrls.values.filter { it.isNotBlank() },
|
||||
@@ -730,6 +757,28 @@ class SettingsRepo(
|
||||
return migrated
|
||||
}
|
||||
|
||||
private fun List<String>.migrateAutoTleSources(): List<String> {
|
||||
if (preferences.getBoolean(keyAutoTleSourceMigration, false)) return this
|
||||
val autoTleUrls = listOfNotNull(
|
||||
Sources.satelliteDataUrls["BI4PYM AutoTLE (GitHub)"],
|
||||
Sources.satelliteDataUrls["BI4PYM AutoTLE (Mirror)"]
|
||||
).filter { it.isNotBlank() }
|
||||
val missingUrls = autoTleUrls.filterNot { containsSourceUrl(it) }
|
||||
if (missingUrls.isEmpty()) {
|
||||
preferences.edit { putBoolean(keyAutoTleSourceMigration, true) }
|
||||
return this
|
||||
}
|
||||
|
||||
val migrated = this + missingUrls
|
||||
val enabled = alignFlags(migrated, readEnabledFlags(keySatelliteEnabled))
|
||||
preferences.edit {
|
||||
putString(keySatelliteUrls, migrated.joinToString(separatorUrl))
|
||||
putString(keySatelliteEnabled, enabled.joinToString(separatorComma))
|
||||
putBoolean(keyAutoTleSourceMigration, true)
|
||||
}
|
||||
return migrated
|
||||
}
|
||||
|
||||
private fun List<String>.containsSourceUrl(url: String): Boolean = any { isSameSourceUrl(it, url) }
|
||||
|
||||
private fun isSameSourceUrl(first: String, second: String): Boolean =
|
||||
@@ -750,40 +799,6 @@ class SettingsRepo(
|
||||
}
|
||||
//endregion
|
||||
|
||||
//region # AMSAT live-transponder lists
|
||||
private val _amSatListsVersion = MutableStateFlow(0)
|
||||
override val amSatListsVersion: StateFlow<Int> = _amSatListsVersion
|
||||
|
||||
override fun getAmSatFmCatnums(): Set<Int> {
|
||||
val raw = preferences.getString(keyAmSatFmCatnums, null) ?: return emptySet()
|
||||
return raw.split(separatorComma).mapNotNull { it.toIntOrNull() }.toSet()
|
||||
}
|
||||
|
||||
override fun getAmSatLinearCatnums(): Set<Int> {
|
||||
val raw = preferences.getString(keyAmSatLinearCatnums, null) ?: return emptySet()
|
||||
return raw.split(separatorComma).mapNotNull { it.toIntOrNull() }.toSet()
|
||||
}
|
||||
|
||||
override fun setAmSatCatnums(fmCatnums: Set<Int>, linearCatnums: Set<Int>) {
|
||||
preferences.edit {
|
||||
putString(keyAmSatFmCatnums, fmCatnums.sorted().joinToString(separatorComma))
|
||||
putString(keyAmSatLinearCatnums, linearCatnums.sorted().joinToString(separatorComma))
|
||||
}
|
||||
_amSatListsVersion.update { it + 1 }
|
||||
}
|
||||
|
||||
override fun getAmSatActiveCatnums(): Set<Int> {
|
||||
val raw = preferences.getString(keyAmSatActiveCatnums, null) ?: return emptySet()
|
||||
return raw.split(separatorComma).mapNotNull { it.toIntOrNull() }.toSet()
|
||||
}
|
||||
|
||||
override fun setAmSatActiveCatnums(catnums: Set<Int>) {
|
||||
preferences.edit {
|
||||
putString(keyAmSatActiveCatnums, catnums.sorted().joinToString(separatorComma))
|
||||
}
|
||||
}
|
||||
//endregion
|
||||
|
||||
//region # Radio control settings
|
||||
private val keyRadioControlEnabled = "radioControlEnabled"
|
||||
private val keyRadioModel = "radioModel"
|
||||
@@ -862,6 +877,31 @@ class SettingsRepo(
|
||||
}
|
||||
//endregion
|
||||
|
||||
//region # Per-satellite logbook mode preset settings
|
||||
private val keySatelliteModes = "satelliteModes"
|
||||
|
||||
override fun getSatelliteMode(catnum: Int): String {
|
||||
val json = preferences.getString(keySatelliteModes, "{}") ?: "{}"
|
||||
return try {
|
||||
JSONObject(json).optString(catnum.toString(), "")
|
||||
} catch (_: Exception) {
|
||||
""
|
||||
}
|
||||
}
|
||||
|
||||
override fun setSatelliteMode(catnum: Int, mode: String) {
|
||||
val json = preferences.getString(keySatelliteModes, "{}") ?: "{}"
|
||||
val updated = try {
|
||||
val obj = JSONObject(json)
|
||||
if (mode.isBlank()) obj.remove(catnum.toString()) else obj.put(catnum.toString(), mode.uppercase())
|
||||
obj.toString()
|
||||
} catch (_: Exception) {
|
||||
if (mode.isBlank()) "{}" else """{"$catnum": "${mode.uppercase()}"}"""
|
||||
}
|
||||
preferences.edit { putString(keySatelliteModes, updated) }
|
||||
}
|
||||
//endregion
|
||||
|
||||
//region # AMSAT status report settings
|
||||
private val keyAmSatCallsign = "amSatCallsign"
|
||||
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
/*
|
||||
* 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.lotw
|
||||
|
||||
import com.rtbishop.look4sat.core.domain.repository.LoTWZonePair
|
||||
import java.io.File
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/** Parses the real bundled ARRL config.tq6 to verify region-field and zonemap extraction. */
|
||||
class LoTWConfigTest {
|
||||
|
||||
private val config = LoTWConfig(File("src/main/assets/lotw/config.tq6").inputStream())
|
||||
|
||||
@Test
|
||||
fun `china exposes province field with all 31 options and zonemaps`() {
|
||||
val meta = config.stationMeta(318)
|
||||
assertEquals("CN_PROVINCE", meta.regionField?.id)
|
||||
assertEquals("Province", meta.regionField?.label)
|
||||
assertEquals(31, meta.regionField?.options?.size)
|
||||
val gd = meta.regionField?.options?.first { it.code == "GD" }
|
||||
assertEquals("Guangdong", gd?.name)
|
||||
assertEquals(listOf(LoTWZonePair(44, 24)), gd?.zones)
|
||||
val gx = meta.regionField?.options?.first { it.code == "GX" }
|
||||
assertEquals(listOf(LoTWZonePair(43, 24), LoTWZonePair(44, 24)), gx?.zones)
|
||||
// China spans 8 zone pairs — the UI must NOT prefill from the national map.
|
||||
assertTrue(meta.countryZones.size > 1)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `usa exposes state field with 49 continental options`() {
|
||||
val meta = config.stationMeta(291)
|
||||
assertEquals("US_STATE", meta.regionField?.id)
|
||||
assertEquals("State", meta.regionField?.label)
|
||||
// AK and HI are separate DXCC entities (6 and 110) with their own 1-option blocks.
|
||||
assertEquals(49, meta.regionField?.options?.size)
|
||||
assertTrue(meta.regionField!!.options.none { it.code == "AK" || it.code == "HI" })
|
||||
assertTrue(meta.countryZones.size > 1)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `alaska and hawaii each expose their own single-state block`() {
|
||||
val ak = config.stationMeta(6)
|
||||
assertEquals(listOf("AK"), ak.regionField?.options?.map { it.code })
|
||||
val hi = config.stationMeta(110)
|
||||
assertEquals(listOf("HI"), hi.regionField?.options?.map { it.code })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `germany has no region field and one national zone pair`() {
|
||||
val meta = config.stationMeta(230)
|
||||
assertNull(meta.regionField)
|
||||
assertEquals(listOf(LoTWZonePair(28, 14)), meta.countryZones)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `india has no region field and one national zone pair`() {
|
||||
val meta = config.stationMeta(324)
|
||||
assertNull(meta.regionField)
|
||||
assertEquals(listOf(LoTWZonePair(41, 22)), meta.countryZones)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `unknown entity yields empty meta`() {
|
||||
val meta = config.stationMeta(999999)
|
||||
assertNull(meta.regionField)
|
||||
assertTrue(meta.countryZones.isEmpty())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
/*
|
||||
* 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.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.rtbishop.look4sat.core.data.lotw
|
||||
|
||||
import java.security.Signature
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertThrows
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Verifies the PBES2/AES-256-CBC PKCS12 reader against a fixture generated with
|
||||
* `openssl pkcs12 -export -certpbe NONE` — same layout as modern TQSL exports
|
||||
* (plain certificate bags + PBES2-shrouded private key), which Android's legacy
|
||||
* Bouncy Castle parser cannot read.
|
||||
*/
|
||||
class Pkcs12ReaderTest {
|
||||
|
||||
private fun fixture(): ByteArray =
|
||||
javaClass.classLoader!!.getResourceAsStream("test_pbes2.p12")!!.use { it.readBytes() }
|
||||
|
||||
@Test
|
||||
fun readsPbes2KeyAndCertificate() {
|
||||
val (key, cert) = Pkcs12Reader.read(fixture(), "testpass123".toCharArray())
|
||||
assertEquals("RSA", key.algorithm)
|
||||
assertEquals("RSA", cert.publicKey.algorithm)
|
||||
assertNotNull(cert.subjectX500Principal)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun keyPairsWithCertificate() {
|
||||
val (key, cert) = Pkcs12Reader.read(fixture(), "testpass123".toCharArray())
|
||||
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)
|
||||
}
|
||||
assertTrue("private key must match certificate", valid)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun wrongPasswordThrows() {
|
||||
assertThrows(Exception::class.java) {
|
||||
Pkcs12Reader.read(fixture(), "wrongpass".toCharArray())
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun garbageBytesThrows() {
|
||||
assertThrows(Exception::class.java) {
|
||||
Pkcs12Reader.read("not a p12 file at all".toByteArray(), "x".toCharArray())
|
||||
}
|
||||
}
|
||||
}
|
||||
+4
-181
@@ -166,168 +166,6 @@ class DatabaseRepoTest {
|
||||
1 98248U 26237.16675926 .00015724 00000-0 97477-3 0 00013
|
||||
2 98248 097.5373 310.9694 0011309 278.1232 340.7230 15.09766181000012
|
||||
""".trimIndent().byteInputStream()
|
||||
|
||||
@Test
|
||||
fun `amsat fm list disambiguates ISS modules via persisted whitelist when active csv fetch fails`() =
|
||||
runTest(dispatcher) {
|
||||
val satnogsUrl = Sources.satelliteDataUrls.getValue("SatNOGS")
|
||||
val localSource = FakeLocalSource()
|
||||
val remoteSource = FakeRemoteSource().apply {
|
||||
networkStreams[satnogsUrl] = { issModulesCsvStream() }
|
||||
networkStreams[Sources.amSatLiveUrls.getValue("FM")] = { amsatFmPageStream() }
|
||||
// Amateur-whitelist URLs intentionally NOT registered -> fetch fails.
|
||||
}
|
||||
val settingsRepo = FakeSettingsRepo(
|
||||
dataSources = DataSourcesSettings(
|
||||
satelliteUrls = listOf(satnogsUrl),
|
||||
transceiversUrls = emptyList()
|
||||
)
|
||||
).apply { amSatActive = setOf(25544) } // whitelist persisted by an earlier good sync
|
||||
val repository = DatabaseRepo(dispatcher, dataParser, localSource, remoteSource, settingsRepo)
|
||||
|
||||
repository.updateFromRemote()
|
||||
|
||||
// Whitelist fetch fails on both sources, but the persisted whitelist
|
||||
// still disambiguates: only primary ISS (ZARYA) survives,
|
||||
// ISS (DESTINY) = 26700 must NOT enter the FM list.
|
||||
assertTrue(25544 in settingsRepo.amSatFm)
|
||||
assertTrue(26700 !in settingsRepo.amSatFm)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `amsat fm list keeps only smallest catnum when whitelist never available`() = runTest(dispatcher) {
|
||||
val satnogsUrl = Sources.satelliteDataUrls.getValue("SatNOGS")
|
||||
val localSource = FakeLocalSource()
|
||||
val remoteSource = FakeRemoteSource().apply {
|
||||
networkStreams[satnogsUrl] = { issModulesCsvStream() }
|
||||
networkStreams[Sources.amSatLiveUrls.getValue("FM")] = { amsatFmPageStream() }
|
||||
}
|
||||
val settingsRepo = FakeSettingsRepo(
|
||||
dataSources = DataSourcesSettings(
|
||||
satelliteUrls = listOf(satnogsUrl),
|
||||
transceiversUrls = emptyList()
|
||||
)
|
||||
) // no persisted whitelist at all
|
||||
val repository = DatabaseRepo(dispatcher, dataParser, localSource, remoteSource, settingsRepo)
|
||||
|
||||
repository.updateFromRemote()
|
||||
|
||||
// Without any whitelist, the multi-match collapse keeps the primary
|
||||
// entry (smallest catnum = ISS ZARYA 25544), never every module alias.
|
||||
assertEquals(setOf(25544), settingsRepo.amSatFm)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `amsat fm list cleans stale ISS module aliases when fm page fetch fails`() = runTest(dispatcher) {
|
||||
val satnogsUrl = Sources.satelliteDataUrls.getValue("SatNOGS")
|
||||
val localSource = FakeLocalSource()
|
||||
val remoteSource = FakeRemoteSource().apply {
|
||||
networkStreams[satnogsUrl] = { issModulesCsvStream() }
|
||||
// FM page NOT registered -> 404 -> empty parse.
|
||||
}
|
||||
val settingsRepo = FakeSettingsRepo(
|
||||
dataSources = DataSourcesSettings(
|
||||
satelliteUrls = listOf(satnogsUrl),
|
||||
transceiversUrls = emptyList()
|
||||
)
|
||||
).apply {
|
||||
// Pre-whitelist era stale list: ALL five ISS module entries.
|
||||
amSatFm = setOf(25544, 25575, 26400, 26700, 49044)
|
||||
}
|
||||
val repository = DatabaseRepo(dispatcher, dataParser, localSource, remoteSource, settingsRepo)
|
||||
|
||||
repository.updateFromRemote()
|
||||
|
||||
// The stale list is not kept verbatim: with the FM page down, the
|
||||
// previous catnums are re-disambiguated via local names, collapsing
|
||||
// the five ISS modules to the primary ZARYA (25544).
|
||||
assertEquals(setOf(25544), settingsRepo.amSatFm)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `amsat fm list survives unknown satellite names and still disambiguates ISS`() = runTest(dispatcher) {
|
||||
// Regression: AMSAT pages list satellites absent from the local TLE
|
||||
// (TEVEL2-1..9, RS95S). resolvePerName used to crash on the empty
|
||||
// match set (all.first()), aborting the WHOLE list update and keeping
|
||||
// the stale pre-whitelist FM list with all five ISS modules.
|
||||
val satnogsUrl = Sources.satelliteDataUrls.getValue("SatNOGS")
|
||||
val localSource = FakeLocalSource()
|
||||
val remoteSource = FakeRemoteSource().apply {
|
||||
networkStreams[satnogsUrl] = { issModulesCsvStream() }
|
||||
networkStreams[Sources.amSatLiveUrls.getValue("FM")] = { amsatFmPageWithUnknownStream() }
|
||||
}
|
||||
val settingsRepo = FakeSettingsRepo(
|
||||
dataSources = DataSourcesSettings(
|
||||
satelliteUrls = listOf(satnogsUrl),
|
||||
transceiversUrls = emptyList()
|
||||
)
|
||||
).apply { amSatActive = setOf(25544) }
|
||||
val repository = DatabaseRepo(dispatcher, dataParser, localSource, remoteSource, settingsRepo)
|
||||
|
||||
repository.updateFromRemote()
|
||||
|
||||
// No crash; ISS still collapses to the primary ZARYA (25544).
|
||||
assertEquals(setOf(25544), settingsRepo.amSatFm)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `amsat fm list falls back to local FM transceivers when page fails and no previous list`() =
|
||||
runTest(dispatcher) {
|
||||
// After clearing data, the previous FM list is empty AND the AMSAT
|
||||
// page is down (timeout) -> the filter must fall back to local FM
|
||||
// transceivers intersected with the whitelist, never stay empty.
|
||||
val satnogsUrl = Sources.satelliteDataUrls.getValue("SatNOGS")
|
||||
val localSource = FakeLocalSource().apply {
|
||||
idsWithModes = listOf(25544, 26700, 39444) // local FM-transceiver sats
|
||||
}
|
||||
val remoteSource = FakeRemoteSource().apply {
|
||||
networkStreams[satnogsUrl] = { issModulesCsvStream() }
|
||||
// FM/Linear pages NOT registered -> 404 -> fetch fails.
|
||||
}
|
||||
val settingsRepo = FakeSettingsRepo(
|
||||
dataSources = DataSourcesSettings(
|
||||
satelliteUrls = listOf(satnogsUrl),
|
||||
transceiversUrls = emptyList()
|
||||
)
|
||||
).apply { amSatActive = setOf(25544) } // whitelist has only ZARYA
|
||||
val repository = DatabaseRepo(dispatcher, dataParser, localSource, remoteSource, settingsRepo)
|
||||
|
||||
repository.updateFromRemote()
|
||||
|
||||
// Fallback = local FM ids ∩ whitelist = {25544}; ISS module aliases
|
||||
// (26700) are excluded by the whitelist.
|
||||
assertEquals(setOf(25544), settingsRepo.amSatFm)
|
||||
}
|
||||
|
||||
private fun amsatFmPageWithUnknownStream(): InputStream = """
|
||||
<table>
|
||||
<thead><tr><th>Satellite</th><th>Uplink</th><th>Downlink</th><th>Comment</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td>ISS</td><td>145.990 MHz</td><td>437.800 MHz</td><td></td></tr>
|
||||
<tr><td>TEVEL2-1 thru TEVEL2-9</td><td>145.970 MHz</td><td>436.400 MHz</td><td></td></tr>
|
||||
<tr><td>RS95S(QMR-KWT-2)</td><td>145.920 MHz</td><td>436.950 MHz</td><td></td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
""".trimIndent().byteInputStream()
|
||||
|
||||
private fun issModulesCsvStream(): InputStream = """
|
||||
OBJECT_NAME,OBJECT_ID,EPOCH,MEAN_MOTION,ECCENTRICITY,INCLINATION,RA_OF_ASC_NODE,ARG_OF_PERICENTER,MEAN_ANOMALY,EPHEMERIS_TYPE,CLASSIFICATION_TYPE,NORAD_CAT_ID,ELEMENT_SET_NO,REV_AT_EPOCH,BSTAR,MEAN_MOTION_DOT,MEAN_MOTION_DDOT
|
||||
ISS (ZARYA),1998-067A,2021-11-16T12:28:09.322176,15.48582035,.0004694,51.6447,309.4881,203.6966,299.8876,0,U,25544,999,31220,.31985E-4,.1288E-4,0
|
||||
ISS (UNITY),1998-067B,2021-11-16T12:28:09.322176,15.48582035,.0004694,51.6447,309.4881,203.6966,299.8876,0,U,25575,999,31220,.31985E-4,.1288E-4,0
|
||||
ISS (ZVEZDA),1998-067C,2021-11-16T12:28:09.322176,15.48582035,.0004694,51.6447,309.4881,203.6966,299.8876,0,U,26400,999,31220,.31985E-4,.1288E-4,0
|
||||
ISS (DESTINY),1998-067D,2021-11-16T12:28:09.322176,15.48582035,.0004694,51.6447,309.4881,203.6966,299.8876,0,U,26700,999,31220,.31985E-4,.1288E-4,0
|
||||
ISS (NAUKA),1998-067E,2021-11-16T12:28:09.322176,15.48582035,.0004694,51.6447,309.4881,203.6966,299.8876,0,U,49044,999,31220,.31985E-4,.1288E-4,0
|
||||
""".trimIndent().byteInputStream()
|
||||
|
||||
private fun amsatFmPageStream(): InputStream = """
|
||||
<table class="has-background">
|
||||
<thead><tr><th>Satellite</th><th>Uplink</th><th>Downlink</th><th>Comment</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td>ISS</td><td>145.990 MHz</td><td>437.800 MHz</td><td></td></tr>
|
||||
<tr><td>AO-91(RadFxSat / Fox-1B)</td><td>435.250 MHz</td><td>145.960 MHz</td><td></td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
""".trimIndent().byteInputStream()
|
||||
}
|
||||
|
||||
private class FakeRemoteSource : IRemoteSource {
|
||||
@@ -349,7 +187,6 @@ private class FakeRemoteSource : IRemoteSource {
|
||||
private class FakeLocalSource : ILocalSource {
|
||||
val insertedEntries = mutableListOf<OrbitalData>()
|
||||
private val insertedRadios = mutableListOf<SatRadio>()
|
||||
var idsWithModes: List<Int> = emptyList()
|
||||
|
||||
override suspend fun getEntriesTotal(): Int = insertedEntries.size
|
||||
|
||||
@@ -366,8 +203,8 @@ private class FakeLocalSource : ILocalSource {
|
||||
insertedEntries.clear()
|
||||
}
|
||||
|
||||
override suspend fun getIdsWithModes(modes: List<String>): List<Int> = idsWithModes
|
||||
override suspend fun getIdsWithModesAndUplink(modes: List<String>): List<Int> = idsWithModes
|
||||
override suspend fun getIdsWithModes(modes: List<String>): List<Int> = emptyList()
|
||||
override suspend fun getIdsWithModesAndUplink(modes: List<String>): List<Int> = emptyList()
|
||||
override suspend fun getIdsWithModesAndAmateur(modes: List<String>): List<Int> = emptyList()
|
||||
|
||||
override suspend fun getRadiosTotal(): Int = insertedRadios.size
|
||||
@@ -458,25 +295,11 @@ private class FakeSettingsRepo(dataSources: DataSourcesSettings = defaultDataSou
|
||||
(dataSourcesStatus as? MutableStateFlow)?.value = status
|
||||
}
|
||||
|
||||
var amSatFm: Set<Int> = emptySet()
|
||||
var amSatLinear: Set<Int> = emptySet()
|
||||
var amSatActive: Set<Int> = emptySet()
|
||||
override val amSatListsVersion: StateFlow<Int> = MutableStateFlow(0)
|
||||
|
||||
override fun getAmSatFmCatnums(): Set<Int> = amSatFm
|
||||
override fun getAmSatLinearCatnums(): Set<Int> = amSatLinear
|
||||
override fun setAmSatCatnums(fmCatnums: Set<Int>, linearCatnums: Set<Int>) {
|
||||
amSatFm = fmCatnums
|
||||
amSatLinear = linearCatnums
|
||||
}
|
||||
override fun getAmSatActiveCatnums(): Set<Int> = amSatActive
|
||||
override fun setAmSatActiveCatnums(catnums: Set<Int>) {
|
||||
amSatActive = catnums
|
||||
}
|
||||
|
||||
override fun updateRadioControlSettings(settings: RadioControlSettings) = Unit
|
||||
|
||||
override fun getSatelliteOffset(catnum: Int): String = ""
|
||||
override fun getSatelliteMode(catnum: Int): String = ""
|
||||
override fun setSatelliteMode(catnum: Int, mode: String) {}
|
||||
|
||||
override fun setSatelliteOffset(catnum: Int, offset: String) = Unit
|
||||
|
||||
|
||||
+71
@@ -169,6 +169,53 @@ class LoTWRepositoryTest {
|
||||
assertTrue(repo.parseConfirmedGridQsos(report(qso1, ground))!!.values.all { it.all { q -> q.myGrid == "OL62" } })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parseQsosCapturesMyVuccGridsMultiGridStation() {
|
||||
// A station location (台址) can span several grids: LoTW emits
|
||||
// <MY_VUCC_GRIDS> (comma-separated) with MY_GRIDSQUARE absent. Both
|
||||
// grids must land in myGrids, and myGrid (back-compat) = the first.
|
||||
val qso = "<CALL:5>A50QO\n<QSO_DATE:8>20260820\n<TIME_ON:4>1130\n" +
|
||||
"<PROP_MODE:3>SAT\n<SAT_NAME:5>RS-44\n<MODE:2>CW\n" +
|
||||
"<MY_VUCC_GRIDS:11>OM60,OM50\n<GRIDSQUARE:4>NL47\n<EOR>\n"
|
||||
val result = repo.parseConfirmedGridQsos(report(qso))!!
|
||||
val parsed = result["NL47"]!!.first()
|
||||
assertEquals(setOf("OM60", "OM50"), parsed.myGrids)
|
||||
assertEquals("OM60", parsed.myGrid)
|
||||
// 6-char fields are truncated to 4 like every other grid path.
|
||||
val six = "<PROP_MODE:3>SAT\n<SAT_NAME:5>RS-44\n" +
|
||||
"<MY_VUCC_GRIDS:17>OM60IL70,OM50MA20\n<GRIDSQUARE:4>OK48\n<EOR>\n"
|
||||
assertEquals(setOf("OM60", "OM50"), repo.parseConfirmedGridQsos(report(six))!!["OK48"]!!.first().myGrids)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parseQsosBuildsStationKeyFromMyFields() {
|
||||
// 台址 identity = the MY_* station snapshot (callsign + dxcc + state +
|
||||
// CQ/ITU + IOTA + country). A record with the full snapshot yields the
|
||||
// stable key the operated-grid selector groups by.
|
||||
val qso = "<CALL:5>A50QO\n<QSO_DATE:8>20260820\n<TIME_ON:4>1130\n" +
|
||||
"<PROP_MODE:3>SAT\n<SAT_NAME:5>RS-44\n" +
|
||||
"<STATION_CALLSIGN:6>BH6RJD\n<MY_DXCC:3>318\n<MY_STATE:11>JS // Jiangsu\n" +
|
||||
"<MY_CQ_ZONE:2>24\n<MY_ITU_ZONE:2>44\n<MY_COUNTRY:5>CHINA\n" +
|
||||
"<GRIDSQUARE:4>NL47\n<EOR>\n"
|
||||
val parsed = repo.parseConfirmedGridQsos(report(qso))!!["NL47"]!!.first()
|
||||
assertEquals("BH6RJD", parsed.myCallsign)
|
||||
assertEquals("BH6RJD|318|JS|24|44||CHINA", parsed.stationKey)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parseQsosStationKeyDistinguishesLocationsUnderSameCallsign() {
|
||||
// One callsign can own several 台址 (e.g. BH6RJD with a Hubei location
|
||||
// and a Zhejiang location); different MY_STATE -> different stationKey.
|
||||
val hb = "<CALL:5>A50QO\n<QSO_DATE:8>20260820\n<TIME_ON:4>1130\n" +
|
||||
"<PROP_MODE:3>SAT\n<SAT_NAME:5>RS-44\n" +
|
||||
"<STATION_CALLSIGN:6>BH6RJD\n<MY_STATE:11>HB // Hubei\n<GRIDSQUARE:4>NL47\n<EOR>\n"
|
||||
val zj = "<CALL:5>BG7ZFK\n<QSO_DATE:8>20260819\n<TIME_ON:4>1130\n" +
|
||||
"<PROP_MODE:3>SAT\n<SAT_NAME:5>SO-50\n" +
|
||||
"<STATION_CALLSIGN:6>BH6RJD\n<MY_STATE:11>ZJ // Zhejiang\n<GRIDSQUARE:4>OK48\n<EOR>\n"
|
||||
val result = repo.parseConfirmedGridQsos(report(hb, zj))!!
|
||||
assertTrue(result["NL47"]!!.first().stationKey != result["OK48"]!!.first().stationKey)
|
||||
}
|
||||
|
||||
// endregion
|
||||
|
||||
// region parseRoamedGrids (MY_GRIDSQUARE = grids the account operated from)
|
||||
@@ -224,6 +271,30 @@ class LoTWRepositoryTest {
|
||||
assertNull(repo.parseRoamedGrids("<HTML>Username/password incorrect</HTML>"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parseRoamedGridsCollectsMyVuccGrids() {
|
||||
// Multi-grid 台址: MY_VUCC_GRIDS carries the extra grids (MY_GRIDSQUARE
|
||||
// may be absent); every field must reach the roamed set for the stripes.
|
||||
val qso = "<PROP_MODE:3>SAT\n<SAT_NAME:5>RS-44\n" +
|
||||
"<MY_VUCC_GRIDS:11>OM60,OM50\n<GRIDSQUARE:4>NL47\n<EOR>\n"
|
||||
assertEquals(setOf("OM60", "OM50"), repo.parseRoamedGrids(report(qso)))
|
||||
// Combined with MY_GRIDSQUARE the union is kept.
|
||||
val both = "<PROP_MODE:3>SAT\n<SAT_NAME:5>RS-44\n" +
|
||||
"<MY_GRIDSQUARE:4>OL62\n<MY_VUCC_GRIDS:11>OM60,OM50\n<GRIDSQUARE:4>NL47\n<EOR>\n"
|
||||
assertEquals(setOf("OL62", "OM60", "OM50"), repo.parseRoamedGrids(report(both)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parseRoamedGridsUppercasesLowercaseMyGrid() {
|
||||
// LoTW emits the <eor> tag lowercase, so field CASE is not guaranteed:
|
||||
// a lowercase MY_GRIDSQUARE must still match the overlay's uppercase
|
||||
// labels (regression: take(4) without .uppercase() silently dropped
|
||||
// stripes for lowercase grids).
|
||||
val qso = "<PROP_MODE:3>SAT\n<SAT_NAME:5>IO-86\n" +
|
||||
"<MY_GRIDSQUARE:4>ol62\n<GRIDSQUARE:4>PM95\n<EOR>\n"
|
||||
assertEquals(setOf("OL62"), repo.parseRoamedGrids(report(qso)))
|
||||
}
|
||||
|
||||
// endregion
|
||||
|
||||
// region failure classification (fetchReportBody -> LoTWResult mapping)
|
||||
|
||||
+34
-65
@@ -113,14 +113,24 @@ class SelectionRepoSearchTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `FM virtual type shows only AMSAT FM list satellites`() = runTest {
|
||||
val repo = createRepo(
|
||||
items = sampleItems,
|
||||
amSatFm = setOf(25544, 39444)
|
||||
)
|
||||
fun `FM virtual type shows only hardcoded FM satellites`() = runTest {
|
||||
// AO-123 ASRTU-1 (61781) is on the hardcoded FM list; a random
|
||||
// satellite (99999) is not.
|
||||
val items = sampleItems + SatItem(catnum = 61781, name = "ASRTU-1")
|
||||
val repo = createRepo(items)
|
||||
repo.setTypes(listOf("AMSAT Live FM"))
|
||||
val results = repo.getEntriesFlow().first()
|
||||
assertEquals(listOf(25544, 39444), results.map { it.catnum })
|
||||
assertEquals(setOf(25544, 61781), results.map { it.catnum }.toSet())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Linear virtual type shows only hardcoded linear satellites`() = runTest {
|
||||
val repo = createRepo(sampleItems)
|
||||
repo.setTypes(listOf("AMSAT Live Linear"))
|
||||
val results = repo.getEntriesFlow().first()
|
||||
// Hardcoded linear list: RS-44 (44909), FO-29 (24278), AO-7 (7530),
|
||||
// AO-73 (39444), JO-97 (43803). Present in the sample DB: 7530, 39444, 43803.
|
||||
assertEquals(setOf(7530, 39444, 43803), results.map { it.catnum }.toSet())
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -140,8 +150,7 @@ class SelectionRepoSearchTest {
|
||||
// only catnums the DAO would have filtered to Amateur service (43803).
|
||||
val repo = createRepo(
|
||||
items = sampleItems,
|
||||
sstvIds = listOf(43803),
|
||||
amSatActive = setOf(43803)
|
||||
sstvIds = listOf(43803)
|
||||
)
|
||||
repo.setTypes(listOf("Live SSTV"))
|
||||
val results = repo.getEntriesFlow().first()
|
||||
@@ -155,8 +164,7 @@ class SelectionRepoSearchTest {
|
||||
val items = sampleItems + SatItem(catnum = 60239, name = "ARIANE 6 R/B")
|
||||
val repo = createRepo(
|
||||
items = items,
|
||||
sstvIds = listOf(43803, 60239),
|
||||
amSatActive = setOf(43803, 60239)
|
||||
sstvIds = listOf(43803, 60239)
|
||||
)
|
||||
repo.setTypes(listOf("Live SSTV"))
|
||||
val results = repo.getEntriesFlow().first()
|
||||
@@ -167,8 +175,6 @@ class SelectionRepoSearchTest {
|
||||
fun `multiple virtual types union their satellites`() = runTest {
|
||||
val repo = createRepo(
|
||||
items = sampleItems,
|
||||
amSatFm = setOf(25544),
|
||||
amSatLinear = setOf(7530),
|
||||
sstvIds = listOf(43803)
|
||||
)
|
||||
repo.setTypes(listOf("AMSAT Live FM", "Live SSTV"))
|
||||
@@ -178,7 +184,7 @@ class SelectionRepoSearchTest {
|
||||
|
||||
@Test
|
||||
fun `clearing types restores full list`() = runTest {
|
||||
val repo = createRepo(items = sampleItems, amSatFm = setOf(25544))
|
||||
val repo = createRepo(sampleItems)
|
||||
repo.setTypes(listOf("AMSAT Live FM"))
|
||||
assertTrue(repo.getEntriesFlow().first().isNotEmpty())
|
||||
repo.setTypes(emptyList())
|
||||
@@ -186,23 +192,24 @@ class SelectionRepoSearchTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `virtual type with empty AMSAT list shows empty not everything`() = runTest {
|
||||
// 根因2回归: AMSAT 清单未同步(空)时, 选虚拟类型应显示空列表, 而非全部卫星.
|
||||
val repo = createRepo(items = sampleItems, amSatFm = emptySet())
|
||||
fun `virtual type with no matching satellites shows empty not everything`() = runTest {
|
||||
// 硬编码 FM 列表 (27607/25544/61781) 里没有样本库中的卫星时, 选虚拟
|
||||
// 类型应显示空列表, 而非全部卫星.
|
||||
val items = listOf(SatItem(catnum = 7530, name = "AO-7 (AMSAT-OSCAR 7)"))
|
||||
val repo = createRepo(items)
|
||||
repo.setTypes(listOf("AMSAT Live FM"))
|
||||
assertTrue(repo.getEntriesFlow().first().isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `virtual type combined with regular type unions both lists`() = runTest {
|
||||
val repo = createRepo(
|
||||
items = sampleItems,
|
||||
amSatFm = setOf(25544),
|
||||
amSatLinear = setOf(7530)
|
||||
)
|
||||
val repo = createRepo(items = sampleItems)
|
||||
repo.setTypes(listOf("AMSAT Live FM", "AMSAT Live Linear"))
|
||||
val results = repo.getEntriesFlow().first()
|
||||
assertEquals(setOf(25544, 7530), results.map { it.catnum }.toSet())
|
||||
assertEquals(
|
||||
setOf(25544, 7530, 39444, 43803),
|
||||
results.map { it.catnum }.toSet()
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -256,40 +263,14 @@ class SelectionRepoSearchTest {
|
||||
assertEquals(sampleItems.map { it.catnum }.toSet(), results.map { it.catnum }.toSet())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `amsat list version bump re-resolves FM filter after data sync`() = runTest {
|
||||
// ISS module aliases: dirty pre-sync FM list contains both ZARYA and DESTINY.
|
||||
val items = listOf(
|
||||
SatItem(25544, "ISS (ZARYA)"),
|
||||
SatItem(26700, "ISS (DESTINY)")
|
||||
)
|
||||
val fake = FakeSettingsRepoForSearch(amSatFm = setOf(25544, 26700))
|
||||
val repo = SelectionRepo(
|
||||
dispatcher = Dispatchers.Unconfined,
|
||||
localSource = FakeLocalSourceForSearch(items),
|
||||
settingsRepo = fake
|
||||
)
|
||||
repo.setTypes(listOf("AMSAT Live FM"))
|
||||
assertEquals(setOf(25544, 26700), repo.getEntriesFlow().first().map { it.catnum }.toSet())
|
||||
|
||||
// Simulate a background data sync: lists rewritten + version bumped.
|
||||
fake.setAmSatCatnums(fmCatnums = setOf(25544), linearCatnums = emptySet())
|
||||
|
||||
// The FM filter must reflect the new list WITHOUT a type toggle or restart.
|
||||
assertEquals(listOf(25544), repo.getEntriesFlow().first().map { it.catnum })
|
||||
}
|
||||
|
||||
private fun createRepo(
|
||||
items: List<SatItem>,
|
||||
amSatFm: Set<Int> = emptySet(),
|
||||
amSatLinear: Set<Int> = emptySet(),
|
||||
sstvIds: List<Int> = emptyList(),
|
||||
amSatActive: Set<Int> = emptySet()
|
||||
sstvIds: List<Int> = emptyList()
|
||||
): ISelectionRepo {
|
||||
return SelectionRepo(
|
||||
dispatcher = Dispatchers.Unconfined,
|
||||
localSource = FakeLocalSourceForSearch(items, sstvIds),
|
||||
settingsRepo = FakeSettingsRepoForSearch(amSatFm, amSatLinear, amSatActive)
|
||||
settingsRepo = FakeSettingsRepoForSearch()
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -313,11 +294,7 @@ private class FakeLocalSourceForSearch(
|
||||
override suspend fun deleteRadios() = Unit
|
||||
}
|
||||
|
||||
private class FakeSettingsRepoForSearch(
|
||||
var amSatFm: Set<Int> = emptySet(),
|
||||
var amSatLinear: Set<Int> = emptySet(),
|
||||
private var amSatActive: Set<Int> = emptySet()
|
||||
) : ISettingsRepo {
|
||||
private class FakeSettingsRepoForSearch : ISettingsRepo {
|
||||
private val typeIds = mutableMapOf<String, List<Int>>()
|
||||
override val appVersionName: String = "test"
|
||||
override val selectedIds: StateFlow<List<Int>> = MutableStateFlow(emptyList())
|
||||
@@ -364,18 +341,10 @@ private class FakeSettingsRepoForSearch(
|
||||
override fun updateOtherSettings(transform: (OtherSettings) -> OtherSettings) = Unit
|
||||
override fun updateDataSourcesSettings(settings: DataSourcesSettings) = Unit
|
||||
override fun updateDataSourcesStatus(status: Map<String, Int>) = Unit
|
||||
override fun getAmSatFmCatnums(): Set<Int> = amSatFm
|
||||
override fun getAmSatLinearCatnums(): Set<Int> = amSatLinear
|
||||
override val amSatListsVersion: StateFlow<Int> = MutableStateFlow(0)
|
||||
override fun setAmSatCatnums(fmCatnums: Set<Int>, linearCatnums: Set<Int>) {
|
||||
amSatFm = fmCatnums
|
||||
amSatLinear = linearCatnums
|
||||
(amSatListsVersion as MutableStateFlow<Int>).value++
|
||||
}
|
||||
override fun getAmSatActiveCatnums(): Set<Int> = amSatActive
|
||||
override fun setAmSatActiveCatnums(catnums: Set<Int>) { amSatActive = catnums }
|
||||
override fun updateRadioControlSettings(settings: RadioControlSettings) = Unit
|
||||
override fun getSatelliteOffset(catnum: Int): String = ""
|
||||
override fun getSatelliteMode(catnum: Int): String = ""
|
||||
override fun setSatelliteMode(catnum: Int, mode: String) {}
|
||||
override fun setSatelliteOffset(catnum: Int, offset: String) = Unit
|
||||
override fun getAmSatCallsign(): String = ""
|
||||
override fun setAmSatCallsign(callsign: String) = Unit
|
||||
|
||||
Binary file not shown.
@@ -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,25 @@
|
||||
/*
|
||||
* 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 markUploaded(ids: List<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
|
||||
}
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
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,
|
||||
// On satellites, MODE=MFSK means FT4 (ADIF pairing); keep the sub-mode so
|
||||
// confirmations match local FT4 records (mode=MFSK, submode=FT4).
|
||||
submode = if (mode.equals("MFSK", true)) "FT4" else "",
|
||||
satelliteName = satName,
|
||||
propagationMode = "SAT",
|
||||
status = QsoStatus.COMPLETE,
|
||||
lotwConfirmed = true,
|
||||
dxcc = dxcc,
|
||||
country = country.orEmpty(),
|
||||
cqZone = cqz,
|
||||
region = state.orEmpty()
|
||||
)
|
||||
+70
@@ -0,0 +1,70 @@
|
||||
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() {
|
||||
// Satellite FT4 is MODE=MFSK + SUBMODE=FT4; any other mode keeps its own
|
||||
// label even when a stale submode default ("FT4") was persisted.
|
||||
val label = if (mode.equals("MFSK", true) && submode.isNotBlank()) submode else mode
|
||||
return label.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,49 @@
|
||||
/*
|
||||
* 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 = "FM",
|
||||
val submode: String = "",
|
||||
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 lotwUploaded: 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)
|
||||
@@ -49,8 +49,27 @@ data class GridQso(
|
||||
val country: String? = null,
|
||||
val cqz: Int? = null,
|
||||
val state: String? = null,
|
||||
/** 4-char grid the station itself operated from (ADIF MY_GRIDSQUARE). */
|
||||
val myGrid: String? = null
|
||||
/** 4-char grid the station itself operated from (ADIF MY_GRIDSQUARE).
|
||||
* Kept as the first entry of [myGrids] for backward compatibility with
|
||||
* consumers that predate multi-grid station locations. */
|
||||
val myGrid: String? = null,
|
||||
/** Every 4-char grid the station operated from for this QSO — ADIF
|
||||
* <MY_GRIDSQUARE> plus every field of <MY_VUCC_GRIDS> (a comma-separated
|
||||
* list LoTW emits when one station location roams across several grid
|
||||
* squares; MY_GRIDSQUARE may be absent on those records). A station
|
||||
* location (台址) can legitimately span multiple grids, and the map's
|
||||
* stripes and the operated-grid selector must show all of them. */
|
||||
val myGrids: Set<String> = emptySet(),
|
||||
/** Callsign of the station location this QSO was logged under (ADIF
|
||||
* <STATION_CALLSIGN>; e.g. "BH6RJD", "BH6RJD/P"). NOT the 台址 identity
|
||||
* on its own — one callsign can have several station locations. */
|
||||
val myCallsign: String? = null,
|
||||
/** Stable key of the station location (台址) this QSO was logged under,
|
||||
* derived from the MY_* station fields (STATION_CALLSIGN + MY_DXCC +
|
||||
* MY_STATE + MY_CQ_ZONE + MY_ITU_ZONE + MY_IOTA + MY_COUNTRY), so the
|
||||
* operated-grid selector can group QSOs per 台址 and show each 台址 with
|
||||
* its full grid set. Null for data synced before this field existed. */
|
||||
val stationKey: String? = null
|
||||
) {
|
||||
/** Short uplink/downlink band label ("U/V", "V/A"), or "" when unknown. */
|
||||
val bandLabel: String
|
||||
|
||||
@@ -75,9 +75,23 @@ data class OtherSettings(
|
||||
* ephemeris auto-update; only effective after at least one manual sync). */
|
||||
val stateOfAutoLotwSync: Boolean = true,
|
||||
/** 指南针手动航向偏置(度, -180..180): 雷达方位 = 传感器方位 + 磁偏角 + 该偏置. */
|
||||
val compassOffsetDegrees: Float = 0f
|
||||
val compassOffsetDegrees: Float = 0f,
|
||||
val mapSource: String = MapSource.OSM,
|
||||
val tiandituKey: String = ""
|
||||
)
|
||||
|
||||
object MapSource {
|
||||
const val OSM = "osm"
|
||||
const val TIANDITU_VECTOR = "tianditu_vector"
|
||||
const val TIANDITU_IMAGE = "tianditu_image"
|
||||
private const val LEGACY_TIANDITU = "tianditu"
|
||||
|
||||
fun normalize(source: String): String = when (source) {
|
||||
OSM, TIANDITU_VECTOR, TIANDITU_IMAGE -> source
|
||||
LEGACY_TIANDITU -> TIANDITU_VECTOR
|
||||
else -> OSM
|
||||
}
|
||||
}
|
||||
data class DataSourcesSettings(
|
||||
val satelliteUrls: List<String>,
|
||||
val transceiversUrls: List<String>,
|
||||
|
||||
+96
@@ -0,0 +1,96 @@
|
||||
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()
|
||||
/** Region field (State/Province/Prefecture…) and national CQZ/ITUZ map for a DXCC entity. */
|
||||
suspend fun stationMeta(dxcc: Int): LoTWStationMeta
|
||||
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 = ""
|
||||
)
|
||||
|
||||
/** One (ITU:CQ) pair from a LoTW zonemap, e.g. Guangdong is 44:24. */
|
||||
data class LoTWZonePair(val itu: Int, val cq: Int)
|
||||
|
||||
/** One selectable region code with the zones it maps to (cross-zone regions carry several pairs). */
|
||||
data class LoTWRegionOption(val code: String, val name: String, val zones: List<LoTWZonePair>)
|
||||
|
||||
/** Country-dependent region field (US_STATE, CN_PROVINCE, …) with its selectable options. */
|
||||
data class LoTWRegionField(val id: String, val label: String, val options: List<LoTWRegionOption>)
|
||||
|
||||
/**
|
||||
* Everything the station-location UI needs for one DXCC entity:
|
||||
* the region field to show (null when the country has none) and the national
|
||||
* zonemap (used to prefill CQZ/ITUZ when the country has a single zone pair).
|
||||
*/
|
||||
data class LoTWStationMeta(
|
||||
val regionField: LoTWRegionField? = null,
|
||||
val countryZones: List<LoTWZonePair> = emptyList()
|
||||
)
|
||||
|
||||
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,
|
||||
CERTIFICATE_FORMAT, 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/>.
|
||||
*/
|
||||
package com.rtbishop.look4sat.core.domain.repository
|
||||
import com.rtbishop.look4sat.core.domain.logbook.IQsoRepository
|
||||
import com.rtbishop.look4sat.core.domain.model.SatRadio
|
||||
import com.rtbishop.look4sat.core.domain.usecase.IAddToCalendar
|
||||
import com.rtbishop.look4sat.core.domain.usecase.IAudioCapture
|
||||
@@ -34,6 +35,8 @@ interface IMainContainer {
|
||||
val updateRepo: IUpdateRepository
|
||||
val wavelogRepo: IWavelogRepository
|
||||
val lotwRepo: com.rtbishop.look4sat.core.domain.repository.ILoTWRepository
|
||||
val qsoRepository: IQsoRepository
|
||||
val lotwUploadRepository: ILoTWUploadRepository
|
||||
val radioTrackingService: IRadioTrackingService
|
||||
val mutualPassData: StateFlow<MutualPassData>
|
||||
fun setMutualPassData(data: MutualPassData)
|
||||
|
||||
+6
-17
@@ -79,23 +79,6 @@ interface ISettingsRepo {
|
||||
fun updateDataSourcesStatus(status: Map<String, Int>)
|
||||
//endregion
|
||||
|
||||
//region # AMSAT live-transponder lists
|
||||
/** Catalog numbers of satellites listed on AMSAT's "Live FM Satellites" page. */
|
||||
fun getAmSatFmCatnums(): Set<Int>
|
||||
/** Catalog numbers of satellites listed on AMSAT's "Live Linear Satellites" page. */
|
||||
fun getAmSatLinearCatnums(): Set<Int>
|
||||
fun setAmSatCatnums(fmCatnums: Set<Int>, linearCatnums: Set<Int>)
|
||||
/** Catalog numbers of active *amateur* satellites from AMSAT's
|
||||
* active-transponder table (whitelist for the virtual type filters). */
|
||||
fun getAmSatActiveCatnums(): Set<Int>
|
||||
fun setAmSatActiveCatnums(catnums: Set<Int>)
|
||||
/** Monotonic version counter, incremented every time the AMSAT live
|
||||
* FM/Linear lists are rewritten. Consumers that resolve the virtual
|
||||
* types (SelectionRepo) should re-resolve on change so a background
|
||||
* data sync is reflected in the UI without a restart. */
|
||||
val amSatListsVersion: StateFlow<Int>
|
||||
//endregion
|
||||
|
||||
//region # Radio control settings
|
||||
val radioControlSettings: StateFlow<RadioControlSettings>
|
||||
fun updateRadioControlSettings(settings: RadioControlSettings)
|
||||
@@ -106,6 +89,12 @@ interface ISettingsRepo {
|
||||
fun setSatelliteOffset(catnum: Int, offset: String)
|
||||
//endregion
|
||||
|
||||
//region # Per-satellite logbook mode preset settings
|
||||
/** Last selected logbook mode (CW/SSB/FT4) for a satellite; empty when unset. */
|
||||
fun getSatelliteMode(catnum: Int): String
|
||||
fun setSatelliteMode(catnum: Int, mode: String)
|
||||
//endregion
|
||||
|
||||
//region # AMSAT status report settings
|
||||
fun getAmSatCallsign(): String
|
||||
fun setAmSatCallsign(callsign: String)
|
||||
|
||||
@@ -21,6 +21,8 @@ object Sources {
|
||||
val satelliteDataUrls = mapOf(
|
||||
"LAPAN-A2" to "https://www.kaggle.com/api/v1/datasets/download/muazamnugroho/lapan-a2-satellite-two-line-element-tle-dataset/LAPAN-A2_TLE_latest.txt",
|
||||
"R4UAB" to "https://r4uab.ru/satonline.txt",
|
||||
"BI4PYM AutoTLE (GitHub)" to "https://raw.githubusercontent.com/BI4PYM/AutoTLE/refs/heads/master/AutoTLE.txt",
|
||||
"BI4PYM AutoTLE (Mirror)" to "https://autotle.bi4pym.cn/AutoTLE.txt",
|
||||
"All" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=active&FORMAT=csv",
|
||||
"SatNOGS" to "https://db.satnogs.org/api/tle/?format=3le",
|
||||
"Amateur" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=amateur&FORMAT=csv",
|
||||
@@ -55,31 +57,25 @@ object Sources {
|
||||
"SatNOGS" to "https://db.satnogs.org/api/transmitters/?format=json&status=active",
|
||||
"R4UAB" to "https://r4uab.ru/transmitters.json"
|
||||
)
|
||||
/** AMSAT "Live FM/Linear Satellites" pages: human-maintained lists of
|
||||
* transponders currently on the air. Parsed into the mutual-match filter
|
||||
* (satellites that actually carry a working FM-voice / linear repeater). */
|
||||
val amSatLiveUrls = mapOf(
|
||||
"FM" to "https://www.amsat.org/live-fm-satellites/",
|
||||
"Linear" to "https://www.amsat.org/live-linear-satellites/"
|
||||
)
|
||||
/**
|
||||
* Hardcoded AMSAT Live FM satellites (NORAD catnums):
|
||||
* SO-50 (27607), ISS ZARYA (25544), AO-123 ASRTU-1 (61781).
|
||||
* Replaces the AMSAT live-page fetch: stable on any network, no
|
||||
* sync-time dependency, no stale-list or timeout failure modes.
|
||||
*/
|
||||
val amSatFmCatnums = setOf(27607, 25544, 61781)
|
||||
|
||||
/** Machine-readable AMSAT active-transponder table (palewire mirror of
|
||||
* amsat.org's active-frequency pages): one CSV row per active amateur
|
||||
* transponder with its NORAD catnum. Used as the authoritative whitelist
|
||||
* of *amateur* satellites, so the virtual filters never match
|
||||
* non-amateur debris (Ariane 6 R/B), retired weather sats (TIROS), or
|
||||
* ISS station-module aliases (ISS (DESTINY) etc.). Primary source plus
|
||||
* a jsDelivr CDN mirror: raw.githubusercontent.com is unreachable on
|
||||
* some networks (e.g. mainland China mobile), and a failed whitelist
|
||||
* fetch would silently disable the module disambiguation. */
|
||||
val amSatActiveUrls = listOf(
|
||||
"https://raw.githubusercontent.com/palewire/amateur-satellite-database/main/data/amsat-active-frequencies.csv",
|
||||
"https://cdn.jsdelivr.net/gh/palewire/amateur-satellite-database@main/data/amsat-active-frequencies.csv"
|
||||
)
|
||||
/**
|
||||
* Hardcoded AMSAT Live linear (SSB/CW) satellites:
|
||||
* RS-44 (44909), FO-29 (24278), AO-7 (7530),
|
||||
* AO-73 FUNcube-1 (39444), JO-97 JY1SAT (43803).
|
||||
*/
|
||||
val amSatLinearCatnums = setOf(44909, 24278, 7530, 39444, 43803)
|
||||
|
||||
/** Virtual satellite-selection types: transponder/activity filters shown
|
||||
* at the top of the type picker. They resolve to live lists (AMSAT pages
|
||||
* or mode=SSTV radios) instead of persisted per-type IDs, and are
|
||||
* mutually exclusive with the regular TLE-source types in the picker. */
|
||||
* at the top of the type picker. They resolve to live lists (hardcoded
|
||||
* FM/Linear catnum sets or mode=SSTV radios) instead of persisted
|
||||
* per-type IDs, and are mutually exclusive with the regular TLE-source
|
||||
* types in the picker. */
|
||||
val virtualTypeNames = listOf("AMSAT Live FM", "AMSAT Live Linear", "Live SSTV")
|
||||
}
|
||||
@@ -40,21 +40,6 @@ class DataParser(private val dispatcher: CoroutineDispatcher) {
|
||||
}
|
||||
}
|
||||
|
||||
/** Parse AMSAT's active-transponder CSV (palewire mirror) and return the
|
||||
* set of NORAD catnums of active *amateur* satellites. Header row has a
|
||||
* norad_id column; some rows carry alphanumeric IDs (Bluebird A0241 etc.)
|
||||
* which are skipped. */
|
||||
suspend fun parseAmSatActiveCatnums(stream: InputStream): Set<Int> = withContext(dispatcher) {
|
||||
stream.bufferedReader().useLines { lines ->
|
||||
val header = lines.firstOrNull() ?: return@useLines emptySet()
|
||||
val noradIdx = header.split(",").indexOfFirst { it.trim().equals("norad_id", true) }
|
||||
if (noradIdx < 0) return@useLines emptySet()
|
||||
lines.mapNotNull { line ->
|
||||
line.split(",").getOrNull(noradIdx)?.trim()?.toIntOrNull()
|
||||
}.toSet()
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun parseTLEStream(stream: InputStream): List<OrbitalData> = withContext(dispatcher) {
|
||||
stream.bufferedReader().readLines()
|
||||
.chunked(3)
|
||||
@@ -73,101 +58,6 @@ class DataParser(private val dispatcher: CoroutineDispatcher) {
|
||||
}.getOrDefault(emptyList())
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse an AMSAT "Live FM/Linear Satellites" page into satellite names.
|
||||
* The page is an HTML table whose first column is the satellite name,
|
||||
* sometimes written as an alias list ("AO-91(RadFxSat / Fox-1B)") or a
|
||||
* range ("TEVEL2-1 thru TEVEL2-9"). Returns the distinct expanded names.
|
||||
*/
|
||||
suspend fun parseAmSatLivePage(stream: InputStream): List<String> = withContext(dispatcher) {
|
||||
runCatching {
|
||||
val html = stream.bufferedReader().readText()
|
||||
// Grab table rows, take the first cell of each row, keep rows that
|
||||
// look like satellite names (start with letters/digits, not a header).
|
||||
val rows = Regex("<tr[^>]*>(.*?)</tr>", RegexOption.IGNORE_CASE)
|
||||
.findAll(html)
|
||||
.mapNotNull { match ->
|
||||
val cells = Regex("<t[dh][^>]*>(.*?)</t[dh]>", RegexOption.IGNORE_CASE)
|
||||
.findAll(match.groupValues[1])
|
||||
.map { cell -> stripHtml(cell.groupValues[1]).trim() }
|
||||
.toList()
|
||||
cells.firstOrNull()?.takeIf { it.isNotBlank() && !it.equals("Satellite", ignoreCase = true) }
|
||||
}
|
||||
.toList()
|
||||
rows.flatMap { expandNameRange(it) }.distinct()
|
||||
}.getOrDefault(emptyList())
|
||||
}
|
||||
|
||||
private fun stripHtml(raw: String): String =
|
||||
raw.replace(Regex("<[^>]+>"), " ").replace(Regex("\\s+"), " ").trim()
|
||||
|
||||
/** Expand "NAME1 thru NAME9" ranges (e.g. TEVEL2-1 thru TEVEL2-9). */
|
||||
private fun expandNameRange(name: String): List<String> {
|
||||
val m = Regex("^(.*?)(\\d+)\\s+thru\\s+(.*?)(\\d+)$", RegexOption.IGNORE_CASE).find(name)
|
||||
if (m == null) return listOf(name)
|
||||
val prefix1 = m.groupValues[1].trim()
|
||||
val startNum = m.groupValues[2].toIntOrNull() ?: return listOf(name)
|
||||
val endNum = m.groupValues[4].toIntOrNull() ?: return listOf(name)
|
||||
val prefix2 = m.groupValues[3].trim()
|
||||
if (startNum > endNum) return listOf(name)
|
||||
// Suffix after the end number, if any (e.g. "TEVEL2-1 thru TEVEL2-9 ").
|
||||
val suffix = name.substringAfterLast(m.groupValues[4]).trim()
|
||||
return (startNum..endNum).map { "$prefix1$it$suffix" }
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a satellite name from the AMSAT live pages into lookup keys
|
||||
* used to match against the local entries table:
|
||||
* - primary key: the leading designator ("AO-91" from "AO-91 (RadFxSat / Fox-1B)")
|
||||
* - alias keys: every bracketed alias, uppercased, digits preserved
|
||||
* The local entry names are normalized the same way in [matchesAmSatName].
|
||||
*/
|
||||
fun normalizeAmSatName(name: String): List<String> {
|
||||
val keys = mutableListOf<String>()
|
||||
// Primary: everything before the first '(' (or '['), then the first token.
|
||||
val primary = name.substringBefore('(').substringBefore('[').trim()
|
||||
primary.split(Regex("\\s+")).firstOrNull()?.takeIf { it.isNotBlank() }?.let {
|
||||
keys += it.uppercase()
|
||||
}
|
||||
// Aliases inside parentheses / brackets: "RadFxSat / Fox-1B" -> two keys.
|
||||
Regex("\\(([^)]*)\\)").findAll(name).forEach { m ->
|
||||
m.groupValues[1].split('/', '|').forEach { alias ->
|
||||
alias.trim().takeIf { it.isNotBlank() }?.let { keys += it.uppercase() }
|
||||
}
|
||||
}
|
||||
Regex("\\[([^]]*)]").findAll(name).forEach { m ->
|
||||
m.groupValues[1].split('/', '|').forEach { alias ->
|
||||
alias.trim().takeIf { it.isNotBlank() }?.let { keys += it.uppercase() }
|
||||
}
|
||||
}
|
||||
return keys.distinct()
|
||||
}
|
||||
|
||||
/** True if a local entry name matches any of the AMSAT normalized keys.
|
||||
* Token-based exact match (split on spaces/brackets/slashes), so a key
|
||||
* like "ISS" does not substring-match "AISSAT-1" — the key must equal a
|
||||
* whole name token (case-insensitive). OSCAR designators are expanded so
|
||||
* that "AO-7" also matches local names spelled "OSCAR 7" / "AMSAT-OSCAR 7"
|
||||
* (the form used by CelesTrak/SatNOGS TLE sources). */
|
||||
fun matchesAmSatName(localName: String, amSatKeys: List<String>): Boolean {
|
||||
val localUpper = localName.uppercase()
|
||||
val localTokens = localUpper
|
||||
.split(Regex("[\\s()\\[\\]/]+"))
|
||||
.filter { it.isNotBlank() }
|
||||
.toSet()
|
||||
return amSatKeys.any { key ->
|
||||
val k = key.uppercase()
|
||||
if (k in localTokens) return@any true
|
||||
// "AO-7" -> also match local "OSCAR 7" or "AMSAT-OSCAR 7".
|
||||
val oscar = Regex("^([A-Z]{1,3})-(\\d+)$").find(k)
|
||||
if (oscar != null) {
|
||||
val num = oscar.groupValues[2]
|
||||
if ("OSCAR $num" in localUpper || "AMSAT-OSCAR $num" in localUpper) return@any true
|
||||
}
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseCSV(values: List<String>): OrbitalData? = runCatching {
|
||||
val name = values[0]
|
||||
val timestamp = values[2]
|
||||
|
||||
@@ -239,60 +239,4 @@ class DataParserTest {
|
||||
// Matches the CSV test data epoch: 2021-11-16 → day 320
|
||||
assert(dataParser.getDayOfYear(2021, 11, 16) == 320)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `AMSAT FM page parses satellite names and expands thru ranges`() = runTest(testDispatcher) {
|
||||
val html = """
|
||||
<html><body><table>
|
||||
<tr><th>Satellite</th><th>Uplink</th><th>Downlink</th><th>Comment</th></tr>
|
||||
<tr><td>AO-91(RadFxSat / Fox-1B)</td><td>435.250</td><td>145.960</td></tr>
|
||||
<tr><td>SO-50(SaudiSat-1C)</td><td>145.850</td><td>436.795</td></tr>
|
||||
<tr><td>TEVEL2-1 thru TEVEL2-9</td><td>145.970</td><td>436.400</td></tr>
|
||||
</table></body></html>
|
||||
""".trimIndent().byteInputStream()
|
||||
val names = dataParser.parseAmSatLivePage(html)
|
||||
assert(names.contains("AO-91(RadFxSat / Fox-1B)"))
|
||||
assert(names.contains("SO-50(SaudiSat-1C)"))
|
||||
assert(names.contains("TEVEL2-1"))
|
||||
assert(names.contains("TEVEL2-9"))
|
||||
assert(names.size == 11) // 2 singles + 9 expanded TEVEL2
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `AMSAT linear page keeps mode-suffixed names`() = runTest(testDispatcher) {
|
||||
val html = """
|
||||
<table>
|
||||
<tr><th>Satellite</th><th>Frequencies</th><th>Comment</th></tr>
|
||||
<tr><td>AO-7 Mode B U/v Inverting Analog</td><td>Uplink LSB</td></tr>
|
||||
<tr><td>FO-29 (JAS-2)V/u Inverting Analog</td><td>Uplink LSB</td></tr>
|
||||
</table>
|
||||
""".trimIndent().byteInputStream()
|
||||
val names = dataParser.parseAmSatLivePage(html)
|
||||
assert(names.contains("AO-7 Mode B U/v Inverting Analog"))
|
||||
assert(names.contains("FO-29 (JAS-2)V/u Inverting Analog"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `AMSAT name normalization produces primary and alias keys`() {
|
||||
assert(dataParser.normalizeAmSatName("AO-91(RadFxSat / Fox-1B)") == listOf("AO-91", "RADFXSAT", "FOX-1B"))
|
||||
assert(dataParser.normalizeAmSatName("QO-100 (Es'hail-2/P4A) S/x") == listOf("QO-100", "ES'HAIL-2", "P4A"))
|
||||
assert(dataParser.normalizeAmSatName("ISS") == listOf("ISS"))
|
||||
assert(dataParser.matchesAmSatName("AO-91 (RADFXSAT)", listOf("AO-91", "RADFXSAT")))
|
||||
assert(!dataParser.matchesAmSatName("SO-50", listOf("AO-91")))
|
||||
// Regression: "ISS" must not substring-match "AISSAT-1" (token match only).
|
||||
assert(dataParser.matchesAmSatName("ISS (ZARYA)", listOf("ISS")))
|
||||
assert(!dataParser.matchesAmSatName("AISSAT-1", listOf("ISS")))
|
||||
assert(!dataParser.matchesAmSatName("TESS", listOf("ISS")))
|
||||
// OSCAR designators match local names spelled "OSCAR N"/"AMSAT-OSCAR N".
|
||||
assert(dataParser.matchesAmSatName("OSCAR 7", listOf("AO-7")))
|
||||
assert(dataParser.matchesAmSatName("AMSAT-OSCAR 7", listOf("AO-7")))
|
||||
assert(dataParser.matchesAmSatName("CATSAT", listOf("CATSAT")))
|
||||
assert(dataParser.matchesAmSatName("RS-44 & BREEZE-KM R/B", listOf("RS-44")))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `AMSAT live page with no table returns empty`() = runTest(testDispatcher) {
|
||||
val html = "<html><body>No data</body></html>".byteInputStream()
|
||||
assert(dataParser.parseAmSatLivePage(html).isEmpty())
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
+136
@@ -0,0 +1,136 @@
|
||||
/*
|
||||
* 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)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun displayMode_ignoresStaleSubmodeForOtherModes() {
|
||||
// Regression: records persisted with the old default submode="FT4" must not
|
||||
// show FT4 when the actual mode is FM/CW/SSB.
|
||||
val staleFm = QsoRecord(startUtcMillis = 0, theirCallsign = "x", myCallsign = "y", mode = "FM", submode = "FT4")
|
||||
val staleCw = QsoRecord(startUtcMillis = 0, theirCallsign = "x", myCallsign = "y", mode = "CW", submode = "FT4")
|
||||
assertEquals("FM", staleFm.displayMode)
|
||||
assertEquals("CW", staleCw.displayMode)
|
||||
}
|
||||
}
|
||||
+2
-6
@@ -283,14 +283,10 @@ class LoTWGridSyncTest {
|
||||
override fun updateOtherSettings(transform: (OtherSettings) -> OtherSettings) = TODO()
|
||||
override fun updateDataSourcesSettings(settings: DataSourcesSettings) = TODO()
|
||||
override fun updateDataSourcesStatus(status: Map<String, Int>) = TODO()
|
||||
override fun getAmSatFmCatnums(): Set<Int> = emptySet()
|
||||
override fun getAmSatLinearCatnums(): Set<Int> = emptySet()
|
||||
override val amSatListsVersion: StateFlow<Int> = MutableStateFlow(0)
|
||||
override fun setAmSatCatnums(fmCatnums: Set<Int>, linearCatnums: Set<Int>) = Unit
|
||||
override fun getAmSatActiveCatnums(): Set<Int> = emptySet()
|
||||
override fun setAmSatActiveCatnums(catnums: Set<Int>) = Unit
|
||||
override fun updateRadioControlSettings(settings: RadioControlSettings) = TODO()
|
||||
override fun getSatelliteOffset(catnum: Int): String = ""
|
||||
override fun getSatelliteMode(catnum: Int): String = ""
|
||||
override fun setSatelliteMode(catnum: Int, mode: String) {}
|
||||
override fun setSatelliteOffset(catnum: Int, offset: String) = TODO()
|
||||
override fun getAmSatCallsign(): String = ""
|
||||
override fun setAmSatCallsign(callsign: String) = TODO()
|
||||
|
||||
+150
@@ -17,9 +17,15 @@
|
||||
*/
|
||||
package com.rtbishop.look4sat.core.presentation
|
||||
|
||||
import androidx.compose.animation.core.FastOutSlowInEasing
|
||||
import androidx.compose.animation.core.animate
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.MarqueeSpacing
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.basicMarquee
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
@@ -29,7 +35,9 @@ import androidx.compose.foundation.layout.RowScope
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.statusBarsPadding
|
||||
@@ -49,16 +57,28 @@ import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.rememberModalBottomSheetState
|
||||
import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.compositionLocalOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clipToBounds
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
|
||||
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.hideFromAccessibility
|
||||
@@ -82,6 +102,8 @@ import com.rtbishop.look4sat.core.domain.predict.OrbitalData
|
||||
import com.rtbishop.look4sat.core.domain.predict.OrbitalPass
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.launch
|
||||
import java.util.Locale
|
||||
import java.util.TimeZone
|
||||
|
||||
@@ -447,6 +469,134 @@ fun isVerticalLayout(): Boolean = !hasEnoughWidth()
|
||||
fun Modifier.infiniteMarquee(): Modifier =
|
||||
basicMarquee(iterations = Int.MAX_VALUE, spacing = MarqueeSpacing(16.dp))
|
||||
|
||||
/** 共享的滑动行展开状态:同一列表内同时只允许一行展开(短信式联动)。 */
|
||||
class SwipeController {
|
||||
var openKey by mutableStateOf<String?>(null)
|
||||
private set
|
||||
fun open(key: String) {
|
||||
openKey = key
|
||||
}
|
||||
fun close() {
|
||||
openKey = null
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun rememberSwipeController(): SwipeController = remember { SwipeController() }
|
||||
|
||||
/** 短信式滑动行:右划露出 [revealButton](默认红底垃圾桶+文字),点击按钮触发 [revealAction]。
|
||||
* 通过 [controller] 联动:滑开新行时其他行自动收回;点按已展开的行内容也可收回(无涟漪)。 */
|
||||
@Composable
|
||||
fun SwipeRevealRow(
|
||||
key: String,
|
||||
controller: SwipeController,
|
||||
modifier: Modifier = Modifier,
|
||||
revealWidth: Dp = 64.dp,
|
||||
revealAction: () -> Unit,
|
||||
revealButton: @Composable () -> Unit = {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colorScheme.error),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_delete),
|
||||
contentDescription = null,
|
||||
tint = Color.White,
|
||||
modifier = Modifier.size(24.dp)
|
||||
)
|
||||
}
|
||||
},
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
val density = LocalDensity.current
|
||||
val revealPx = with(density) { revealWidth.toPx() }
|
||||
var offsetX by remember { mutableFloatStateOf(0f) }
|
||||
var settleJob by remember { mutableStateOf<Job?>(null) }
|
||||
val scope = rememberCoroutineScope()
|
||||
val haptics = LocalHapticFeedback.current
|
||||
val isOpen = controller.openKey == key
|
||||
|
||||
fun settle(target: Float) {
|
||||
settleJob?.cancel()
|
||||
settleJob = scope.launch {
|
||||
animate(offsetX, target, animationSpec = tween(durationMillis = 200, easing = FastOutSlowInEasing)) { value, _ ->
|
||||
offsetX = value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 另一行展开(或全部收回)时,本行随之收回。
|
||||
LaunchedEffect(controller.openKey) {
|
||||
if (!isOpen && offsetX < 0f) settle(0f)
|
||||
}
|
||||
|
||||
Box(modifier.fillMaxWidth().clipToBounds()) {
|
||||
// 背景删除按钮:初始位于内容右侧屏幕外,与内容同步平移(跟手滑入)。
|
||||
// 父 Box clipToBounds 裁剪容器外部分——即便外层 item 带 padding、按钮
|
||||
// 左缘落在容器外的屏幕内,也因裁剪而不可见、不可点。
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.align(Alignment.CenterEnd)
|
||||
.fillMaxHeight()
|
||||
.width(revealWidth)
|
||||
.offset(x = revealWidth)
|
||||
.graphicsLayer { translationX = offsetX }
|
||||
.clickable {
|
||||
haptics.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||
controller.close()
|
||||
settle(0f)
|
||||
revealAction()
|
||||
}
|
||||
) { revealButton() }
|
||||
// 前景内容:右划平移露出按钮
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.graphicsLayer { translationX = offsetX }
|
||||
.pointerInput(revealPx) {
|
||||
detectHorizontalDragGestures(
|
||||
onDragStart = { settleJob?.cancel() },
|
||||
onHorizontalDrag = { change, dragAmount ->
|
||||
change.consume()
|
||||
offsetX = (offsetX + dragAmount).coerceIn(-revealPx, 0f)
|
||||
},
|
||||
onDragEnd = {
|
||||
if (offsetX < -revealPx / 2) {
|
||||
controller.open(key)
|
||||
haptics.performHapticFeedback(HapticFeedbackType.SegmentTick)
|
||||
settle(-revealPx)
|
||||
} else {
|
||||
controller.close()
|
||||
settle(0f)
|
||||
}
|
||||
},
|
||||
onDragCancel = {
|
||||
if (offsetX < -revealPx / 2) {
|
||||
controller.open(key)
|
||||
settle(-revealPx)
|
||||
} else {
|
||||
controller.close()
|
||||
settle(0f)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
// 已展开的行,点按内容直接收回(无涟漪,避免整行出现点击反馈)
|
||||
.clickable(
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
indication = null
|
||||
) {
|
||||
if (isOpen) {
|
||||
controller.close()
|
||||
settle(0f)
|
||||
}
|
||||
}
|
||||
) { content() }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun Modifier.layoutPadding(): Modifier {
|
||||
val spacing = LocalSpacing.current.extraSmall
|
||||
|
||||
@@ -88,7 +88,7 @@
|
||||
<!-- Map screen -->
|
||||
<string name="map_prev">Önceki</string>
|
||||
<string name="map_next">Sonraki</string>
|
||||
<string name="map_copyright" translatable="false">© OpenStreetMap contributors</string>
|
||||
<string name="map_copyright_osm" translatable="false">© OpenStreetMap contributors</string>
|
||||
<string name="map_azimuth">Azimut: %.1f°</string>
|
||||
<string name="map_elevation">Yükseklik açısı: %.1f°</string>
|
||||
<string name="map_altitude">İrtifa: %.0f km</string>
|
||||
@@ -209,7 +209,8 @@
|
||||
\n• Alexandru Csete (Gpredict)
|
||||
\n• Dr T.S. Kelso (Celestrak)
|
||||
\n• Libre Space Foundation (SatNOGS)
|
||||
\n• BG7NTA BG5JSU (CW decoding feature)</string>
|
||||
\n• BG7NTA BG5JSU (CW decoding feature)
|
||||
\n• BH2VSQ BG5JSU (TianDiTu rendering support)</string>
|
||||
<string name="prefs_outro_license">Bu uygulama herhangi bir garanti sunmaz</string>
|
||||
|
||||
</resources>
|
||||
@@ -191,6 +191,35 @@
|
||||
<string name="lotw_sync_progress_qso">本次同步 %1$d 条 QSO,预计还需 %2$d 秒</string>
|
||||
<string name="prefs_locator_text">使用梅登黑德网格设置站点位置</string>
|
||||
|
||||
<string name="btn_delete">删除</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_logbook_upload">上传</string>
|
||||
<string name="prefs_logbook_upload_title">上传到 LoTW</string>
|
||||
<string name="prefs_logbook_upload_confirm">确认上传</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_import_confirm">导入</string>
|
||||
<string name="prefs_lotw_upload_error_password">证书密码错误,请检查 TQSL 导出 .p12 时设置的密码</string>
|
||||
<string name="prefs_lotw_upload_error_expired">证书已过期或尚未生效</string>
|
||||
<string name="prefs_lotw_upload_error_invalid">不是有效的 LoTW 证书文件</string>
|
||||
<string name="prefs_lotw_upload_error_format">该 .p12 是新版 PBES2/AES-256 格式(OpenSSL 3 / 新版 TQSL 导出),Android 无法直接读取。请用旧格式重新导出(TQSL 旧加密导出或 OpenSSL -legacy 转换)后再导入</string>
|
||||
<string name="prefs_lotw_upload_error_unknown">导入失败,请重试</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_region_hint">未选择</string>
|
||||
<string name="prefs_lotw_upload_grid">网格(逗号分隔,可多格)</string>
|
||||
<string name="prefs_lotw_upload_save">保存</string>
|
||||
<string name="prefs_lotw_upload_close">关闭</string>
|
||||
|
||||
<string name="prefs_data_title">卫星数据更新</string>
|
||||
<string name="prefs_data_entries">卫星:%s</string>
|
||||
<string name="prefs_data_radios">收发器:%s</string>
|
||||
@@ -247,6 +276,15 @@
|
||||
<string name="rc_enable_switch">启用 CAT 控制</string>
|
||||
|
||||
<string name="prefs_other_title">其他设置</string>
|
||||
<string name="prefs_map_title">地图设置</string>
|
||||
<string name="prefs_map_configure">配置地图</string>
|
||||
<string name="prefs_map_source_title">地图来源</string>
|
||||
<string name="prefs_map_source_osm">离线 OSM 地图</string>
|
||||
<string name="prefs_map_source_tianditu_vector">天地图(矢量)</string>
|
||||
<string name="prefs_map_source_tianditu_image">天地图(影像)</string>
|
||||
<string name="prefs_map_tianditu_key">天地图访问密钥</string>
|
||||
<string name="prefs_map_tianditu_key_help">使用天地图前,需先在天地图开发者控制台申请服务端应用 Key。</string>
|
||||
<string name="prefs_map_tianditu_key_required">选择天地图需要填写访问密钥;保存密钥前将继续使用离线 OSM 地图。</string>
|
||||
<string name="prefs_other_switch_utc">以 UTC 时间显示</string>
|
||||
<string name="prefs_other_switch_update">启用卫星数据自动更新</string>
|
||||
<string name="prefs_other_switch_lotw_sync">自动同步 LoTW 已确认网格</string>
|
||||
@@ -282,7 +320,10 @@
|
||||
\n• Dr T.S. Kelso (Celestrak)
|
||||
\n• Libre Space Foundation (SatNOGS)
|
||||
\n• xdsopl 和 Robot36 贡献者!
|
||||
\n• BG7NTA BG5JSU(CW解码功能)</string>
|
||||
\n• BG7NTA BG5JSU(CW解码功能)
|
||||
\n• BH2VSQ BG5JSU(天地图渲染支持)
|
||||
\n• BG5JSU(LoTW上传支持)
|
||||
</string>
|
||||
<string name="prefs_outro_license">该应用程序不提供任何保修.</string>
|
||||
|
||||
<!-- 匹配页 Mutual match page -->
|
||||
|
||||
@@ -128,7 +128,8 @@
|
||||
<!-- Map screen -->
|
||||
<string name="map_prev">Prev</string>
|
||||
<string name="map_next">Next</string>
|
||||
<string name="map_copyright" translatable="false">© OpenStreetMap contributors</string>
|
||||
<string name="map_copyright_osm" translatable="false">© OpenStreetMap contributors</string>
|
||||
<string name="map_copyright_tianditu" translatable="false">© Tianditu</string>
|
||||
<string name="map_grid_mode">Grid mode</string>
|
||||
<string name="map_satellite_mode">Satellite mode</string>
|
||||
<string name="map_first_call_mode">First call</string>
|
||||
@@ -221,6 +222,35 @@
|
||||
<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="btn_delete">Delete</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_logbook_upload">Upload</string>
|
||||
<string name="prefs_logbook_upload_title">Upload to LoTW</string>
|
||||
<string name="prefs_logbook_upload_confirm">Upload</string>
|
||||
<string name="prefs_lotw_upload_title">LoTW upload certificate</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_import_confirm">Import</string>
|
||||
<string name="prefs_lotw_upload_error_password">Incorrect certificate password. Check the password you set when exporting the .p12 from TQSL.</string>
|
||||
<string name="prefs_lotw_upload_error_expired">Certificate is expired or not yet valid.</string>
|
||||
<string name="prefs_lotw_upload_error_invalid">Not a valid LoTW certificate file.</string>
|
||||
<string name="prefs_lotw_upload_error_format">This .p12 uses the new PBES2/AES-256 format (OpenSSL 3 / recent TQSL), which Android cannot read directly. Re-export it as a legacy format (TQSL "export with legacy encryption" or an OpenSSL -legacy conversion), then import again.</string>
|
||||
<string name="prefs_lotw_upload_error_unknown">Import failed. Try again.</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_region_hint">Not selected</string>
|
||||
<string name="prefs_lotw_upload_grid">Grid(s), comma-separated</string>
|
||||
<string name="prefs_lotw_upload_save">Save</string>
|
||||
<string name="prefs_lotw_upload_close">Close</string>
|
||||
|
||||
<string name="prefs_data_title">Satellite data</string>
|
||||
<string name="prefs_data_entries">Satellites: %s</string>
|
||||
<string name="prefs_data_radios">Transceivers: %s</string>
|
||||
@@ -276,6 +306,15 @@
|
||||
<string name="prefs_net_perm_error">Check your network permission</string>
|
||||
|
||||
<string name="prefs_other_title">Other settings</string>
|
||||
<string name="prefs_map_title">Map settings</string>
|
||||
<string name="prefs_map_configure">Configure map</string>
|
||||
<string name="prefs_map_source_title">Map source</string>
|
||||
<string name="prefs_map_source_osm">Offline OSM</string>
|
||||
<string name="prefs_map_source_tianditu_vector">TianDiTu vector</string>
|
||||
<string name="prefs_map_source_tianditu_image">TianDiTu imagery</string>
|
||||
<string name="prefs_map_tianditu_key">TianDiTu access key</string>
|
||||
<string name="prefs_map_tianditu_key_help">Request a server-side application key from the TianDiTu developer console before using TianDiTu maps.</string>
|
||||
<string name="prefs_map_tianditu_key_required">A TianDiTu key is required. Offline OSM remains active until it is entered.</string>
|
||||
<string name="prefs_other_switch_utc">Show pass time in UTC</string>
|
||||
<string name="prefs_other_switch_update">Enable automatic data update</string>
|
||||
<string name="prefs_other_switch_lotw_sync">Auto-sync LoTW confirmed grids</string>
|
||||
@@ -312,6 +351,8 @@
|
||||
\n* Libre Space Foundation (SatNOGS)
|
||||
\n* xdsopl and Robot36 contributors!
|
||||
\n* BG7NTA BG5JSU (CW decoding feature)
|
||||
\n* BH2VSQ BG5JSU (TianDiTu rendering support)
|
||||
\n* BG5JSU (LoTW upload support)
|
||||
</string>
|
||||
<string name="prefs_outro_license">The app comes with no warranty</string>
|
||||
|
||||
|
||||
@@ -85,6 +85,7 @@ import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.rtbishop.look4sat.core.domain.model.AwardCalculator
|
||||
import com.rtbishop.look4sat.core.domain.model.AwardProgress
|
||||
import com.rtbishop.look4sat.core.domain.model.AwardType
|
||||
import com.rtbishop.look4sat.core.domain.model.MapSource
|
||||
import com.rtbishop.look4sat.core.domain.predict.GeoPos
|
||||
import com.rtbishop.look4sat.core.domain.predict.OrbitalObject
|
||||
import com.rtbishop.look4sat.core.domain.predict.OrbitalPos
|
||||
@@ -96,31 +97,40 @@ import com.rtbishop.look4sat.core.presentation.TimerRow
|
||||
import com.rtbishop.look4sat.core.presentation.TopBar
|
||||
import com.rtbishop.look4sat.core.presentation.isVerticalLayout
|
||||
import com.rtbishop.look4sat.core.presentation.layoutPadding
|
||||
import org.osmdroid.tileprovider.MapTileProviderBasic
|
||||
import org.osmdroid.tileprovider.tilesource.OnlineTileSourceBase
|
||||
import org.osmdroid.tileprovider.tilesource.XYTileSource
|
||||
import org.osmdroid.events.DelayedMapListener
|
||||
import org.osmdroid.events.MapListener
|
||||
import org.osmdroid.events.ScrollEvent
|
||||
import org.osmdroid.events.ZoomEvent
|
||||
import org.osmdroid.util.GeoPoint
|
||||
import org.osmdroid.util.MapTileIndex
|
||||
import org.osmdroid.views.CustomZoomButtonsController
|
||||
import org.osmdroid.views.MapView
|
||||
import org.osmdroid.views.overlay.FolderOverlay
|
||||
import org.osmdroid.views.overlay.Marker
|
||||
import org.osmdroid.views.overlay.Polyline
|
||||
import org.osmdroid.views.overlay.TilesOverlay
|
||||
import java.net.URLEncoder
|
||||
|
||||
// Overlay indices
|
||||
private const val OVERLAY_GRID = 0
|
||||
private const val OVERLAY_STATION = 1
|
||||
private const val OVERLAY_TRACK = 2
|
||||
private const val OVERLAY_FOOTPRINT = 3
|
||||
private const val OVERLAY_POSITIONS = 4
|
||||
private const val OVERLAY_TERMINATOR = 5
|
||||
private const val OVERLAY_SUN = 6
|
||||
private const val OVERLAY_MOON = 7
|
||||
private const val OVERLAY_COUNT = 8
|
||||
private const val OVERLAY_TDT_LABELS = 0
|
||||
private const val OVERLAY_GRID = 1
|
||||
private const val OVERLAY_STATION = 2
|
||||
private const val OVERLAY_TRACK = 3
|
||||
private const val OVERLAY_FOOTPRINT = 4
|
||||
private const val OVERLAY_POSITIONS = 5
|
||||
private const val OVERLAY_TERMINATOR = 6
|
||||
private const val OVERLAY_SUN = 7
|
||||
private const val OVERLAY_MOON = 8
|
||||
private const val OVERLAY_COUNT = 9
|
||||
|
||||
private val minLat = MapView.getTileSystem().minLatitude
|
||||
private val maxLat = MapView.getTileSystem().maxLatitude
|
||||
private val offlineTileSource = XYTileSource("tiles", 0, 6, 256, ".webp", emptyArray<String>())
|
||||
private const val OFFLINE_MAX_ZOOM = 7.0
|
||||
private const val TIANDITU_MAX_ZOOM = 18.0
|
||||
private val trackPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||
strokeWidth = 3f
|
||||
style = Paint.Style.STROKE
|
||||
@@ -209,6 +219,8 @@ private fun MapScreen(
|
||||
val rotateMod = Modifier.rotate(180f)
|
||||
val timeString = uiState.mapData?.aosTime ?: "00:00:00"
|
||||
val isTimeAos = uiState.mapData?.isTimeAos ?: true
|
||||
val usesTianditu = tiandituLayers(uiState.mapSource) != null && uiState.tiandituKey.isNotBlank()
|
||||
val copyrightResId = if (usesTianditu) R.string.map_copyright_tianditu else R.string.map_copyright_osm
|
||||
|
||||
// Tapped worked grid -> centered QSO dialog. Local UI state: the map is the
|
||||
// only consumer and it resets when leaving the page.
|
||||
@@ -216,19 +228,6 @@ private fun MapScreen(
|
||||
// Selected award filter. Lives in an Activity-scoped ViewModel so it
|
||||
// survives page switches; defaults to VUCC only once per process (cold start).
|
||||
var selectedAward by mapFilterViewModel.selectedAward
|
||||
// Six-award progress derived from the confirmed QSO store; recomputed when
|
||||
// the store changes (LoTW/Wavelog sync).
|
||||
// Per operated-grid VUCC breakdown: myGrid -> set of worked grids worked from it.
|
||||
val vuccByMyGrid: Map<String, Set<String>> = remember(uiState.workedGridQsos) {
|
||||
val m = mutableMapOf<String, MutableSet<String>>()
|
||||
for ((grid, qsos) in uiState.workedGridQsos) {
|
||||
for (q in qsos) {
|
||||
val mg = q.myGrid ?: continue
|
||||
m.getOrPut(mg) { mutableSetOf() }.add(grid)
|
||||
}
|
||||
}
|
||||
m
|
||||
}
|
||||
// First callsign worked in each grid (earliest QSO by time), used by the
|
||||
// "首通呼号" label mode — derived from the same QSO store as the worked fills.
|
||||
val firstCallsByGrid: Map<String, String> = remember(uiState.workedGridQsos) {
|
||||
@@ -236,26 +235,47 @@ private fun MapScreen(
|
||||
qsos.minByOrNull { it.epochMs }?.let { grid to it.call }
|
||||
}.toMap()
|
||||
}
|
||||
// Selected operated grid for VUCC counting; defaults to the grid with the
|
||||
// most worked grids. Null when the store carries no per-QSO myGrid data
|
||||
// (requires a LoTW resync) — then VUCC falls back to the global count.
|
||||
var selectedMyGrid by remember(vuccByMyGrid) {
|
||||
mutableStateOf(vuccByMyGrid.maxByOrNull { it.value.size }?.key)
|
||||
// 台址名单: QSOs grouped by the 台址's GRID SET. A station location (台址)
|
||||
// can span several grids (MY_GRIDSQUARE + MY_VUCC_GRIDS), and QSOs whose
|
||||
// location covers the same grid set belong to the same 台址; groups with an
|
||||
// identical grid set merge (they are VUCC-equivalent — the MY_* snapshot
|
||||
// can differ between records of the same physical 台址 when LoTW omits
|
||||
// optional fields). Data synced before multi-grid support falls back to
|
||||
// one group per grid, preserving the old selector exactly.
|
||||
val stationGroups: List<StationGroup> = remember(uiState.workedGridQsos) {
|
||||
val byGridSet = LinkedHashMap<String, Pair<Set<String>, MutableSet<String>>>()
|
||||
for ((grid, qsos) in uiState.workedGridQsos) {
|
||||
for (q in qsos) {
|
||||
val gs = q.myGrids.ifEmpty { q.myGrid?.let { setOf(it) }.orEmpty() }
|
||||
if (gs.isEmpty()) continue
|
||||
val key = gs.sorted().joinToString(",")
|
||||
byGridSet.getOrPut(key) { gs to mutableSetOf() }.second.add(grid)
|
||||
}
|
||||
}
|
||||
byGridSet.map { (key, v) -> StationGroup(id = key, grids = v.first, worked = v.second) }
|
||||
.sortedByDescending { it.workedCount }
|
||||
}
|
||||
val awardProgress: List<AwardProgress> = remember(uiState.workedGridQsos, vuccByMyGrid, selectedMyGrid) {
|
||||
val vuccGrids = selectedMyGrid?.let { vuccByMyGrid[it].orEmpty() }
|
||||
// Selected 台址 for VUCC counting; defaults to the group with the most
|
||||
// worked grids. Null = "All" (every 台址 combined). Empty when the store
|
||||
// carries no per-QSO myGrid data (requires a LoTW resync) — the selector
|
||||
// is hidden then and VUCC falls back to the global count.
|
||||
var selectedStationId by remember(stationGroups) {
|
||||
mutableStateOf(stationGroups.maxByOrNull { it.workedCount }?.id)
|
||||
}
|
||||
val awardProgress: List<AwardProgress> = remember(uiState.workedGridQsos, stationGroups, selectedStationId) {
|
||||
val base = AwardCalculator.calculate(uiState.workedGridQsos)
|
||||
if (vuccGrids == null) base
|
||||
val worked = stationGroups.firstOrNull { it.id == selectedStationId }?.worked
|
||||
if (worked == null) base
|
||||
else base.map { p ->
|
||||
if (p.type == AwardType.VUCC) p.copy(workedKeys = vuccGrids, count = vuccGrids.size) else p
|
||||
if (p.type == AwardType.VUCC) p.copy(workedKeys = worked, count = worked.size) else p
|
||||
}
|
||||
}
|
||||
// Worked grids drawn on the map: filtered by the selected operated grid
|
||||
// (null = all operated grids). Drives both the green fills and the tap
|
||||
// listener below, so switching the selector changes which cells are green.
|
||||
val workedGrids = remember(uiState.workedGrids, vuccByMyGrid, selectedMyGrid) {
|
||||
if (selectedMyGrid == null) uiState.workedGrids
|
||||
else vuccByMyGrid[selectedMyGrid] ?: emptySet()
|
||||
// Worked grids drawn on the map: filtered by the selected 台址 (null = all).
|
||||
// Drives both the green fills and the tap listener below, so switching the
|
||||
// selector changes which cells are green.
|
||||
val workedGrids = remember(uiState.workedGrids, stationGroups, selectedStationId) {
|
||||
if (selectedStationId == null) uiState.workedGrids
|
||||
else stationGroups.firstOrNull { it.id == selectedStationId }?.worked ?: emptySet()
|
||||
}
|
||||
val isGridMode = uiState.isGridMode
|
||||
// True when this composition restored a saved viewport. Only grid mode
|
||||
@@ -300,6 +320,9 @@ private fun MapScreen(
|
||||
val firstPos = uiState.track?.firstOrNull()?.firstOrNull() ?: return@LaunchedEffect
|
||||
mapView.controller.animateTo(GeoPoint(firstPos.latitude, firstPos.longitude))
|
||||
}
|
||||
LaunchedEffect(uiState.mapSource, uiState.tiandituKey) {
|
||||
configureTileSources(mapView, uiState.mapSource, uiState.tiandituKey)
|
||||
}
|
||||
Column(modifier = Modifier.layoutPadding(), verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
val isVertical = isVerticalLayout()
|
||||
if (isGridMode) {
|
||||
@@ -382,17 +405,18 @@ private fun MapScreen(
|
||||
}
|
||||
uiState.mapData?.let { mapData ->
|
||||
if (!uiState.isGridMode) {
|
||||
if (isVertical) MapDataCard(mapData) else MapDataCards(mapData)
|
||||
if (isVertical) MapDataCard(mapData, copyrightResId)
|
||||
else MapDataCards(mapData, copyrightResId)
|
||||
}
|
||||
}
|
||||
// Top-left: operated-grid selector for VUCC counting (grid mode only).
|
||||
// Top-left: 台址 selector for VUCC counting (grid mode only).
|
||||
// Shown whenever per-grid QSO data exists; "All" (null) is available.
|
||||
if (uiState.isGridMode && selectedAward == AwardType.VUCC && vuccByMyGrid.isNotEmpty()) {
|
||||
if (uiState.isGridMode && selectedAward == AwardType.VUCC && stationGroups.isNotEmpty()) {
|
||||
VuccGridSelector(
|
||||
options = vuccByMyGrid,
|
||||
stations = stationGroups,
|
||||
allCount = uiState.workedGrids.size,
|
||||
selected = selectedMyGrid,
|
||||
onSelect = { selectedMyGrid = it },
|
||||
selected = selectedStationId,
|
||||
onSelect = { selectedStationId = it },
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopStart)
|
||||
.padding(8.dp)
|
||||
@@ -682,7 +706,7 @@ private fun WorkedGridCallRow(
|
||||
text = call,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace,
|
||||
color = MaterialTheme.colorScheme.tertiary,
|
||||
color = ComposeColor(0xFFFFE082),
|
||||
maxLines = 1,
|
||||
overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f)
|
||||
@@ -761,11 +785,11 @@ private fun formatTime(epochMs: Long, isUtc: Boolean): String {
|
||||
|
||||
// region Map data composables
|
||||
@Composable
|
||||
private fun MapDataCard(data: MapData) {
|
||||
private fun MapDataCard(data: MapData, copyrightResId: Int) {
|
||||
val textColor = MaterialTheme.colorScheme.primary
|
||||
val cardColors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerLowest)
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text(text = stringResource(R.string.map_copyright), fontSize = 14.sp)
|
||||
Text(text = stringResource(copyrightResId), fontSize = 14.sp)
|
||||
Card(colors = cardColors) {
|
||||
Column(modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)) {
|
||||
MapDataRow(
|
||||
@@ -803,7 +827,7 @@ private fun MapDataRow(
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MapDataCards(data: MapData) {
|
||||
private fun MapDataCards(data: MapData, copyrightResId: Int) {
|
||||
val cardColors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerLowest)
|
||||
val paddingMod = Modifier
|
||||
.padding(horizontal = 8.dp, vertical = 4.dp)
|
||||
@@ -830,7 +854,7 @@ private fun MapDataCards(data: MapData) {
|
||||
}
|
||||
}
|
||||
Text(
|
||||
text = stringResource(R.string.map_copyright),
|
||||
text = stringResource(copyrightResId),
|
||||
fontSize = 14.sp,
|
||||
modifier = Modifier.align(Alignment.BottomCenter)
|
||||
)
|
||||
@@ -881,22 +905,42 @@ private fun AwardChipsRow(
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact pill toggle for switching between satellite view and grid mode,
|
||||
* One 台址 shown by the operated-grid selector. A 台址 is a station location
|
||||
* and can span SEVERAL grids (MY_GRIDSQUARE + MY_VUCC_GRIDS); groups are keyed
|
||||
* by the 台址's full grid set, so a multi-grid 台址 is one entry listing all
|
||||
* its grids. Data synced before multi-grid support falls back to one group
|
||||
* per grid.
|
||||
*/
|
||||
private data class StationGroup(
|
||||
/** Grid-set key (sorted 4-char grids joined by ","). */
|
||||
val id: String,
|
||||
/** Full grid set of this 台址 (1..n 4-char grids). */
|
||||
val grids: Set<String>,
|
||||
/** Distinct worked grids worked under this 台址 (any of its grids). */
|
||||
val worked: Set<String>
|
||||
) {
|
||||
val workedCount: Int get() = worked.size
|
||||
/** Selector label: the grids themselves — "OL62" for a single-grid 台址,
|
||||
* "OM60,PM01" for a multi-grid one (matches how 台址 are described). */
|
||||
val label: String get() = grids.sorted().joinToString(",")
|
||||
}
|
||||
|
||||
/** Compact pill toggle for switching between satellite view and grid mode,
|
||||
* floated over the map's top-right corner. The label reflects the active
|
||||
* mode ("Grid mode" when ON, "Satellite mode" when OFF). Semi-transparent
|
||||
* background keeps the map readable underneath.
|
||||
*/
|
||||
* background keeps the map readable underneath. */
|
||||
@Composable
|
||||
private fun VuccGridSelector(
|
||||
options: Map<String, Set<String>>,
|
||||
stations: List<StationGroup>,
|
||||
allCount: Int,
|
||||
selected: String?,
|
||||
onSelect: (String?) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
// Most-worked grid first; selection is made on tap. "All" sits last.
|
||||
val sorted = remember(options) { options.entries.sortedByDescending { it.value.size } }
|
||||
// Most-worked 台址 first; selection is made on tap. "All" sits last.
|
||||
val sorted = remember(stations) { stations.sortedByDescending { it.workedCount } }
|
||||
val selectedLabel = stations.firstOrNull { it.id == selected }?.label
|
||||
Surface(
|
||||
color = ComposeColor.Black.copy(alpha = 0.45f),
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
@@ -914,8 +958,8 @@ private fun VuccGridSelector(
|
||||
.padding(start = 10.dp, end = 6.dp, top = 4.dp, bottom = 4.dp)
|
||||
) {
|
||||
Text(
|
||||
text = if (selected == null) "All ($allCount)"
|
||||
else "$selected (${options[selected]?.size ?: 0})",
|
||||
text = if (selectedLabel == null) "All ($allCount)"
|
||||
else "$selectedLabel (${stations.firstOrNull { it.id == selected }?.workedCount ?: 0})",
|
||||
color = ComposeColor.White,
|
||||
fontSize = 12.sp,
|
||||
fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace
|
||||
@@ -929,20 +973,20 @@ private fun VuccGridSelector(
|
||||
}
|
||||
if (expanded) {
|
||||
androidx.compose.material3.HorizontalDivider(color = ComposeColor.White.copy(alpha = 0.2f))
|
||||
sorted.forEach { (grid, grids) ->
|
||||
sorted.forEach { station ->
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable {
|
||||
onSelect(grid)
|
||||
onSelect(station.id)
|
||||
expanded = false
|
||||
}
|
||||
.padding(horizontal = 10.dp, vertical = 3.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "$grid (${grids.size})",
|
||||
color = if (grid == selected) MaterialTheme.colorScheme.primary else ComposeColor.White,
|
||||
text = "${station.label} (${station.workedCount})",
|
||||
color = if (station.id == selected) MaterialTheme.colorScheme.primary else ComposeColor.White,
|
||||
fontSize = 12.sp,
|
||||
fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace
|
||||
)
|
||||
@@ -1045,6 +1089,100 @@ private fun FirstCallToggle(
|
||||
|
||||
// endregion
|
||||
|
||||
// region Tile sources
|
||||
private var configuredTileMapView: MapView? = null
|
||||
private var configuredTileSource: String? = null
|
||||
|
||||
private fun configureTileSources(mapView: MapView, mapSource: String, tiandituKey: String) {
|
||||
val key = tiandituKey.trim()
|
||||
val layers = tiandituLayers(mapSource)
|
||||
val sourceKey = if (layers == null || key.isBlank()) MapSource.OSM else "${layers.base}:$key"
|
||||
if (configuredTileMapView === mapView && configuredTileSource == sourceKey) return
|
||||
|
||||
configuredTileMapView = mapView
|
||||
configuredTileSource = sourceKey
|
||||
(mapView.overlays.getOrNull(OVERLAY_TDT_LABELS) as? TilesOverlay)?.onDetach(mapView)
|
||||
|
||||
if (layers == null || key.isBlank()) {
|
||||
mapView.setUseDataConnection(false)
|
||||
mapView.setTileSource(offlineTileSource)
|
||||
mapView.maxZoomLevel = OFFLINE_MAX_ZOOM
|
||||
mapView.overlayManager.tilesOverlay.applyTileOverlayDefaults(createColorFilter())
|
||||
mapView.overlays[OVERLAY_TDT_LABELS] = FolderOverlay()
|
||||
requestTileRefresh(mapView)
|
||||
return
|
||||
}
|
||||
|
||||
mapView.setUseDataConnection(true)
|
||||
mapView.setTileSource(TiandituTileSource(layer = layers.base, key = key))
|
||||
mapView.maxZoomLevel = TIANDITU_MAX_ZOOM
|
||||
mapView.overlayManager.tilesOverlay.applyTileOverlayDefaults(null)
|
||||
mapView.overlays[OVERLAY_TDT_LABELS] = TilesOverlay(
|
||||
MapTileProviderBasic(mapView.context, TiandituTileSource(layer = layers.label, key = key)),
|
||||
mapView.context
|
||||
).apply {
|
||||
applyTileOverlayDefaults(null)
|
||||
setUseDataConnection(true)
|
||||
}
|
||||
requestTileRefresh(mapView)
|
||||
}
|
||||
|
||||
private data class TiandituLayers(val base: String, val label: String)
|
||||
|
||||
private fun tiandituLayers(mapSource: String): TiandituLayers? = when (MapSource.normalize(mapSource)) {
|
||||
MapSource.TIANDITU_VECTOR -> TiandituLayers(base = "vec", label = "cva")
|
||||
MapSource.TIANDITU_IMAGE -> TiandituLayers(base = "img", label = "cia")
|
||||
else -> null
|
||||
}
|
||||
|
||||
private fun TilesOverlay.applyTileOverlayDefaults(colorFilter: ColorMatrixColorFilter?) {
|
||||
setColorFilter(colorFilter)
|
||||
setLoadingBackgroundColor(Color.TRANSPARENT)
|
||||
setLoadingLineColor(Color.TRANSPARENT)
|
||||
}
|
||||
|
||||
private fun requestTileRefresh(mapView: MapView) {
|
||||
mapView.post {
|
||||
mapView.requestLayout()
|
||||
mapView.invalidate()
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildTiandituWmtsUrl(
|
||||
baseUrl: String,
|
||||
layer: String,
|
||||
zoom: Int,
|
||||
row: Int,
|
||||
col: Int,
|
||||
encodedKey: String
|
||||
): String = "$baseUrl?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0&LAYER=$layer" +
|
||||
"&STYLE=default&TILEMATRIXSET=w&FORMAT=tiles&TILEMATRIX=$zoom&TILEROW=$row&TILECOL=$col&tk=$encodedKey"
|
||||
|
||||
private class TiandituTileSource(
|
||||
private val layer: String,
|
||||
key: String
|
||||
) : OnlineTileSourceBase(
|
||||
"tianditu-wmts-https-$layer",
|
||||
1,
|
||||
TIANDITU_MAX_ZOOM.toInt(),
|
||||
256,
|
||||
".png",
|
||||
Array(8) { index -> "https://t$index.tianditu.gov.cn/${layer}_w/wmts" },
|
||||
"Tianditu"
|
||||
) {
|
||||
private val encodedKey = URLEncoder.encode(key, Charsets.UTF_8.name())
|
||||
|
||||
override fun getTileURLString(pMapTileIndex: Long): String = buildTiandituWmtsUrl(
|
||||
baseUrl = getBaseUrl(),
|
||||
layer = layer,
|
||||
zoom = MapTileIndex.getZoom(pMapTileIndex),
|
||||
row = MapTileIndex.getY(pMapTileIndex),
|
||||
col = MapTileIndex.getX(pMapTileIndex),
|
||||
encodedKey = encodedKey
|
||||
)
|
||||
}
|
||||
// endregion
|
||||
|
||||
// region Map overlay helpers
|
||||
|
||||
/**
|
||||
@@ -1463,7 +1601,6 @@ private fun rememberMapViewWithLifecycle(
|
||||
mapFilterViewModel: MapFilterViewModel,
|
||||
restoreViewport: Boolean
|
||||
): MapView {
|
||||
val tileSource = XYTileSource("tiles", 0, 6, 256, ".webp", emptyArray<String>())
|
||||
val context = LocalContext.current
|
||||
val isVertical = isVerticalLayout()
|
||||
val mapView = remember {
|
||||
@@ -1475,9 +1612,9 @@ private fun rememberMapViewWithLifecycle(
|
||||
// draws a full-height artifact line whenever its X range crosses
|
||||
// the screen at low zoom. The map must never repeat vertically.
|
||||
setVerticalMapRepetitionEnabled(false)
|
||||
setTileSource(tileSource)
|
||||
setTileSource(offlineTileSource)
|
||||
minZoomLevel = getMinZoom(resources.displayMetrics.heightPixels, isVertical)
|
||||
maxZoomLevel = 7.0
|
||||
maxZoomLevel = OFFLINE_MAX_ZOOM
|
||||
// Restore the viewport the user left on the previous visit, but only
|
||||
// in grid mode: satellite mode intentionally keeps its original
|
||||
// behavior (default center, then follows the selected satellite).
|
||||
@@ -1522,6 +1659,8 @@ private fun clearMapCaches() {
|
||||
lastPositions = null
|
||||
lastAction = null
|
||||
lastMapView = null
|
||||
configuredTileMapView = null
|
||||
configuredTileSource = null
|
||||
}
|
||||
|
||||
@Composable
|
||||
|
||||
@@ -21,11 +21,14 @@ import com.rtbishop.look4sat.core.domain.predict.GeoPos
|
||||
import com.rtbishop.look4sat.core.domain.predict.OrbitalObject
|
||||
import com.rtbishop.look4sat.core.domain.predict.OrbitalPass
|
||||
import com.rtbishop.look4sat.core.domain.predict.OrbitalPos
|
||||
import com.rtbishop.look4sat.core.domain.model.MapSource
|
||||
|
||||
data class MapState(
|
||||
val mapData: MapData? = null,
|
||||
val isLightUi: Boolean = false,
|
||||
val isUtc: Boolean = false,
|
||||
val mapSource: String = MapSource.OSM,
|
||||
val tiandituKey: String = "",
|
||||
val isGridMode: Boolean = false,
|
||||
/** Grid mode: label worked (green) cells with the first callsign worked in
|
||||
* that grid instead of the grid code; non-worked cells get no label. */
|
||||
|
||||
@@ -63,6 +63,8 @@ class MapViewModel(
|
||||
MapState(
|
||||
isLightUi = settingsRepo.otherSettings.value.stateOfLightTheme,
|
||||
isUtc = settingsRepo.otherSettings.value.stateOfUtc,
|
||||
mapSource = settingsRepo.otherSettings.value.mapSource,
|
||||
tiandituKey = settingsRepo.otherSettings.value.tiandituKey,
|
||||
orbitalPass = defaultPass
|
||||
)
|
||||
)
|
||||
@@ -82,6 +84,8 @@ class MapViewModel(
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isUtc = settings.stateOfUtc,
|
||||
mapSource = settings.mapSource,
|
||||
tiandituKey = settings.tiandituKey,
|
||||
isGridMode = settings.stateOfMapGrid,
|
||||
showFirstCallLabels = settings.stateOfMapFirstCall
|
||||
)
|
||||
|
||||
+9
-20
@@ -26,6 +26,7 @@ import com.rtbishop.look4sat.core.domain.predict.OrbitalPass
|
||||
import com.rtbishop.look4sat.core.domain.repository.IMainContainer
|
||||
import com.rtbishop.look4sat.core.domain.repository.ISatelliteRepo
|
||||
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
|
||||
import com.rtbishop.look4sat.core.domain.source.Sources
|
||||
import com.rtbishop.look4sat.core.domain.utility.positionToQth
|
||||
import com.rtbishop.look4sat.core.domain.utility.qthToPosition
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
@@ -303,27 +304,15 @@ class MutualViewModel(
|
||||
|
||||
viewModelScope.launch {
|
||||
val now = System.currentTimeMillis()
|
||||
// 转发器类型过滤(协程上下文): AMSAT Live 清单优先, 为空时回退数据库.
|
||||
val amSatFm = settingsRepo.getAmSatFmCatnums()
|
||||
val amSatLinear = settingsRepo.getAmSatLinearCatnums()
|
||||
val filteredSatellites = if (amSatFm.isEmpty() && amSatLinear.isEmpty()) {
|
||||
// 回退到数据库模式过滤: 只用带上行频率的转发器记录, 排除纯下行
|
||||
// 信标/遥测(如 CW 信标、FM 语音合成信标), 避免 ISS/IO-86/FO-29 等
|
||||
// 因共享模式标签(CW/FM)被误判进错误类别.
|
||||
val filterModes = buildList {
|
||||
if (state.filterFM) add("FM")
|
||||
if (state.filterLinear) addAll(listOf("USB", "LSB", "CW", "SSB"))
|
||||
}
|
||||
val idsWithModes = satelliteRepo.getSatelliteIdsWithModesAndUplink(filterModes)
|
||||
if (idsWithModes.isEmpty()) satellites
|
||||
else satellites.filter { it.data.catnum in idsWithModes }
|
||||
} else {
|
||||
val allowed = buildSet {
|
||||
if (state.filterFM) addAll(amSatFm)
|
||||
if (state.filterLinear) addAll(amSatLinear)
|
||||
}
|
||||
satellites.filter { it.data.catnum in allowed }
|
||||
// 转发器类型筛选: 使用硬编码 AMSAT Live 清单 (见 Sources):
|
||||
// FM = {SO-50, ISS ZARYA, AO-123}, Linear = {RS-44, FO-29,
|
||||
// AO-7, AO-73, JO-97}. 不再依赖 AMSAT 网页同步: 任何网络下都
|
||||
// 稳定, 没有旧脏列表/超时/同步失败清空等问题.
|
||||
val allowed = buildSet {
|
||||
if (state.filterFM) addAll(Sources.amSatFmCatnums)
|
||||
if (state.filterLinear) addAll(Sources.amSatLinearCatnums)
|
||||
}
|
||||
val filteredSatellites = satellites.filter { it.data.catnum in allowed }
|
||||
// 历史回看: 查询窗口起点 = now - hoursBefore (跟随主页面 Passes 设置),
|
||||
// 终点 = now + hoursAhead 不变.
|
||||
val time = now - state.hoursBefore * 60L * 60L * 1000L
|
||||
|
||||
@@ -54,9 +54,7 @@ class FakeSatelliteRepo(
|
||||
* state; everything else the ViewModel never touches fails loudly.
|
||||
*/
|
||||
class FakeSettingsRepo(
|
||||
initialPosition: GeoPos = GeoPos(23.13, 113.26),
|
||||
val amSatFm: Set<Int> = emptySet(),
|
||||
val amSatLinear: Set<Int> = emptySet()
|
||||
initialPosition: GeoPos = GeoPos(23.13, 113.26)
|
||||
) : ISettingsRepo {
|
||||
|
||||
override val stationPosition = MutableStateFlow(initialPosition)
|
||||
@@ -110,13 +108,6 @@ class FakeSettingsRepo(
|
||||
|
||||
override fun updateDataSourcesStatus(status: Map<String, Int>) = TODO()
|
||||
|
||||
override fun getAmSatFmCatnums(): Set<Int> = amSatFm
|
||||
override fun getAmSatLinearCatnums(): Set<Int> = amSatLinear
|
||||
override val amSatListsVersion: StateFlow<Int> = MutableStateFlow(0)
|
||||
override fun setAmSatCatnums(fmCatnums: Set<Int>, linearCatnums: Set<Int>) = TODO()
|
||||
override fun getAmSatActiveCatnums(): Set<Int> = emptySet()
|
||||
override fun setAmSatActiveCatnums(catnums: Set<Int>) = TODO()
|
||||
|
||||
override fun updateRadioControlSettings(settings: RadioControlSettings) = TODO()
|
||||
override fun getSatelliteOffset(catnum: Int): String = ""
|
||||
override fun setSatelliteOffset(catnum: Int, offset: String) = TODO()
|
||||
|
||||
+13
-16
@@ -31,12 +31,10 @@ class MutualViewModelTest {
|
||||
private fun TestScope.createVm(
|
||||
satellites: List<OrbitalObject> = emptyList(),
|
||||
passes: List<OrbitalPass> = emptyList(),
|
||||
position: GeoPos = TestOrbits.GUANGZHOU,
|
||||
amSatFm: Set<Int> = emptySet(),
|
||||
amSatLinear: Set<Int> = emptySet()
|
||||
position: GeoPos = TestOrbits.GUANGZHOU
|
||||
): MutualViewModel = MutualViewModel(
|
||||
satelliteRepo = FakeSatelliteRepo(satellites, passes),
|
||||
settingsRepo = FakeSettingsRepo(position, amSatFm, amSatLinear),
|
||||
settingsRepo = FakeSettingsRepo(position),
|
||||
computeDispatcher = StandardTestDispatcher(mainDispatcherRule.dispatcher.scheduler)
|
||||
)
|
||||
|
||||
@@ -343,15 +341,15 @@ class MutualViewModelTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `AMSAT filter restricts results to listed catnums`() = runTest(mainDispatcherRule.dispatcher.scheduler) {
|
||||
fun `hardcoded AMSAT filter restricts results to listed catnums`() = runTest(mainDispatcherRule.dispatcher.scheduler) {
|
||||
val windows = TestOrbits.findPassWindows(hoursAhead = 24) +
|
||||
TestOrbits.findPassWindows(sat = TestOrbits.ISS_VARIANT, hoursAhead = 24)
|
||||
assertTrue("fixture must produce windows", windows.isNotEmpty())
|
||||
// Only ISS (25544) is on the AMSAT live FM list; ISS_VARIANT (25545) is not.
|
||||
// ISS (25544) is on the hardcoded FM list (Sources.amSatFmCatnums);
|
||||
// ISS_VARIANT (25545) is not.
|
||||
val vm = createVm(
|
||||
satellites = listOf(TestOrbits.ISS, TestOrbits.ISS_VARIANT),
|
||||
passes = windows,
|
||||
amSatFm = setOf(25544)
|
||||
passes = windows
|
||||
)
|
||||
vm.onStationBGrid("OL62")
|
||||
vm.onHoursAhead(24)
|
||||
@@ -361,19 +359,18 @@ class MutualViewModelTest {
|
||||
val state = vm.uiState.value
|
||||
assertNull(state.errorMessage)
|
||||
assertTrue(state.mutualPasses.isNotEmpty())
|
||||
assertTrue("only AMSAT-listed satellites should appear", state.mutualPasses.all { it.catNum == 25544 })
|
||||
assertTrue("only hardcoded FM-listed satellites should appear", state.mutualPasses.all { it.catNum == 25544 })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `AMSAT filter off for one type excludes its satellites`() = runTest(mainDispatcherRule.dispatcher.scheduler) {
|
||||
fun `turning FM off excludes FM-listed satellites under Linear filter`() = runTest(mainDispatcherRule.dispatcher.scheduler) {
|
||||
val windows = TestOrbits.findPassWindows(hoursAhead = 24) +
|
||||
TestOrbits.findPassWindows(sat = TestOrbits.ISS_VARIANT, hoursAhead = 24)
|
||||
// FM filter off, Linear on: neither sat is linear-listed -> nothing matches.
|
||||
// ISS (25544) is on the hardcoded FM list but NOT on the hardcoded
|
||||
// Linear list; with FM off and Linear on, no satellite passes.
|
||||
val vm = createVm(
|
||||
satellites = listOf(TestOrbits.ISS, TestOrbits.ISS_VARIANT),
|
||||
passes = windows,
|
||||
amSatFm = setOf(25544),
|
||||
amSatLinear = setOf(25545)
|
||||
passes = windows
|
||||
)
|
||||
vm.onStationBGrid("OL62")
|
||||
vm.onHoursAhead(24)
|
||||
@@ -382,7 +379,7 @@ class MutualViewModelTest {
|
||||
queryAndSettle(vm)
|
||||
|
||||
val state = vm.uiState.value
|
||||
assertNull(state.errorMessage)
|
||||
assertTrue("only linear-listed satellites should appear", state.mutualPasses.all { it.catNum == 25545 })
|
||||
// 空结果会设置 "No mutual passes found" 提示, 这是正常行为而非筛选错误.
|
||||
assertTrue(state.mutualPasses.isEmpty())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
/*
|
||||
* 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.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
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.presentation.SwipeController
|
||||
import com.rtbishop.look4sat.core.presentation.SwipeRevealRow
|
||||
import com.rtbishop.look4sat.core.presentation.rememberSwipeController
|
||||
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 swipeController = rememberSwipeController()
|
||||
|
||||
val selectedRadio = remember(uiState.transceivers.transmitters, uiState.transceivers.selectedUuid) {
|
||||
uiState.transceivers.transmitters.firstOrNull { it.uuid == uiState.transceivers.selectedUuid }
|
||||
}
|
||||
// Linear if the satellite carries ANY named linear transponder — same test
|
||||
// that gates the Calculator tab (RadarScreen), so the mode selector and the
|
||||
// calculator always appear together regardless of which entry is selected.
|
||||
val isLinear = uiState.transceivers.transmitters.any(DopplerFrequencyCalculator::isNamedLinearTransponder)
|
||||
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 = uiState.calculatorTxHz ?: remember(selectedRadio) {
|
||||
selectedRadio?.uplinkLow?.let { low ->
|
||||
selectedRadio.uplinkHigh?.let { high -> (low + high) / 2 } ?: low
|
||||
}
|
||||
}
|
||||
val rxHz = uiState.calculatorRxHz ?: 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, swipeController, 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, swipeController: SwipeController, 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))
|
||||
}
|
||||
// 右划露出删除按钮(短信式);整行点击不再删除。
|
||||
SwipeRevealRow(
|
||||
key = record.id.toString(),
|
||||
controller = swipeController,
|
||||
revealAction = onDelete,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 4.dp, vertical = 2.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(text = "$time ", fontSize = 14.sp, maxLines = 1)
|
||||
Text(
|
||||
text = record.theirCallsign,
|
||||
fontSize = 14.sp,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
color = Color(0xFFFFE082),
|
||||
maxLines = 1
|
||||
)
|
||||
Text(text = " ${record.displayMode}", fontSize = 14.sp, maxLines = 1)
|
||||
}
|
||||
when {
|
||||
record.lotwConfirmed -> Text("QSL", fontSize = 12.sp, color = Color(0xFFFFE082), fontFamily = FontFamily.Monospace)
|
||||
record.lotwUploaded -> Text("UP", fontSize = 12.sp, color = Color(0xFFFFE082), fontFamily = FontFamily.Monospace)
|
||||
else -> Text("", fontSize = 12.sp)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@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,208 @@
|
||||
/*
|
||||
* 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
|
||||
|
||||
// Record ids submitted in the most recent prepare → upload cycle, so a
|
||||
// successful POST can mark them "uploaded" (distinct from "confirmed").
|
||||
private var lastUploadedIds: List<Long> = emptyList()
|
||||
|
||||
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)
|
||||
lastUploadedIds = pending.map { it.id }
|
||||
_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)
|
||||
if (result is LoTWUploadResult.Accepted && lastUploadedIds.isNotEmpty()) {
|
||||
// Mark the submitted QSOs as uploaded (distinct from confirmed).
|
||||
qsoRepository.markUploaded(lastUploadedIds)
|
||||
lastUploadedIds = emptyList()
|
||||
}
|
||||
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) {
|
||||
Transceivers("Transceivers"),
|
||||
Calculator("Calculator"),
|
||||
Log("Log"),
|
||||
Sstv("SSTV")
|
||||
}
|
||||
|
||||
@@ -91,6 +92,7 @@ fun RadarDestination(navigateUp: () -> Unit) {
|
||||
val context = LocalContext.current
|
||||
val container = (context.applicationContext as IContainerProvider).getMainContainer()
|
||||
val viewModel: RadarViewModel = viewModel(factory = RadarViewModel.factory(container))
|
||||
val logViewModel: LogViewModel = viewModel(factory = LogViewModel.factory(container))
|
||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
val mutualData by container.mutualPassData.collectAsStateWithLifecycle()
|
||||
val navigateUpAndClearMutual = {
|
||||
@@ -127,7 +129,7 @@ fun RadarDestination(navigateUp: () -> Unit) {
|
||||
viewModel.onAction(RadarAction.SstvPermissionResult(granted))
|
||||
viewModel.onAction(RadarAction.CwPermissionResult(granted))
|
||||
}
|
||||
RadarScreen(uiState, viewModel::onAction, navigateUpAndClearMutual, mutualData, requestMicPermission = {
|
||||
RadarScreen(uiState, viewModel::onAction, navigateUpAndClearMutual, mutualData, logViewModel, requestMicPermission = {
|
||||
permissionLauncher.launch(Manifest.permission.RECORD_AUDIO)
|
||||
})
|
||||
}
|
||||
@@ -138,6 +140,7 @@ private fun RadarScreen(
|
||||
onAction: (RadarAction) -> Unit,
|
||||
navigateUp: () -> Unit,
|
||||
mutualData: MutualPassData,
|
||||
logViewModel: LogViewModel,
|
||||
requestMicPermission: () -> Unit
|
||||
) {
|
||||
val upcomingPass = uiState.currentPass ?: getDefaultPass()
|
||||
@@ -194,11 +197,11 @@ private fun RadarScreen(
|
||||
}
|
||||
if (isVertical) {
|
||||
RadarCard(uiState, trackB, trackBPosition, Modifier.weight(1f))
|
||||
PagerCard(uiState, onAction, requestMicPermission, Modifier.weight(1f))
|
||||
PagerCard(uiState, onAction, logViewModel, requestMicPermission, Modifier.weight(1f))
|
||||
} else {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
RadarCard(uiState, trackB, trackBPosition, Modifier.weight(1f))
|
||||
PagerCard(uiState, onAction, requestMicPermission, Modifier.weight(1f))
|
||||
PagerCard(uiState, onAction, logViewModel, requestMicPermission, Modifier.weight(1f))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -208,6 +211,7 @@ private fun RadarScreen(
|
||||
private fun PagerCard(
|
||||
uiState: RadarState,
|
||||
onAction: (RadarAction) -> Unit,
|
||||
logViewModel: LogViewModel,
|
||||
requestMicPermission: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
@@ -218,6 +222,7 @@ private fun PagerCard(
|
||||
buildList {
|
||||
add(RadarPage.Transceivers)
|
||||
if (hasCalculatorPage) add(RadarPage.Calculator)
|
||||
add(RadarPage.Log)
|
||||
add(RadarPage.Sstv)
|
||||
}
|
||||
}
|
||||
@@ -261,6 +266,10 @@ private fun PagerCard(
|
||||
onAction = onAction,
|
||||
requestMicPermission = requestMicPermission
|
||||
)
|
||||
RadarPage.Log -> LogPage(
|
||||
uiState = uiState,
|
||||
logViewModel = logViewModel
|
||||
)
|
||||
RadarPage.Sstv -> SstvPage(
|
||||
sstv = uiState.sstv,
|
||||
dopplerFrequency = uiState.transceivers.selectedFrequency?.let { formatFrequency(it) },
|
||||
|
||||
@@ -70,7 +70,11 @@ data class RadarState(
|
||||
val mutualMaxElev: Double = 10.0,
|
||||
val mutualLabelA: String = "你",
|
||||
val mutualLabelB: String = "友台",
|
||||
val calculatorOffsetKHz: String = ""
|
||||
val calculatorOffsetKHz: String = "",
|
||||
/** Current calculator TX/RX frequencies (linear transponder), synced from the
|
||||
* Calculator tab so the Log tab records at the same frequencies. */
|
||||
val calculatorTxHz: Long? = null,
|
||||
val calculatorRxHz: Long? = null
|
||||
)
|
||||
|
||||
enum class SstvStatus { Idle, Recording }
|
||||
@@ -128,4 +132,5 @@ sealed interface RadarAction {
|
||||
|
||||
// Calculator actions
|
||||
data class ChangeCalculatorOffset(val offsetKHz: String) : RadarAction
|
||||
data class UpdateCalculatorFrequency(val txHz: Long?, val rxHz: Long?) : RadarAction
|
||||
}
|
||||
@@ -327,6 +327,12 @@ class RadarViewModel(
|
||||
}
|
||||
_uiState.update { it.copy(calculatorOffsetKHz = action.offsetKHz) }
|
||||
}
|
||||
is RadarAction.UpdateCalculatorFrequency -> {
|
||||
_uiState.update {
|
||||
if (it.calculatorTxHz == action.txHz && it.calculatorRxHz == action.rxHz) it
|
||||
else it.copy(calculatorTxHz = action.txHz, calculatorRxHz = action.rxHz)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -66,6 +66,7 @@ import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.SideEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
@@ -215,6 +216,7 @@ fun CalculatorPage(
|
||||
orbitalPos = orbitalPos,
|
||||
offsetKHz = calculatorOffsetKHz,
|
||||
onOffsetChange = { onAction(RadarAction.ChangeCalculatorOffset(it)) },
|
||||
onFrequencyChange = { tx, rx -> onAction(RadarAction.UpdateCalculatorFrequency(tx, rx)) },
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
@@ -569,6 +571,7 @@ private fun DopplerFrequencyCalculator(
|
||||
orbitalPos: OrbitalPos?,
|
||||
offsetKHz: String = "",
|
||||
onOffsetChange: (String) -> Unit = {},
|
||||
onFrequencyChange: (Long, Long) -> Unit = { _, _ -> },
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
if (orbitalPos == null || !DopplerFrequencyCalculator.isLinearTransponder(transponder)) return
|
||||
@@ -579,6 +582,12 @@ private fun DopplerFrequencyCalculator(
|
||||
var passbandPosition by rememberSaveable(transponder.uuid) { mutableStateOf(0.5f) }
|
||||
var stepSizeKHz by remember { mutableIntStateOf(1) }
|
||||
|
||||
// Sync the calculator's current frequencies upward after every recomposition,
|
||||
// so the Log tab records at the same TX/RX the operator is listening on.
|
||||
SideEffect {
|
||||
if (txFrequencyHz != 0L && rxFrequencyHz != 0L) onFrequencyChange(txFrequencyHz, rxFrequencyHz)
|
||||
}
|
||||
|
||||
val offsetHz = offsetKHz.toDoubleOrNull()?.let { it * 1000 }?.toLong() ?: 0L
|
||||
|
||||
val txLow = transponder.uplinkLow ?: return
|
||||
|
||||
+320
@@ -0,0 +1,320 @@
|
||||
/*
|
||||
* 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 android.net.Uri
|
||||
import android.provider.OpenableColumns
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
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.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
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.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
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.draw.clip
|
||||
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.text.style.TextOverflow
|
||||
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.domain.repository.LoTWStationMeta
|
||||
import com.rtbishop.look4sat.core.presentation.CardButton
|
||||
import com.rtbishop.look4sat.core.presentation.LocalSpacing
|
||||
import com.rtbishop.look4sat.core.presentation.R
|
||||
import com.rtbishop.look4sat.core.presentation.SharedDialog
|
||||
|
||||
@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?,
|
||||
stationMeta: LoTWStationMeta?,
|
||||
busy: Boolean,
|
||||
error: LoTWUploadError?,
|
||||
onDismiss: () -> Unit,
|
||||
onImport: (ByteArray, CharArray) -> Unit,
|
||||
onRemove: () -> Unit,
|
||||
onSaveStation: (LoTWStation) -> Unit
|
||||
) {
|
||||
var password by remember { mutableStateOf("") }
|
||||
var selectedFile by remember { mutableStateOf<Uri?>(null) }
|
||||
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()) }
|
||||
var region by remember { mutableStateOf(station?.region.orEmpty()) }
|
||||
// Countries whose national zonemap has exactly one pair (e.g. Germany, India) get
|
||||
// their CQZ/ITUZ prefilled; multi-zone countries are left blank to avoid a wrong
|
||||
// default that would mislead (e.g. Guangdong would show the Heilongjiang zone).
|
||||
LaunchedEffect(stationMeta) {
|
||||
val meta = stationMeta ?: return@LaunchedEffect
|
||||
val zones = meta.countryZones
|
||||
if (zones.size == 1 && cqZone.isBlank() && ituZone.isBlank()) {
|
||||
cqZone = zones[0].cq.toString()
|
||||
ituZone = zones[0].itu.toString()
|
||||
}
|
||||
// A region saved under a previous certificate (different DXCC) no longer
|
||||
// applies — clear it so saving doesn't fail validation.
|
||||
val metaRegion = meta.regionField
|
||||
if (metaRegion != null && region.isNotBlank() && metaRegion.options.none { it.code == region }) {
|
||||
region = ""
|
||||
}
|
||||
}
|
||||
val regionField = stationMeta?.regionField
|
||||
val selectedRegionName = regionField?.options?.firstOrNull { it.code == region }?.name.orEmpty()
|
||||
val context = LocalContext.current
|
||||
|
||||
// Pick the file first, then ask for the password — matches normal usage.
|
||||
val filePicker = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri ->
|
||||
if (uri != null) selectedFile = uri
|
||||
}
|
||||
|
||||
fun displayName(uri: Uri): String = runCatching {
|
||||
context.contentResolver.query(uri, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null)?.use { cursor ->
|
||||
if (cursor.moveToFirst()) cursor.getString(0) else null
|
||||
}
|
||||
}.getOrNull() ?: uri.lastPathSegment.orEmpty()
|
||||
|
||||
SharedDialog(
|
||||
title = stringResource(R.string.prefs_lotw_upload_title),
|
||||
onDismissRequest = onDismiss,
|
||||
onCancel = onDismiss,
|
||||
onAccept = null
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = LocalSpacing.current.large),
|
||||
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)
|
||||
CardButton(
|
||||
onClick = { filePicker.launch(arrayOf("*/*")) },
|
||||
text = stringResource(R.string.prefs_lotw_upload_import),
|
||||
isEnabled = !busy,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
error?.let {
|
||||
Text(
|
||||
text = stringResource(
|
||||
when (it) {
|
||||
LoTWUploadError.PASSWORD -> R.string.prefs_lotw_upload_error_password
|
||||
LoTWUploadError.EXPIRED -> R.string.prefs_lotw_upload_error_expired
|
||||
LoTWUploadError.INVALID_FILE -> R.string.prefs_lotw_upload_error_invalid
|
||||
LoTWUploadError.FORMAT -> R.string.prefs_lotw_upload_error_format
|
||||
LoTWUploadError.UNKNOWN -> R.string.prefs_lotw_upload_error_unknown
|
||||
}
|
||||
),
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
fontSize = 12.sp
|
||||
)
|
||||
}
|
||||
selectedFile?.let { uri ->
|
||||
Text(
|
||||
text = displayName(uri),
|
||||
fontSize = 12.sp,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
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()
|
||||
)
|
||||
CardButton(
|
||||
onClick = {
|
||||
val bytes = runCatching {
|
||||
context.contentResolver.openInputStream(uri)?.use { it.readBytes() }
|
||||
}.getOrNull()
|
||||
if (bytes != null && bytes.isNotEmpty()) {
|
||||
onImport(bytes, password.toCharArray())
|
||||
password = ""
|
||||
selectedFile = null
|
||||
}
|
||||
},
|
||||
text = stringResource(R.string.prefs_lotw_upload_import_confirm),
|
||||
isEnabled = password.isNotBlank() && !busy,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
} 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()
|
||||
)
|
||||
// Country-specific region field (US_STATE, CN_PROVINCE, …) shown only when
|
||||
// the certificate's DXCC entity defines one; selecting it fills CQZ/ITUZ.
|
||||
// Drawn as a plain Box (not OutlinedTextField): a read-only text field's
|
||||
// internal gesture handler consumes the tap, so clickable never fires.
|
||||
// Options expand inline inside the sheet (no Popup/Dialog window stacking).
|
||||
if (regionField != null) {
|
||||
var regionExpanded by remember { mutableStateOf(false) }
|
||||
val fieldShape = MaterialTheme.shapes.extraSmall
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(fieldShape)
|
||||
.background(MaterialTheme.colorScheme.surface)
|
||||
.border(1.dp, MaterialTheme.colorScheme.outline, fieldShape)
|
||||
.clickable { regionExpanded = !regionExpanded }
|
||||
.padding(horizontal = 12.dp, vertical = 8.dp)
|
||||
) {
|
||||
Column {
|
||||
Text(regionField.label, fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
Spacer(modifier = Modifier.height(3.dp))
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
text = if (region.isBlank()) {
|
||||
stringResource(R.string.prefs_lotw_upload_region_hint)
|
||||
} else {
|
||||
"$region — $selectedRegionName"
|
||||
},
|
||||
fontSize = 16.sp,
|
||||
color = if (region.isBlank()) {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurface
|
||||
},
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
Text(
|
||||
text = if (regionExpanded) "▴" else "▾",
|
||||
fontSize = 14.sp,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
AnimatedVisibility(visible = regionExpanded) {
|
||||
LazyColumn(modifier = Modifier.fillMaxWidth().heightIn(max = 280.dp)) {
|
||||
items(regionField.options, key = { it.code }) { option ->
|
||||
DropdownMenuItem(
|
||||
text = { Text("${option.code} — ${option.name}", fontSize = 13.sp) },
|
||||
onClick = {
|
||||
region = option.code
|
||||
regionExpanded = false
|
||||
option.zones.firstOrNull()?.let { zone ->
|
||||
cqZone = zone.cq.toString()
|
||||
ituZone = zone.itu.toString()
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
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)
|
||||
)
|
||||
}
|
||||
CardButton(
|
||||
onClick = { onSaveStation(LoTWStation(grid, cqZone, ituZone, region, "", iota)) },
|
||||
text = stringResource(R.string.prefs_lotw_upload_save),
|
||||
isEnabled = grid.isNotBlank() && !busy,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+211
@@ -0,0 +1,211 @@
|
||||
/*
|
||||
* 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.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
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.graphics.Color
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
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.domain.logbook.frequencyBand
|
||||
import com.rtbishop.look4sat.core.presentation.LocalSpacing
|
||||
import com.rtbishop.look4sat.core.presentation.R
|
||||
import com.rtbishop.look4sat.core.presentation.SharedDialog
|
||||
import com.rtbishop.look4sat.core.presentation.SwipeController
|
||||
import com.rtbishop.look4sat.core.presentation.SwipeRevealRow
|
||||
import com.rtbishop.look4sat.core.presentation.rememberSwipeController
|
||||
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>,
|
||||
uploadBusy: Boolean,
|
||||
uploadMessage: String,
|
||||
preview: com.rtbishop.look4sat.core.domain.repository.LoTWUploadPreview?,
|
||||
onDismiss: () -> Unit,
|
||||
onDelete: (Long) -> Unit,
|
||||
onUpload: () -> Unit,
|
||||
onConfirmUpload: () -> Unit,
|
||||
onDismissPreview: () -> Unit,
|
||||
onDismissMessage: () -> Unit
|
||||
) {
|
||||
val swipeController = rememberSwipeController()
|
||||
SharedDialog(
|
||||
title = stringResource(R.string.prefs_logbook_title),
|
||||
onDismissRequest = onDismiss,
|
||||
onCancel = onDismiss,
|
||||
onAccept = onUpload,
|
||||
acceptText = stringResource(R.string.prefs_logbook_upload),
|
||||
acceptEnabled = !uploadBusy
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = LocalSpacing.current.large),
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp)
|
||||
) {
|
||||
if (records.isEmpty()) {
|
||||
Text(stringResource(R.string.prefs_logbook_empty), fontSize = 14.sp)
|
||||
} else {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxWidth().heightIn(max = 420.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp)
|
||||
) {
|
||||
items(records, key = { it.id }) { record ->
|
||||
LogbookRow(record, swipeController, onDelete = { onDelete(record.id) })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (preview != null) {
|
||||
LogbookUploadPreviewDialog(
|
||||
preview = preview,
|
||||
busy = uploadBusy,
|
||||
onConfirm = onConfirmUpload,
|
||||
onDismiss = onDismissPreview
|
||||
)
|
||||
}
|
||||
if (uploadMessage.isNotBlank()) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismissMessage,
|
||||
title = { Text("LoTW Upload") },
|
||||
text = { Text(uploadMessage) },
|
||||
confirmButton = {
|
||||
TextButton(onClick = onDismissMessage) { Text("OK") }
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LogbookUploadPreviewDialog(
|
||||
preview: com.rtbishop.look4sat.core.domain.repository.LoTWUploadPreview,
|
||||
busy: Boolean,
|
||||
onConfirm: () -> Unit,
|
||||
onDismiss: () -> Unit
|
||||
) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(stringResource(R.string.prefs_logbook_upload_title)) },
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
Text("${preview.callsign} DXCC ${preview.dxcc} Grid ${preview.grid}", fontSize = 13.sp)
|
||||
Text(
|
||||
"${preview.count} QSO(s) · ${preview.firstUtc} – ${preview.lastUtc}",
|
||||
fontSize = 13.sp
|
||||
)
|
||||
Text(preview.contacts.joinToString("\n") { it }, fontSize = 12.sp, maxLines = 8)
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = onConfirm, enabled = !busy) {
|
||||
Text(stringResource(R.string.prefs_logbook_upload_confirm))
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) { Text(stringResource(R.string.btn_cancel)) }
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LogbookRow(record: QsoRecord, swipeController: SwipeController, 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()
|
||||
val band = record.band.ifBlank { frequencyBand(record.txFrequencyHz) }
|
||||
// 右划露出删除按钮(短信式);整行点击不再删除。
|
||||
SwipeRevealRow(
|
||||
key = record.id.toString(),
|
||||
controller = swipeController,
|
||||
revealAction = onDelete,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
// Row style mirrors the worked-grid QSO details on the map: callsign in
|
||||
// monospace titleMedium, summary line in bodySmall with " · " separators.
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 4.dp, vertical = 8.dp)
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
|
||||
Text(
|
||||
text = record.theirCallsign,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
color = Color(0xFFFFE082),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
if (record.lotwConfirmed) {
|
||||
Text(text = "QSL", fontSize = 13.sp, color = Color(0xFFFFE082), fontFamily = FontFamily.Monospace)
|
||||
} else if (record.lotwUploaded) {
|
||||
Text(text = "UP", fontSize = 13.sp, color = Color(0xFFFFE082), fontFamily = FontFamily.Monospace)
|
||||
}
|
||||
}
|
||||
Text(
|
||||
text = listOf(satShort, record.displayMode, band, time).filter { it.isNotBlank() }.joinToString(" · "),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.padding(top = 3.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+119
@@ -0,0 +1,119 @@
|
||||
/*
|
||||
* 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.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.rtbishop.look4sat.feature.settings
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.rtbishop.look4sat.core.domain.model.MapSource
|
||||
import com.rtbishop.look4sat.core.domain.model.OtherSettings
|
||||
import com.rtbishop.look4sat.core.presentation.R
|
||||
import com.rtbishop.look4sat.core.presentation.SharedDialog
|
||||
|
||||
@Composable
|
||||
fun MapSettingsDialog(
|
||||
settings: OtherSettings,
|
||||
onDismiss: () -> Unit,
|
||||
onSave: (mapSource: String, tiandituKey: String) -> Unit
|
||||
) {
|
||||
var selectedSource by rememberSaveable {
|
||||
mutableStateOf(MapSource.normalize(settings.mapSource))
|
||||
}
|
||||
var tiandituKey by rememberSaveable { mutableStateOf(settings.tiandituKey) }
|
||||
|
||||
SharedDialog(
|
||||
title = stringResource(R.string.prefs_map_title),
|
||||
onDismissRequest = onDismiss,
|
||||
onCancel = onDismiss,
|
||||
onAccept = { onSave(selectedSource, tiandituKey) }
|
||||
) { padding ->
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = Modifier.padding(horizontal = padding)
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.prefs_map_source_title),
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
FlowRow(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
MapSourceChip(
|
||||
selected = selectedSource == MapSource.OSM,
|
||||
label = stringResource(R.string.prefs_map_source_osm),
|
||||
onClick = { selectedSource = MapSource.OSM }
|
||||
)
|
||||
MapSourceChip(
|
||||
selected = selectedSource == MapSource.TIANDITU_VECTOR,
|
||||
label = stringResource(R.string.prefs_map_source_tianditu_vector),
|
||||
onClick = { selectedSource = MapSource.TIANDITU_VECTOR }
|
||||
)
|
||||
MapSourceChip(
|
||||
selected = selectedSource == MapSource.TIANDITU_IMAGE,
|
||||
label = stringResource(R.string.prefs_map_source_tianditu_image),
|
||||
onClick = { selectedSource = MapSource.TIANDITU_IMAGE }
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = stringResource(R.string.prefs_map_tianditu_key_help),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = tiandituKey,
|
||||
onValueChange = { tiandituKey = it },
|
||||
label = { Text(stringResource(R.string.prefs_map_tianditu_key)) },
|
||||
visualTransformation = PasswordVisualTransformation(),
|
||||
keyboardOptions = KeyboardOptions(
|
||||
keyboardType = KeyboardType.Password,
|
||||
imeAction = ImeAction.Done
|
||||
),
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
if (selectedSource != MapSource.OSM && tiandituKey.isBlank()) {
|
||||
Text(
|
||||
text = stringResource(R.string.prefs_map_tianditu_key_required),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.error
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MapSourceChip(selected: Boolean, label: String, onClick: () -> Unit) {
|
||||
FilterChip(selected = selected, onClick = onClick, label = { Text(label) })
|
||||
}
|
||||
+76
-1
@@ -99,6 +99,7 @@ fun SettingsDestination() {
|
||||
@Composable
|
||||
private fun SettingsScreen(uiState: SettingsState, onAction: (SettingsAction) -> Unit) {
|
||||
var showUpdateChecker by rememberSaveable { mutableStateOf(false) }
|
||||
var showMapSettings by rememberSaveable { mutableStateOf(false) }
|
||||
if (showUpdateChecker) {
|
||||
UpdateCheckerScreen(
|
||||
currentVersion = uiState.appVersionName,
|
||||
@@ -129,6 +130,17 @@ private fun SettingsScreen(uiState: SettingsState, onAction: (SettingsAction) ->
|
||||
}
|
||||
)
|
||||
|
||||
if (showMapSettings) {
|
||||
MapSettingsDialog(
|
||||
settings = uiState.otherSettings,
|
||||
onDismiss = { showMapSettings = false },
|
||||
onSave = { mapSource, tiandituKey ->
|
||||
onAction(SettingsAction.UpdateMapSettings(mapSource, tiandituKey))
|
||||
showMapSettings = false
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// Dialogs
|
||||
if (dialogs.position) {
|
||||
PositionDialog(
|
||||
@@ -249,6 +261,33 @@ private fun SettingsScreen(uiState: SettingsState, onAction: (SettingsAction) ->
|
||||
onSyncIncremental = { onAction(SettingsAction.SyncLoTWGrids(it, LoTWSyncMode.Incremental)) }
|
||||
)
|
||||
}
|
||||
if (dialogs.logbook) {
|
||||
LogbookDialog(
|
||||
records = uiState.logbookRecords,
|
||||
uploadBusy = uiState.logbookUploadBusy,
|
||||
uploadMessage = uiState.logbookUploadMessage,
|
||||
preview = uiState.logbookPreview,
|
||||
onDismiss = { dialogs.logbook = false },
|
||||
onDelete = { onAction(SettingsAction.DeleteLogbookRecord(it)) },
|
||||
onUpload = { onAction(SettingsAction.PrepareLogbookUpload) },
|
||||
onConfirmUpload = { onAction(SettingsAction.ConfirmLogbookUpload) },
|
||||
onDismissPreview = { onAction(SettingsAction.DismissLogbookPreview) },
|
||||
onDismissMessage = { onAction(SettingsAction.ClearLogbookMessage) }
|
||||
)
|
||||
}
|
||||
if (dialogs.lotwUpload) {
|
||||
LoTWUploadConfigDialog(
|
||||
certificate = uiState.lotwCertificate,
|
||||
station = uiState.lotwStation,
|
||||
stationMeta = uiState.lotwStationMeta,
|
||||
busy = uiState.lotwUploadBusy,
|
||||
error = uiState.lotwUploadError,
|
||||
onDismiss = { dialogs.lotwUpload = false },
|
||||
onImport = { bytes, password -> onAction(SettingsAction.ImportLoTWCertificate(bytes, password)) },
|
||||
onRemove = { onAction(SettingsAction.RemoveLoTWCertificate) },
|
||||
onSaveStation = { onAction(SettingsAction.SaveLoTWStation(it)) }
|
||||
)
|
||||
}
|
||||
|
||||
// URLs for top bar
|
||||
val uriHandler = LocalUriHandler.current
|
||||
@@ -353,6 +392,19 @@ private fun SettingsScreen(uiState: SettingsState, onAction: (SettingsAction) ->
|
||||
onRadioControlClick = { dialogs.radioControl = true }
|
||||
)
|
||||
}
|
||||
item {
|
||||
LogbookCard(
|
||||
recordCount = uiState.logbookRecords.size,
|
||||
showLogbookDialog = { dialogs.logbook = true }
|
||||
)
|
||||
}
|
||||
item {
|
||||
LoTWUploadCard(
|
||||
hasCertificate = uiState.lotwCertificate != null,
|
||||
stationGrid = uiState.lotwStation?.grid.orEmpty(),
|
||||
showUploadConfigDialog = { onAction(SettingsAction.LoadLoTWUploadStatus); dialogs.lotwUpload = true }
|
||||
)
|
||||
}
|
||||
item {
|
||||
LoTWCard(
|
||||
settings = uiState.lotwSettings,
|
||||
@@ -368,6 +420,7 @@ private fun SettingsScreen(uiState: SettingsState, onAction: (SettingsAction) ->
|
||||
onAction = onAction
|
||||
)
|
||||
}
|
||||
item { MapSettingsCard(onClick = { showMapSettings = true }) }
|
||||
item { CardCredits() }
|
||||
item(span = { GridItemSpan(maxLineSpan) }) {
|
||||
CardButton(
|
||||
@@ -558,6 +611,24 @@ private fun OtherCardPreview() = MainTheme {
|
||||
OtherCard(settings = values, onCompassCalibration = {}, onAction = {})
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MapSettingsCard(onClick: () -> Unit) {
|
||||
ElevatedCard(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(modifier = Modifier.padding(horizontal = 8.dp, vertical = 6.dp)) {
|
||||
Text(
|
||||
text = stringResource(R.string.prefs_map_title),
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
Spacer(modifier = Modifier.height(6.dp))
|
||||
CardButton(
|
||||
onClick = onClick,
|
||||
text = stringResource(R.string.prefs_map_configure),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun OtherCard(
|
||||
settings: OtherSettings,
|
||||
@@ -864,6 +935,8 @@ private class DialogVisibility {
|
||||
var wavelog by mutableStateOf(false)
|
||||
var lotw by mutableStateOf(false)
|
||||
var compassCalibration by mutableStateOf(false)
|
||||
var logbook by mutableStateOf(false)
|
||||
var lotwUpload by mutableStateOf(false)
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -871,7 +944,7 @@ private fun rememberDialogVisibility(): DialogVisibility {
|
||||
return rememberSaveable(saver = run {
|
||||
androidx.compose.runtime.saveable.Saver(
|
||||
save = {
|
||||
listOf(it.position, it.locator, it.dataSources, it.network, it.bluetooth, it.radioControl, it.wavelog, it.lotw, it.compassCalibration)
|
||||
listOf(it.position, it.locator, it.dataSources, it.network, it.bluetooth, it.radioControl, it.wavelog, it.lotw, it.compassCalibration, it.logbook, it.lotwUpload)
|
||||
},
|
||||
restore = {
|
||||
DialogVisibility().apply {
|
||||
@@ -879,6 +952,8 @@ private fun rememberDialogVisibility(): DialogVisibility {
|
||||
network = it[3]; bluetooth = it[4]; radioControl = it[5]; wavelog = it[6]
|
||||
lotw = it.getOrElse(7) { false }
|
||||
compassCalibration = it.getOrElse(8) { false }
|
||||
logbook = it.getOrElse(9) { false }
|
||||
lotwUpload = it.getOrElse(10) { false }
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
+33
@@ -68,6 +68,22 @@ data class SettingsState(
|
||||
val lotwError: LoTWError? = null,
|
||||
/** Epoch ms of the last successful LoTW sync (0 = never) — shown like the ephemeris update time. */
|
||||
val lotwLastSyncEpochMs: Long = 0L,
|
||||
/** Logbook (QSO records + LoTW confirmations), newest first. */
|
||||
val logbookRecords: List<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,
|
||||
/** Region-field options and national zonemap for the certificate's DXCC entity. */
|
||||
val lotwStationMeta: com.rtbishop.look4sat.core.domain.repository.LoTWStationMeta? = null,
|
||||
val lotwUploadBusy: Boolean = false,
|
||||
/** Last certificate import outcome; shown inside the upload config dialog. */
|
||||
val lotwUploadError: LoTWUploadError? = null,
|
||||
/** One-click logbook upload: prepared preview awaiting confirmation. */
|
||||
val logbookPreview: com.rtbishop.look4sat.core.domain.repository.LoTWUploadPreview? = null,
|
||||
val logbookUploadBusy: Boolean = false,
|
||||
/** User-facing upload message shown inside the logbook dialog ("" = none). */
|
||||
val logbookUploadMessage: String = "",
|
||||
/** 指南针校准精度等级 (校准对话框进度条). */
|
||||
val compassAccuracy: CompassAccuracy = CompassAccuracy.UNRELIABLE,
|
||||
/** 校正后航向(度, 含磁偏角+手动偏置), 校准对话框实时显示. */
|
||||
@@ -84,6 +100,8 @@ sealed interface LoTWError {
|
||||
data class Network(val detail: String) : LoTWError
|
||||
}
|
||||
|
||||
enum class LoTWUploadError { PASSWORD, INVALID_FILE, EXPIRED, FORMAT, UNKNOWN }
|
||||
|
||||
sealed interface SettingsAction {
|
||||
// Position
|
||||
data object SetGpsPosition : SettingsAction
|
||||
@@ -108,6 +126,7 @@ sealed interface SettingsAction {
|
||||
data class SetCompassOffset(val degrees: Float) : SettingsAction
|
||||
data class ToggleLightTheme(val value: Boolean) : SettingsAction
|
||||
data class ToggleNightMode(val value: Boolean) : SettingsAction
|
||||
data class UpdateMapSettings(val mapSource: String, val tiandituKey: String) : SettingsAction
|
||||
|
||||
// Remote control
|
||||
data class UpdateRC(val settings: RCSettings) : SettingsAction
|
||||
@@ -129,6 +148,20 @@ sealed interface SettingsAction {
|
||||
/** Abort an in-flight LoTW sync (wrong button / changed mind). */
|
||||
data object CancelLoTWSync : SettingsAction
|
||||
|
||||
// Logbook (QSO records + LoTW confirmations)
|
||||
data object RefreshLogbook : SettingsAction
|
||||
data class DeleteLogbookRecord(val id: Long) : SettingsAction
|
||||
data object PrepareLogbookUpload : SettingsAction
|
||||
data object ConfirmLogbookUpload : SettingsAction
|
||||
data object DismissLogbookPreview : SettingsAction
|
||||
data object ClearLogbookMessage : SettingsAction
|
||||
|
||||
// LoTW upload configuration (certificate + station)
|
||||
data object LoadLoTWUploadStatus : SettingsAction
|
||||
data class ImportLoTWCertificate(val bytes: ByteArray, val password: CharArray) : SettingsAction
|
||||
data object RemoveLoTWCertificate : SettingsAction
|
||||
data class SaveLoTWStation(val station: com.rtbishop.look4sat.core.domain.repository.LoTWStation) : SettingsAction
|
||||
|
||||
// Update checker
|
||||
data object CheckForUpdate : SettingsAction
|
||||
data object DownloadUpdate : SettingsAction
|
||||
|
||||
+144
@@ -37,9 +37,11 @@ import com.rtbishop.look4sat.core.domain.repository.resolveLoTWSyncMode
|
||||
import com.rtbishop.look4sat.core.domain.repository.IWavelogRepository
|
||||
import com.rtbishop.look4sat.core.domain.usecase.IShowToast
|
||||
import com.rtbishop.look4sat.core.domain.utility.VersionComparator
|
||||
import com.rtbishop.look4sat.core.domain.logbook.toConfirmedRecord
|
||||
import com.rtbishop.look4sat.core.presentation.R
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import java.io.File
|
||||
@@ -50,6 +52,8 @@ class SettingsViewModel(
|
||||
private val updateRepo: IUpdateRepository,
|
||||
private val wavelogRepo: IWavelogRepository,
|
||||
private val lotwRepo: com.rtbishop.look4sat.core.domain.repository.ILoTWRepository,
|
||||
private val qsoRepository: com.rtbishop.look4sat.core.domain.logbook.IQsoRepository,
|
||||
private val lotwUploadRepository: com.rtbishop.look4sat.core.domain.repository.ILoTWUploadRepository,
|
||||
private val sensorsRepo: ISensorsRepo,
|
||||
private val apkFile: File,
|
||||
private val showToast: IShowToast
|
||||
@@ -154,6 +158,11 @@ class SettingsViewModel(
|
||||
_uiState.update { it.copy(lotwSettings = settings) }
|
||||
}
|
||||
}
|
||||
viewModelScope.launch {
|
||||
qsoRepository.records.collect { records ->
|
||||
_uiState.update { it.copy(logbookRecords = records) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -186,6 +195,9 @@ class SettingsViewModel(
|
||||
}
|
||||
is SettingsAction.ToggleLightTheme -> settingsRepo.updateOtherSettings { it.copy(stateOfLightTheme = action.value) }
|
||||
is SettingsAction.ToggleNightMode -> settingsRepo.updateOtherSettings { it.copy(stateOfNightMode = action.value) }
|
||||
is SettingsAction.UpdateMapSettings -> settingsRepo.updateOtherSettings {
|
||||
it.copy(mapSource = action.mapSource, tiandituKey = action.tiandituKey)
|
||||
}
|
||||
// Remote control & data sources
|
||||
is SettingsAction.UpdateRC -> settingsRepo.updateRCSettings(action.settings)
|
||||
is SettingsAction.UpdateRadioControl -> settingsRepo.updateRadioControlSettings(action.settings)
|
||||
@@ -197,6 +209,18 @@ class SettingsViewModel(
|
||||
is SettingsAction.UpdateLoTW -> settingsRepo.updateLoTWSettings(action.settings)
|
||||
is SettingsAction.SyncLoTWGrids -> syncLoTWGrids(action.settings, action.mode)
|
||||
SettingsAction.CancelLoTWSync -> cancelLoTWSync()
|
||||
// Logbook
|
||||
SettingsAction.RefreshLogbook -> refreshLogbook()
|
||||
is SettingsAction.DeleteLogbookRecord -> viewModelScope.launch { qsoRepository.delete(action.id) }
|
||||
SettingsAction.PrepareLogbookUpload -> prepareLogbookUpload()
|
||||
SettingsAction.ConfirmLogbookUpload -> confirmLogbookUpload()
|
||||
SettingsAction.DismissLogbookPreview -> dismissLogbookPreview()
|
||||
SettingsAction.ClearLogbookMessage -> clearLogbookMessage()
|
||||
// LoTW upload configuration
|
||||
SettingsAction.LoadLoTWUploadStatus -> loadLoTWUploadStatus()
|
||||
is SettingsAction.ImportLoTWCertificate -> importLoTWCertificate(action.bytes, action.password)
|
||||
SettingsAction.RemoveLoTWCertificate -> removeLoTWCertificate()
|
||||
is SettingsAction.SaveLoTWStation -> saveLoTWStation(action.station)
|
||||
// Update checker
|
||||
SettingsAction.CheckForUpdate -> checkForUpdate()
|
||||
SettingsAction.DownloadUpdate -> downloadUpdate()
|
||||
@@ -260,6 +284,13 @@ class SettingsViewModel(
|
||||
// by call + QSO time); full: the fresh report replaces it all.
|
||||
// Shared with the automatic sync on app start (MainApplication).
|
||||
val mergedGridsCount = applyLoTWGridResult(settingsRepo, result, effectiveMode, callsign)
|
||||
// Feed the logbook: imported confirmations show up in 日志本 and
|
||||
// match local uploads (sameConfirmedContact) to mark them confirmed.
|
||||
viewModelScope.launch {
|
||||
val confirmed = result.qsos.values.flatten()
|
||||
.map { it.toConfirmedRecord(callsign) }
|
||||
qsoRepository.mergeLoTW(confirmed)
|
||||
}
|
||||
_uiState.update { state ->
|
||||
state.copy(
|
||||
lotwSyncing = false, lotwSyncMode = null, lotwProgress = null,
|
||||
@@ -309,6 +340,117 @@ class SettingsViewModel(
|
||||
|
||||
// endregion
|
||||
|
||||
// region Logbook + LoTW upload configuration
|
||||
|
||||
private fun refreshLogbook() {
|
||||
viewModelScope.launch {
|
||||
_uiState.update { it.copy(logbookRecords = qsoRepository.records.first()) }
|
||||
}
|
||||
}
|
||||
|
||||
// Record ids submitted in the most recent logbook prepare → upload cycle, so a
|
||||
// successful POST can mark them "uploaded" (distinct from "confirmed").
|
||||
private var lastLogbookUploadIds: List<Long> = emptyList()
|
||||
|
||||
private fun prepareLogbookUpload() {
|
||||
if (_uiState.value.logbookUploadBusy) return
|
||||
viewModelScope.launch {
|
||||
_uiState.update { it.copy(logbookUploadBusy = true, logbookUploadMessage = "") }
|
||||
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 == com.rtbishop.look4sat.core.domain.logbook.QsoStatus.COMPLETE }
|
||||
val audit = lotwUploadRepository.audit(pending)
|
||||
if (audit.pending == 0) {
|
||||
_uiState.update { it.copy(logbookUploadBusy = false, logbookUploadMessage = "No pending QSOs to upload") }
|
||||
return@launch
|
||||
}
|
||||
val preview = lotwUploadRepository.prepare(pending, false)
|
||||
lastLogbookUploadIds = pending.map { it.id }
|
||||
_uiState.update { it.copy(logbookUploadBusy = false, logbookPreview = preview) }
|
||||
} catch (e: com.rtbishop.look4sat.core.domain.repository.LoTWOperationException) {
|
||||
_uiState.update { it.copy(logbookUploadBusy = false, logbookUploadMessage = "Upload unavailable: ${e.reason}") }
|
||||
} catch (_: Exception) {
|
||||
_uiState.update { it.copy(logbookUploadBusy = false, logbookUploadMessage = "Upload failed") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun confirmLogbookUpload() {
|
||||
val preview = _uiState.value.logbookPreview ?: return
|
||||
viewModelScope.launch {
|
||||
_uiState.update { it.copy(logbookUploadBusy = true) }
|
||||
val result = lotwUploadRepository.upload(preview.id)
|
||||
if (result is com.rtbishop.look4sat.core.domain.repository.LoTWUploadResult.Accepted && lastLogbookUploadIds.isNotEmpty()) {
|
||||
qsoRepository.markUploaded(lastLogbookUploadIds)
|
||||
lastLogbookUploadIds = emptyList()
|
||||
}
|
||||
val msg = when (result) {
|
||||
is com.rtbishop.look4sat.core.domain.repository.LoTWUploadResult.Accepted -> "Uploaded ${result.count} QSO(s) — accepted by LoTW"
|
||||
is com.rtbishop.look4sat.core.domain.repository.LoTWUploadResult.Rejected -> "Rejected: ${result.message}"
|
||||
com.rtbishop.look4sat.core.domain.repository.LoTWUploadResult.Unknown -> "Unknown result — will not auto-retry"
|
||||
com.rtbishop.look4sat.core.domain.repository.LoTWUploadResult.ExpiredPreview -> "Preview expired — tap upload again"
|
||||
}
|
||||
_uiState.update { it.copy(logbookUploadBusy = false, logbookPreview = null, logbookUploadMessage = msg) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun dismissLogbookPreview() = _uiState.update { it.copy(logbookPreview = null) }
|
||||
|
||||
private fun clearLogbookMessage() = _uiState.update { it.copy(logbookUploadMessage = "") }
|
||||
|
||||
private fun loadLoTWUploadStatus() {
|
||||
viewModelScope.launch {
|
||||
val cert = lotwUploadRepository.certificate()
|
||||
val station = lotwUploadRepository.station()
|
||||
// Config parsing failure must never crash the dialog — degrade to no meta.
|
||||
val meta = cert?.let { runCatching { lotwUploadRepository.stationMeta(it.dxcc) }.getOrNull() }
|
||||
_uiState.update { it.copy(lotwCertificate = cert, lotwStation = station, lotwStationMeta = meta, lotwUploadBusy = false) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun importLoTWCertificate(bytes: ByteArray, password: CharArray) {
|
||||
viewModelScope.launch {
|
||||
_uiState.update { it.copy(lotwUploadBusy = true, lotwUploadError = null) }
|
||||
try {
|
||||
val cert = lotwUploadRepository.importCertificate(bytes, password)
|
||||
_uiState.update { it.copy(lotwCertificate = cert, lotwUploadBusy = false, lotwUploadError = null) }
|
||||
} catch (e: com.rtbishop.look4sat.core.domain.repository.LoTWOperationException) {
|
||||
val error = when (e.reason) {
|
||||
com.rtbishop.look4sat.core.domain.repository.LoTWProblem.CERTIFICATE_PASSWORD -> LoTWUploadError.PASSWORD
|
||||
com.rtbishop.look4sat.core.domain.repository.LoTWProblem.CERTIFICATE_EXPIRED -> LoTWUploadError.EXPIRED
|
||||
com.rtbishop.look4sat.core.domain.repository.LoTWProblem.CERTIFICATE_FORMAT -> LoTWUploadError.FORMAT
|
||||
else -> LoTWUploadError.INVALID_FILE
|
||||
}
|
||||
_uiState.update { it.copy(lotwUploadBusy = false, lotwUploadError = error) }
|
||||
} catch (_: Exception) {
|
||||
_uiState.update { it.copy(lotwUploadBusy = false, lotwUploadError = LoTWUploadError.UNKNOWN) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun removeLoTWCertificate() {
|
||||
viewModelScope.launch {
|
||||
lotwUploadRepository.removeCertificate()
|
||||
_uiState.update { it.copy(lotwCertificate = null, lotwStation = null, lotwStationMeta = null) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun saveLoTWStation(station: com.rtbishop.look4sat.core.domain.repository.LoTWStation) {
|
||||
viewModelScope.launch {
|
||||
_uiState.update { it.copy(lotwUploadBusy = true) }
|
||||
try {
|
||||
val saved = lotwUploadRepository.saveStation(station)
|
||||
_uiState.update { it.copy(lotwStation = saved, lotwUploadBusy = false) }
|
||||
} catch (_: Exception) {
|
||||
_uiState.update { it.copy(lotwUploadBusy = false) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// endregion
|
||||
|
||||
// region Update checker
|
||||
|
||||
private fun checkForUpdate() {
|
||||
@@ -444,6 +586,8 @@ class SettingsViewModel(
|
||||
updateRepo = container.updateRepo,
|
||||
wavelogRepo = container.wavelogRepo,
|
||||
lotwRepo = container.lotwRepo,
|
||||
qsoRepository = container.qsoRepository,
|
||||
lotwUploadRepository = container.lotwUploadRepository,
|
||||
sensorsRepo = container.provideSensorsRepo(),
|
||||
apkFile = File(context.cacheDir, "look4sat-update.apk"),
|
||||
showToast = container.provideShowToast()
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
[versions]
|
||||
#noinspection UnusedVersionCatalogEntry
|
||||
appVersionCode = "505"
|
||||
appVersionCode = "523"
|
||||
#noinspection UnusedVersionCatalogEntry
|
||||
appVersionName = "4.4.7-ba7opf.15.8"
|
||||
appVersionName = "4.4.7-ba7opf.17"
|
||||
#noinspection UnusedVersionCatalogEntry
|
||||
compileSdk = "37"
|
||||
#noinspection UnusedVersionCatalogEntry
|
||||
|
||||
Reference in new issue
Block a user