mirror of
https://github.com/atsunatsu/Look4Sat.git
synced 2026-10-04 03:37:21 +00:00
feat: LoTW upload + logbook (port from Iu-yang1's fork)
- Port TQ8 signing/upload pipeline (core/data/lotw: LoTWConfig, LoTWKeyMaterial,
LoTWSigner, LoTWStorage, LoTWUploadRepository) with bundled ARRL config.tq6
- Multi-grid station locations: primary grid in GRIDSQUARE + full set in
MY_VUCC_GRIDS (unsigned tSTATION extra, IOTA precedent); signer matches
myGrid against the whole grid set
- Satellite-name normalization: SatNOGS names ("SO-50 (SaudiOSCAR 50)") resolve
to official LoTW names by exact-then-substring match
- Logbook storage: separate QsoDatabase (version 1, no migration on existing
installs), QsoRepository with dedupe/confirmation merge; LoTW sync (manual +
auto) feeds confirmations into the logbook and marks local uploads confirmed
- Radar Log tab: single-callsign recording, per-satellite mode preset
(CW/SSB/FT4), English social post generation (copy/share), LoTW upload with
one-shot preview confirmation + receipts ledger
- Settings: 日志本 section (records + confirmation state, delete on tap) above
LoTW config; LoTW upload config (TrustedQSL .p12 import + password, station
grid list incl. IOTA/CQZ/ITUZ)
- i18n strings (en/zh)
This commit is contained in:
1 parent
d33629f015
commit
8e549cd43c
36 files changed
+2696
-4
No files matched your search
@@ -0,0 +1,163 @@
|
||||
/*
|
||||
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*/
|
||||
package com.rtbishop.look4sat.core.domain.logbook
|
||||
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
import java.util.TimeZone
|
||||
|
||||
/** Small ADI codec for the logbook's actual field set (ADIF 3.1.7). */
|
||||
object AdifCodec {
|
||||
private val utc = TimeZone.getTimeZone("UTC")
|
||||
|
||||
fun encode(records: List<QsoRecord>): String = buildString {
|
||||
append(field("ADIF_VER", "3.1.7"))
|
||||
append(field("PROGRAMID", "Look4Sat"))
|
||||
append("<EOH>\r\n")
|
||||
records.forEach { record ->
|
||||
val startDate = format(record.startUtcMillis, "yyyyMMdd")
|
||||
val startTime = format(record.startUtcMillis, "HHmmss")
|
||||
append(field("QSO_DATE", startDate))
|
||||
append(field("TIME_ON", startTime))
|
||||
record.endUtcMillis?.let {
|
||||
append(field("QSO_DATE_OFF", format(it, "yyyyMMdd")))
|
||||
append(field("TIME_OFF", format(it, "HHmmss")))
|
||||
}
|
||||
append(field("CALL", record.theirCallsign))
|
||||
append(field("STATION_CALLSIGN", record.myCallsign))
|
||||
appendOptional("MODE", record.mode)
|
||||
appendOptional("SUBMODE", record.submode)
|
||||
appendOptional("GRIDSQUARE", record.theirGrid)
|
||||
appendOptional("MY_GRIDSQUARE", record.myGrid)
|
||||
appendOptional("RST_SENT", record.sentReport)
|
||||
appendOptional("RST_RCVD", record.receivedReport)
|
||||
record.txFrequencyHz?.let { append(field("FREQ", hzToMhz(it))) }
|
||||
record.rxFrequencyHz?.let { append(field("FREQ_RX", hzToMhz(it))) }
|
||||
appendOptional("BAND", record.band)
|
||||
appendOptional("BAND_RX", record.rxBand)
|
||||
appendOptional("PROP_MODE", record.propagationMode.ifBlank { if (record.satelliteName.isNotBlank()) "SAT" else "" })
|
||||
appendOptional("SAT_NAME", record.satelliteName)
|
||||
appendOptional("SAT_MODE", record.satelliteMode)
|
||||
if (record.lotwConfirmed) append(field("LOTW_QSL_RCVD", "Y"))
|
||||
if (record.lotwReceived) append(field("LOTW_QSL_SENT", "Y"))
|
||||
appendOptional("LOTW_QSLRDATE", record.lotwQslDate)
|
||||
appendOptional("VUCC_GRIDS", record.vuccGrids.joinToString(","))
|
||||
record.dxcc?.let { append(field("DXCC", it.toString())) }
|
||||
appendOptional("COUNTRY", record.country)
|
||||
record.cqZone?.let { append(field("CQZ", it.toString())) }
|
||||
appendOptional("STATE", record.region)
|
||||
appendOptional("COMMENT", record.comment)
|
||||
appendOptional("APP_LOOK4SAT_TRANSPONDER", record.transponderName)
|
||||
record.passAosUtcMillis?.let { append(field("APP_LOOK4SAT_PASS_AOS", it.toString())) }
|
||||
append(field("APP_LOOK4SAT_AUTOMATIC", if (record.automatic) "Y" else "N"))
|
||||
append(field("APP_LOOK4SAT_STATUS", record.status.name))
|
||||
append("<EOR>\r\n")
|
||||
}
|
||||
}
|
||||
|
||||
fun decode(content: String): List<QsoRecord> = decodeRecords(splitRecords(content))
|
||||
|
||||
fun decodeRecords(records: List<Map<String, String>>): List<QsoRecord> = records.mapNotNull { values ->
|
||||
val call = values["CALL"].orEmpty().trim().uppercase(Locale.US)
|
||||
val date = values["QSO_DATE"].orEmpty()
|
||||
val time = values["TIME_ON"].orEmpty()
|
||||
if (call.isBlank() || date.length != 8 || time.length !in setOf(4, 6)) return@mapNotNull null
|
||||
val start = parse(date, time) ?: return@mapNotNull null
|
||||
val end = values["QSO_DATE_OFF"]?.let { endDate ->
|
||||
values["TIME_OFF"]?.let { endTime -> parse(endDate, endTime) }
|
||||
}
|
||||
QsoRecord(
|
||||
startUtcMillis = start,
|
||||
endUtcMillis = end,
|
||||
theirCallsign = call,
|
||||
myCallsign = values["STATION_CALLSIGN"].orEmpty().uppercase(Locale.US),
|
||||
theirGrid = values["GRIDSQUARE"].orEmpty().uppercase(Locale.US),
|
||||
myGrid = values["MY_GRIDSQUARE"].orEmpty().uppercase(Locale.US),
|
||||
sentReport = values["RST_SENT"].orEmpty(),
|
||||
receivedReport = values["RST_RCVD"].orEmpty(),
|
||||
txFrequencyHz = mhzToHz(values["FREQ"]),
|
||||
rxFrequencyHz = mhzToHz(values["FREQ_RX"]),
|
||||
band = values["BAND"].orEmpty(),
|
||||
rxBand = values["BAND_RX"].orEmpty(),
|
||||
mode = values["MODE"].orEmpty(),
|
||||
submode = values["SUBMODE"].orEmpty(),
|
||||
satelliteName = values["SAT_NAME"].orEmpty(),
|
||||
transponderName = values["APP_LOOK4SAT_TRANSPONDER"].orEmpty(),
|
||||
satelliteMode = values["SAT_MODE"].orEmpty(),
|
||||
passAosUtcMillis = values["APP_LOOK4SAT_PASS_AOS"]?.toLongOrNull(),
|
||||
automatic = values["APP_LOOK4SAT_AUTOMATIC"].equals("Y", true),
|
||||
status = values["APP_LOOK4SAT_STATUS"]?.let { runCatching { QsoStatus.valueOf(it) }.getOrNull() }
|
||||
?: QsoStatus.COMPLETE,
|
||||
propagationMode = values["PROP_MODE"].orEmpty().trim().uppercase(Locale.US),
|
||||
lotwConfirmed = values["LOTW_QSL_RCVD"].equals("Y", true),
|
||||
lotwReceived = values["LOTW_QSL_SENT"].equals("Y", true),
|
||||
lotwQslDate = values["LOTW_QSLRDATE"].orEmpty(),
|
||||
vuccGrids = values["VUCC_GRIDS"].orEmpty().split(',').map { it.trim().uppercase(Locale.US) }
|
||||
.filter { it.matches(Regex("[A-R]{2}[0-9]{2}([A-X]{2}([0-9]{2})?)?")) }.distinct(),
|
||||
dxcc = values["DXCC"]?.toIntOrNull(),
|
||||
country = values["COUNTRY"].orEmpty(),
|
||||
cqZone = values["CQZ"]?.toIntOrNull()?.takeIf { it in 1..40 },
|
||||
region = values["STATE"].orEmpty().substringBefore(" // ").trim().uppercase(Locale.US),
|
||||
comment = values["COMMENT"].orEmpty()
|
||||
)
|
||||
}
|
||||
|
||||
private fun splitRecords(content: String): List<Map<String, String>> {
|
||||
val records = mutableListOf<Map<String, String>>()
|
||||
var values = linkedMapOf<String, String>()
|
||||
var index = 0
|
||||
while (index < content.length) {
|
||||
val start = content.indexOf('<', index)
|
||||
if (start < 0) break
|
||||
val end = content.indexOf('>', start + 1)
|
||||
if (end < 0) break
|
||||
val descriptor = content.substring(start + 1, end)
|
||||
val parts = descriptor.split(':')
|
||||
val name = parts.first().trim().uppercase(Locale.US)
|
||||
index = end + 1
|
||||
when (name) {
|
||||
"EOH" -> values.clear()
|
||||
"EOR" -> {
|
||||
if (values.isNotEmpty()) records += values.toMap()
|
||||
values = linkedMapOf()
|
||||
}
|
||||
else -> {
|
||||
val length = parts.getOrNull(1)?.toIntOrNull() ?: continue
|
||||
if (length < 0 || index + length > content.length) continue
|
||||
values[name] = content.substring(index, index + length)
|
||||
index += length
|
||||
}
|
||||
}
|
||||
}
|
||||
return records
|
||||
}
|
||||
|
||||
private fun StringBuilder.appendOptional(name: String, value: String) {
|
||||
if (value.isNotBlank()) append(field(name, value))
|
||||
}
|
||||
|
||||
private fun field(name: String, value: String): String = "<$name:${value.length}>$value"
|
||||
|
||||
private fun hzToMhz(value: Long): String = String.format(Locale.US, "%.6f", value / 1_000_000.0)
|
||||
|
||||
private fun mhzToHz(value: String?): Long? = value?.toDoubleOrNull()?.let { (it * 1_000_000.0).toLong() }
|
||||
|
||||
private fun format(value: Long, pattern: String): String =
|
||||
SimpleDateFormat(pattern, Locale.US).apply { timeZone = utc }.format(Date(value))
|
||||
|
||||
private fun parse(date: String, time: String): Long? = runCatching {
|
||||
val normalizedTime = if (time.length == 4) "${time}00" else time
|
||||
SimpleDateFormat("yyyyMMddHHmmss", Locale.US).apply {
|
||||
isLenient = false
|
||||
timeZone = utc
|
||||
}.parse(date + normalizedTime)?.time
|
||||
}.getOrNull()
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
/*
|
||||
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*/
|
||||
package com.rtbishop.look4sat.core.domain.logbook
|
||||
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
interface IQsoRepository {
|
||||
val records: Flow<List<QsoRecord>>
|
||||
|
||||
suspend fun find(id: Long): QsoRecord?
|
||||
suspend fun save(record: QsoRecord): Long
|
||||
suspend fun delete(id: Long)
|
||||
suspend fun exportAdi(ids: Set<Long>? = null, includeIncomplete: Boolean = false): String
|
||||
suspend fun importAdi(content: String): AdifImportResult
|
||||
suspend fun mergeConfirmed(records: List<QsoRecord>): AdifImportResult
|
||||
suspend fun mergeLoTW(records: List<QsoRecord>): AdifImportResult
|
||||
}
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
package com.rtbishop.look4sat.core.domain.logbook
|
||||
|
||||
import com.rtbishop.look4sat.core.domain.model.GridQso
|
||||
|
||||
/** Bridge a LoTW report QSO (confirmed, download side) into a logbook record
|
||||
* so the logbook can show downloaded confirmations and mark local uploads
|
||||
* as confirmed (matched by [sameConfirmedContact]). */
|
||||
fun GridQso.toConfirmedRecord(accountCallsign: String): QsoRecord = QsoRecord(
|
||||
startUtcMillis = epochMs,
|
||||
theirCallsign = call,
|
||||
myCallsign = accountCallsign.trim().uppercase(),
|
||||
myGrid = (myGrids.firstOrNull() ?: myGrid).orEmpty(),
|
||||
vuccGrids = myGrids.toList(),
|
||||
band = bandUp,
|
||||
rxBand = bandDown,
|
||||
mode = mode,
|
||||
satelliteName = satName,
|
||||
propagationMode = "SAT",
|
||||
status = QsoStatus.COMPLETE,
|
||||
lotwConfirmed = true,
|
||||
dxcc = dxcc,
|
||||
country = country.orEmpty(),
|
||||
cqZone = cqz,
|
||||
region = state.orEmpty()
|
||||
)
|
||||
+65
@@ -0,0 +1,65 @@
|
||||
package com.rtbishop.look4sat.core.domain.logbook
|
||||
|
||||
import java.util.Locale
|
||||
import kotlin.math.abs
|
||||
|
||||
/** LoTW official name vs SatNOGS name ("SO-50 (SaudiOSCAR 50)"): compare the
|
||||
* parenthetical-free prefix so local records and downloaded confirmations match. */
|
||||
fun satMatchKey(name: String): String = name.substringBefore('(').trim().uppercase(Locale.US)
|
||||
|
||||
/** LoTW omits frequency and may round time to a minute. Only merge an unambiguous contact. */
|
||||
fun sameConfirmedContact(local: QsoRecord, remote: QsoRecord): Boolean =
|
||||
local.theirCallsign.trim().equals(remote.theirCallsign.trim(), true) &&
|
||||
(local.myCallsign.isBlank() || remote.myCallsign.isBlank() || local.myCallsign.equals(remote.myCallsign, true)) &&
|
||||
satMatchKey(local.satelliteName) == satMatchKey(remote.satelliteName) &&
|
||||
local.isSatellite == remote.isSatellite &&
|
||||
local.displayMode == remote.displayMode &&
|
||||
(local.band.isBlank() || remote.band.isBlank() || local.band.equals(remote.band, true)) &&
|
||||
abs(local.startUtcMillis - remote.startUtcMillis) < 60_000L
|
||||
|
||||
fun QsoRecord.confirmationLookupKey(): String = listOf(
|
||||
theirCallsign.trim().uppercase(Locale.US), satMatchKey(satelliteName), displayMode
|
||||
).joinToString("|")
|
||||
|
||||
fun QsoRecord.withConfirmation(confirmed: QsoRecord): QsoRecord = copy(
|
||||
myCallsign = myCallsign.ifBlank { confirmed.myCallsign },
|
||||
theirGrid = confirmed.theirGrid.ifBlank { theirGrid },
|
||||
myGrid = myGrid.ifBlank { confirmed.myGrid },
|
||||
sentReport = sentReport.ifBlank { confirmed.sentReport },
|
||||
receivedReport = receivedReport.ifBlank { confirmed.receivedReport },
|
||||
txFrequencyHz = txFrequencyHz ?: confirmed.txFrequencyHz,
|
||||
rxFrequencyHz = rxFrequencyHz ?: confirmed.rxFrequencyHz,
|
||||
band = band.ifBlank { confirmed.band },
|
||||
rxBand = rxBand.ifBlank { confirmed.rxBand },
|
||||
propagationMode = propagationMode.ifBlank { confirmed.propagationMode },
|
||||
status = QsoStatus.COMPLETE,
|
||||
lotwConfirmed = lotwConfirmed || confirmed.lotwConfirmed,
|
||||
lotwReceived = lotwReceived || confirmed.lotwReceived || confirmed.lotwConfirmed,
|
||||
lotwQslDate = confirmed.lotwQslDate.ifBlank { lotwQslDate },
|
||||
vuccGrids = confirmed.vuccGrids.ifEmpty { vuccGrids },
|
||||
dxcc = confirmed.dxcc ?: dxcc,
|
||||
country = confirmed.country.ifBlank { country },
|
||||
cqZone = confirmed.cqZone ?: cqZone,
|
||||
region = confirmed.region.ifBlank { region }
|
||||
)
|
||||
|
||||
val QsoRecord.displayMode: String
|
||||
get() = submode.ifBlank { mode }.trim().uppercase(Locale.US)
|
||||
|
||||
val QsoRecord.isSatellite: Boolean
|
||||
get() = propagationMode.equals("SAT", true) || (propagationMode.isBlank() && satelliteName.isNotBlank())
|
||||
|
||||
fun frequencyBand(hz: Long?): String = when (hz) {
|
||||
null -> ""
|
||||
in 28_000_000L..29_700_000L -> "10M"
|
||||
in 50_000_000L..54_000_000L -> "6M"
|
||||
in 144_000_000L..148_000_000L -> "2M"
|
||||
in 219_000_000L..225_000_000L -> "1.25M"
|
||||
in 420_000_000L..450_000_000L -> "70CM"
|
||||
in 902_000_000L..928_000_000L -> "33CM"
|
||||
in 1_240_000_000L..1_300_000_000L -> "23CM"
|
||||
in 2_300_000_000L..2_450_000_000L -> "13CM"
|
||||
in 5_650_000_000L..5_925_000_000L -> "6CM"
|
||||
in 10_000_000_000L..10_500_000_000L -> "3CM"
|
||||
else -> ""
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
/*
|
||||
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*/
|
||||
package com.rtbishop.look4sat.core.domain.logbook
|
||||
|
||||
enum class QsoStatus { DRAFT, COMPLETE, ABORTED }
|
||||
|
||||
data class QsoRecord(
|
||||
val id: Long = 0L,
|
||||
val startUtcMillis: Long,
|
||||
val endUtcMillis: Long? = null,
|
||||
val theirCallsign: String,
|
||||
val myCallsign: String,
|
||||
val theirGrid: String = "",
|
||||
val myGrid: String = "",
|
||||
val sentReport: String = "",
|
||||
val receivedReport: String = "",
|
||||
val txFrequencyHz: Long? = null,
|
||||
val rxFrequencyHz: Long? = null,
|
||||
val band: String = "",
|
||||
val rxBand: String = "",
|
||||
val mode: String = "MFSK",
|
||||
val submode: String = "FT4",
|
||||
val satelliteName: String = "",
|
||||
val transponderName: String = "",
|
||||
val satelliteMode: String = "",
|
||||
val passAosUtcMillis: Long? = null,
|
||||
val automatic: Boolean = false,
|
||||
val status: QsoStatus = QsoStatus.DRAFT,
|
||||
val propagationMode: String = if (satelliteName.isNotBlank()) "SAT" else "",
|
||||
val lotwConfirmed: Boolean = false,
|
||||
val lotwReceived: Boolean = false,
|
||||
val lotwQslDate: String = "",
|
||||
val vuccGrids: List<String> = emptyList(),
|
||||
val dxcc: Int? = null,
|
||||
val country: String = "",
|
||||
val cqZone: Int? = null,
|
||||
val region: String = "",
|
||||
val comment: String = ""
|
||||
)
|
||||
|
||||
data class AdifImportResult(val imported: Int, val skipped: Int, val updated: Int = 0)
|
||||
+74
@@ -0,0 +1,74 @@
|
||||
package com.rtbishop.look4sat.core.domain.repository
|
||||
|
||||
import com.rtbishop.look4sat.core.domain.logbook.QsoRecord
|
||||
|
||||
interface ILoTWUploadRepository {
|
||||
suspend fun certificate(): LoTWCertificate?
|
||||
suspend fun station(): LoTWStation?
|
||||
suspend fun importCertificate(data: ByteArray, password: CharArray): LoTWCertificate
|
||||
suspend fun saveCertificatePassword(password: CharArray): LoTWCertificate
|
||||
suspend fun saveStation(station: LoTWStation): LoTWStation
|
||||
suspend fun removeCertificate()
|
||||
suspend fun audit(records: List<QsoRecord>): LoTWUploadAudit
|
||||
suspend fun prepare(records: List<QsoRecord>, resubmit: Boolean): LoTWUploadPreview
|
||||
suspend fun upload(previewId: String): LoTWUploadResult
|
||||
fun discardPreview()
|
||||
}
|
||||
|
||||
data class LoTWCertificate(
|
||||
val callsign: String,
|
||||
val dxcc: Int,
|
||||
val serial: String,
|
||||
val expires: String,
|
||||
val firstQsoDate: String,
|
||||
val lastQsoDate: String,
|
||||
val passwordSaved: Boolean = false
|
||||
)
|
||||
|
||||
/** The location is explicitly confirmed for the selected contacts, not inferred from today's GPS. */
|
||||
data class LoTWStation(
|
||||
val grid: String = "",
|
||||
val cqZone: String = "",
|
||||
val ituZone: String = "",
|
||||
val region: String = "",
|
||||
val county: String = "",
|
||||
val iota: String = ""
|
||||
)
|
||||
|
||||
data class LoTWUploadPreview(
|
||||
val id: String,
|
||||
val callsign: String,
|
||||
val dxcc: Int,
|
||||
val grid: String,
|
||||
val count: Int,
|
||||
val skipped: Int,
|
||||
val firstUtc: String,
|
||||
val lastUtc: String,
|
||||
val contacts: List<String>,
|
||||
val unknownSkipped: Int = 0
|
||||
)
|
||||
|
||||
/** Local comparison against downloaded LoTW receipt flags and this app's durable upload receipts. */
|
||||
data class LoTWUploadAudit(
|
||||
val total: Int,
|
||||
val pending: Int,
|
||||
val uploaded: Int,
|
||||
val unknown: Int,
|
||||
val unavailable: Int
|
||||
)
|
||||
|
||||
sealed interface LoTWUploadResult {
|
||||
data class Accepted(val count: Int) : LoTWUploadResult
|
||||
data class Rejected(val message: String = "") : LoTWUploadResult
|
||||
/** May have reached LoTW. Do not retry automatically. */
|
||||
data object Unknown : LoTWUploadResult
|
||||
data object ExpiredPreview : LoTWUploadResult
|
||||
}
|
||||
|
||||
class LoTWOperationException(val reason: LoTWProblem, val detail: String = "") : Exception(reason.name)
|
||||
|
||||
enum class LoTWProblem {
|
||||
CERTIFICATE_PASSWORD, CERTIFICATE_INVALID, CERTIFICATE_EXPIRED, CERTIFICATE_MISSING,
|
||||
STORAGE, EMPTY_SELECTION, CALLSIGN_MISMATCH, QSO_DATE, STATION_GRID, STATION_REGION,
|
||||
STATION_ZONE, STATION_IOTA, MODE, BAND, SATELLITE, INVALID_CONTACT, LOCATION_MISMATCH, TOO_MANY_CONTACTS
|
||||
}
|
||||
+3
@@ -16,6 +16,7 @@
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
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
@@ -89,6 +89,12 @@ interface ISettingsRepo {
|
||||
fun setSatelliteOffset(catnum: Int, offset: String)
|
||||
//endregion
|
||||
|
||||
//region # Per-satellite logbook mode preset settings
|
||||
/** Last selected logbook mode (CW/SSB/FT4) for a satellite; empty when unset. */
|
||||
fun getSatelliteMode(catnum: Int): String
|
||||
fun setSatelliteMode(catnum: Int, mode: String)
|
||||
//endregion
|
||||
|
||||
//region # AMSAT status report settings
|
||||
fun getAmSatCallsign(): String
|
||||
fun setAmSatCallsign(callsign: String)
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
/*
|
||||
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*/
|
||||
package com.rtbishop.look4sat.core.domain.logbook
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class AdifCodecTest {
|
||||
|
||||
@Test
|
||||
fun encodeDecode_roundTrip() {
|
||||
val record = QsoRecord(
|
||||
startUtcMillis = 1_700_000_000_000L,
|
||||
endUtcMillis = 1_700_000_000_100L,
|
||||
theirCallsign = "BG5JVM",
|
||||
myCallsign = "BA7OPF",
|
||||
theirGrid = "OM60",
|
||||
myGrid = "OL62TI",
|
||||
sentReport = "59",
|
||||
receivedReport = "59",
|
||||
txFrequencyHz = 145_850_000L,
|
||||
rxFrequencyHz = 436_805_000L,
|
||||
band = "2M",
|
||||
rxBand = "70CM",
|
||||
mode = "FM",
|
||||
satelliteName = "SO-50",
|
||||
satelliteMode = "FM",
|
||||
propagationMode = "SAT",
|
||||
status = QsoStatus.COMPLETE,
|
||||
lotwConfirmed = true,
|
||||
lotwQslDate = "2026-09-20",
|
||||
vuccGrids = listOf("OM60", "OM50")
|
||||
)
|
||||
val decoded = AdifCodec.decode(AdifCodec.encode(listOf(record))).single()
|
||||
assertEquals(record.startUtcMillis, decoded.startUtcMillis)
|
||||
assertEquals(record.theirCallsign, decoded.theirCallsign)
|
||||
assertEquals(record.myCallsign, decoded.myCallsign)
|
||||
assertEquals(record.satelliteName, decoded.satelliteName)
|
||||
assertEquals("2M", decoded.band)
|
||||
assertEquals("70CM", decoded.rxBand)
|
||||
assertTrue(decoded.lotwConfirmed)
|
||||
assertEquals(listOf("OM60", "OM50"), decoded.vuccGrids)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun decode_lotwStyleReportFields() {
|
||||
// LoTW report subset: lowercase eor, alphabetical fields, VUCC_GRIDS list.
|
||||
val adi = """
|
||||
<EOH>
|
||||
<CALL:6>BH6RJD
|
||||
<QSO_DATE:8>20260916
|
||||
<TIME_ON:6>051300
|
||||
<MODE:2>FM
|
||||
<SAT_NAME:5>SO-50
|
||||
<PROP_MODE:3>SAT
|
||||
<BAND:3>2M
|
||||
<BAND_RX:4>70CM
|
||||
<GRIDSQUARE:4>OM60
|
||||
<MY_GRIDSQUARE:8>OM60IL70
|
||||
<VUCC_GRIDS:9>OM60,OM50
|
||||
<STATION_CALLSIGN:6>BH6RJD
|
||||
<DXCC:3>318
|
||||
<COUNTRY:5>China
|
||||
<LOTW_QSL_RCVD:1>Y
|
||||
<eor>
|
||||
""".trimIndent()
|
||||
val records = AdifCodec.decode(adi)
|
||||
assertEquals(1, records.size)
|
||||
val record = records.single()
|
||||
assertEquals("BH6RJD", record.theirCallsign)
|
||||
assertEquals("SO-50", record.satelliteName)
|
||||
assertEquals("FM", record.displayMode)
|
||||
assertEquals("OM60IL70", record.myGrid)
|
||||
assertEquals(listOf("OM60", "OM50"), record.vuccGrids)
|
||||
assertTrue(record.lotwConfirmed)
|
||||
assertTrue(record.isSatellite)
|
||||
}
|
||||
}
|
||||
+126
@@ -0,0 +1,126 @@
|
||||
/*
|
||||
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*/
|
||||
package com.rtbishop.look4sat.core.domain.logbook
|
||||
|
||||
import com.rtbishop.look4sat.core.domain.model.GridQso
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class LogbookMergeTest {
|
||||
|
||||
private val local = QsoRecord(
|
||||
startUtcMillis = 1_700_000_000_000L,
|
||||
theirCallsign = "bg5jvm",
|
||||
myCallsign = "ba7opf",
|
||||
txFrequencyHz = 145_850_000L,
|
||||
mode = "FM",
|
||||
submode = "",
|
||||
satelliteName = "SO-50 (SaudiOSCAR 50)",
|
||||
propagationMode = "SAT",
|
||||
status = QsoStatus.COMPLETE
|
||||
)
|
||||
|
||||
private val confirmed = QsoRecord(
|
||||
startUtcMillis = 1_700_000_000_000L,
|
||||
theirCallsign = "BG5JVM",
|
||||
myCallsign = "BA7OPF",
|
||||
mode = "FM",
|
||||
submode = "",
|
||||
satelliteName = "SO-50",
|
||||
propagationMode = "SAT",
|
||||
status = QsoStatus.COMPLETE,
|
||||
lotwConfirmed = true,
|
||||
vuccGrids = listOf("OM60", "OM50")
|
||||
)
|
||||
|
||||
@Test
|
||||
fun satMatchKey_stripsParenthetical() {
|
||||
assertEquals("SO-50", satMatchKey("SO-50 (SaudiOSCAR 50)"))
|
||||
assertEquals("SO-50", satMatchKey("SO-50"))
|
||||
assertEquals("AO-91", satMatchKey("AO-91 (FUNcube-1)"))
|
||||
assertEquals("IO-86", satMatchKey("IO-86"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sameConfirmedContact_matchesAcrossSatNOGSAndOfficialName() {
|
||||
assertTrue(sameConfirmedContact(local, confirmed))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sameConfirmedContact_rejectsDifferentSatellite() {
|
||||
val other = confirmed.copy(satelliteName = "AO-91")
|
||||
assertFalse(sameConfirmedContact(local, other))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sameConfirmedContact_rejectsTimeSkew() {
|
||||
val skewed = confirmed.copy(startUtcMillis = confirmed.startUtcMillis + 120_000L)
|
||||
assertFalse(sameConfirmedContact(local, skewed))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun confirmationLookupKey_usesNormalizedSatellite() {
|
||||
assertEquals(local.confirmationLookupKey(), confirmed.confirmationLookupKey())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun withConfirmation_marksConfirmedAndMergesVuccGrids() {
|
||||
val merged = local.withConfirmation(confirmed)
|
||||
assertTrue(merged.lotwConfirmed)
|
||||
assertTrue(merged.lotwReceived)
|
||||
assertEquals(listOf("OM60", "OM50"), merged.vuccGrids)
|
||||
// Local myCallsign is kept when present; only blanks are backfilled.
|
||||
assertEquals("ba7opf", merged.myCallsign)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun toConfirmedRecord_mapsGridQso() {
|
||||
val qso = GridQso(
|
||||
call = "BH6RJD",
|
||||
epochMs = 1_700_000_000_000L,
|
||||
satName = "SO-50",
|
||||
mode = "FM",
|
||||
bandUp = "70CM",
|
||||
bandDown = "2M",
|
||||
dxcc = 318,
|
||||
country = "China",
|
||||
cqz = 24,
|
||||
state = "GD",
|
||||
myGrid = "OM60",
|
||||
myGrids = setOf("OM60", "OM50")
|
||||
)
|
||||
val record = qso.toConfirmedRecord("ba7opf")
|
||||
assertEquals("BH6RJD", record.theirCallsign)
|
||||
assertEquals("BA7OPF", record.myCallsign)
|
||||
assertEquals("OM60", record.myGrid)
|
||||
assertEquals(listOf("OM60", "OM50"), record.vuccGrids)
|
||||
assertTrue(record.lotwConfirmed)
|
||||
assertTrue(record.isSatellite)
|
||||
assertEquals("70CM", record.band)
|
||||
assertEquals("2M", record.rxBand)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun frequencyBand_edges() {
|
||||
assertEquals("2M", frequencyBand(144_000_000L))
|
||||
assertEquals("70CM", frequencyBand(450_000_000L))
|
||||
assertEquals("10M", frequencyBand(29_000_000L))
|
||||
assertEquals("", frequencyBand(null))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun displayMode_prefersSubmode() {
|
||||
val ft4 = QsoRecord(startUtcMillis = 0, theirCallsign = "x", myCallsign = "y", mode = "MFSK", submode = "FT4")
|
||||
assertEquals("FT4", ft4.displayMode)
|
||||
assertEquals("FM", local.displayMode)
|
||||
}
|
||||
}
|
||||
+2
@@ -285,6 +285,8 @@ class LoTWGridSyncTest {
|
||||
override fun updateDataSourcesStatus(status: Map<String, Int>) = TODO()
|
||||
override fun 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()
|
||||
|
||||
Reference in new issue
Block a user