Compare commits

...
Author SHA1 Message Date
atsunatsu 5c12d64824 feat(settings): LoTW 省份选择入口常驻 + 导入前预解析填充选项
- 省份入口从第一帧就在台站区:DXCC 未知时显示「导入证书后可选」占位,不可展开
- 选中 p12 后 400ms 防抖静默预解析(previewCertificate,只读不落库,密码副本用后清零),
  点「确认导入」之前下拉即可选省份;错密码/非 p12 静默保持占位,导入报错行为不变
- 修复导入成功不刷新 stationMeta:此前导入完成后省份下拉要关掉重开对话框才出现
- 无省份字段的国家(德国等)维持隐藏;后缀 seq 防慢预览覆盖新结果
- 新增 LoTWUploadRepositoryPreviewTest 2 例(TQSL 风格空密码夹具 test_tqsl_empty.p12,
  实测解析 DXCC 318、不落库、错密码映射 CERTIFICATE_PASSWORD);en/zh 文案
2026-10-05 18:05:42 +08:00
atsunatsu 881ca442ff fix(map): DXCC 资产重建——俄罗斯拆欧/亚、提取子实体、英国四实体、德巴改现行码
- dxcc.json(176→189区): 沿乌拉尔山/河(OSM relation 21368163 + NE 乌拉尔河)
  把整只俄罗斯切成 European Russia(54)/Asiatic Russia(15)——修复亚洲俄罗斯
  永不填绿; 从父国环提取 Alaska/Hawaii/East Malaysia/French Guiana/Corsica/
  Crete/Sardinia/Kaliningrad/Franz Josef 9 子实体; 英国拆 England(223)/
  Scotland(279)/Wales(294)/N.Ireland(265) 四实体; Germany 81→230、
  Palestine 196→510 改为 ADIF 现行码(81/196 已删除, LoTW 导出现行码永不匹配)
- 新增 DxccAssetTest 5 例: 必备码齐全/ADIF名/环完整性/26城侧归属/禁用
  ADIF 3.1.7 全部62个已删除码; build.gradle 补 testImplementation org.json
- THIRD_PARTY_NOTICES: 补 OSM(ODbL) 与 Natural Earth(公共领域) 数据出处
2026-10-05 17:13:50 +08:00
atsunatsu f35d78186c fix(map): 多格台址覆盖单格台址时通联计入单格统计(方案A单向覆盖)
- scopedToStation 由 key 精确匹配改为网格集合包含(记录 MY 网格集 ⊇ 选中台址网格集):
  选中 OL62 时 OL62/63(四格点同理)的通联一并计入;反向不泄漏(选中宽台址不见单格记录)
- stationGroups 每组 worked 改经 scopedToStation 推导——地图填绿/VUCC 计数/选择器计数/
  首通标签/点格子弹窗详情五出口单源;分组条目仍只列实际拥有的台址
- GridQsoStationScopeTest 改写精确匹配断言 + 新增覆盖/不泄漏/集合包含 3 例(共 14),
  全量 test 通过
2026-10-05 15:06:19 +08:00
atsunatsu 09539ff13a feat(wavelog): 二选一互斥模式 + p12 空密码导入修复(4.4.8-ba7opf.9.4, 569)
- LoTW/Wavelog 互斥:isReady 即 Wavelog 模式,LoTW 卡置灰+说明,上传按模式分流(雷达页/日志本),自动 LoTW 同步门控;配置切换弹警告、可清除配置
- 同步重做:get_contacts_adif 每台址游标,增量/全量双按钮,只增不删,URL 变更增量降级全量;同步刷新日志本+地图 workedGrids/gridQsos/roamedGrids
- 日志本台址选择器(全部/各台址)+ Wavelog 上传预览弹窗;Qso 增 wavelogStation,DB v4→v5
- 删旧 WavelogRepository/wavelogSettings 链路,换 WavelogSyncRepository + WavelogSync 纯函数
- p12 修复:导入按钮放开空密码(isEnabled=!busy)+ 密码框留空提示;Pkcs12ReaderTest +4 用例
- 单测六模块全绿;版本 4.4.8-ba7opf.9.4 (569)
2026-10-05 14:57:46 +08:00
atsunatsu 1fa7dd2d88 merge: 合入 fork 9.2 发布线(AMSAT 冲突修复并入;版本保持 9.2/567)
# Conflicts:
#	gradle/libs.versions.toml
2026-10-05 03:56:52 +08:00
atsunatsu f0fc788ebe build: candidate version 4.4.8-ba7opf.8.2 (565) 2026-10-05 03:54:26 +08:00
atsunatsu 6f83740983 feat(amsat): 时隙判定照官网链(严格多数/并列=冲突/crew 紫)+ 弹窗「冲突」徽标
- 旧逻辑取槽内最新一条报告取色 → 官网图例「Conflicting」永不显示
- 判定链照 AMSAT 官方源码 index.php:crew>0 取紫;严格多数取色;并列/无多数=「冲突」
- SatSlot 新增 isConflicted 字段;弹窗冲突槽内全部报告加「冲突/Conflicting」小徽标(随系统语言)
- 天格子外观不变(B 未采纳);AmSatRepositoryTest +4 用例(共 9 例)
2026-10-05 03:54:22 +08:00
56 changed files with 2315 additions and 510 deletions

No files matched your search

+21
View File
@@ -44,3 +44,24 @@ SOFTWARE.
- License: Apache-2.0
- Used for: `core/presentation/src/main/res/drawable/ic_grid_finder.xml`
(crosshairs-gps icon). Attribution is also noted in the icon file header.
## OpenStreetMap (award boundary geometry)
- Source: https://www.openstreetmap.org — relations `Ural Mountains` (21368163),
`England` (58447), `Scotland` (58446), `Wales` (58437),
`Northern Ireland` (156393)
- License: Open Database License (ODbL) 1.0 —
https://opendatacommons.org/licenses/odbl/1-0/
- Used for: the Europe/Asia split line of European/Asiatic Russia and the
England/Scotland/Wales/Northern Ireland partitions in
`feature/map/src/main/assets/awards/dxcc.json` (derived geometry).
Map tiles displayed in the app are © OpenStreetMap contributors and carry
their own in-app attribution.
## Natural Earth
- Source: https://www.naturalearthdata.com (1:110m countries, 1:10m rivers)
- License: Public domain — https://www.naturalearthdata.com/about/terms-of-use/
- Used for: the country/region polygons of the award boundary assets under
`feature/map/src/main/assets/awards/`, and the Ural river centerline used
for the European/Asiatic Russia split.
@@ -105,6 +105,9 @@ class MainApplication : Application(), IContainerProvider {
*/
private suspend fun checkLoTWAutoSync(timeNow: Long = System.currentTimeMillis()) {
val settingsRepo = container.settingsRepo
// One-of-two rule: while Wavelog is configured the app uploads/syncs through
// Wavelog only — the automatic LoTW sync stays off.
if (settingsRepo.wavelogUploadSettings.value.isReady) return
val lotwSettings = settingsRepo.lotwSettings.value
if (!shouldAutoSyncLoTW(
isConfigured = lotwSettings.isConfigured,
@@ -48,6 +48,6 @@ interface QsoDao {
@Query("UPDATE qso_records SET lotwUploaded = 1 WHERE id IN (:ids)")
suspend fun markUploaded(ids: List<Long>)
@Query("UPDATE qso_records SET wavelogUploaded = 1 WHERE id IN (:ids)")
suspend fun markWavelogUploaded(ids: List<Long>)
@Query("UPDATE qso_records SET wavelogUploaded = 1, wavelogStation = CASE WHEN :stationId = '' THEN wavelogStation ELSE :stationId END WHERE id IN (:ids)")
suspend fun markWavelogUploaded(ids: List<Long>, stationId: String)
}
@@ -20,7 +20,7 @@ import com.rtbishop.look4sat.core.data.database.entity.QsoEntity
* upload receipts). Kept apart from [Look4SatDb] so existing installations
* never need a migration of the satellite database.
*/
@Database(entities = [QsoEntity::class], version = 4, exportSchema = false)
@Database(entities = [QsoEntity::class], version = 5, exportSchema = false)
abstract class QsoDatabase : RoomDatabase() {
abstract fun qsoDao(): QsoDao
}
@@ -45,3 +45,10 @@ val MIGRATION_QSO_3_4 = object : Migration(3, 4) {
db.execSQL("ALTER TABLE qso_records ADD COLUMN wavelogUploaded INTEGER NOT NULL DEFAULT 0")
}
}
/** v4 → v5: remember which Wavelog station profile (台址) a QSO belongs to. */
val MIGRATION_QSO_4_5 = object : Migration(4, 5) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE qso_records ADD COLUMN wavelogStation TEXT NOT NULL DEFAULT ''")
}
}
@@ -45,6 +45,7 @@ data class QsoEntity(
@ColumnInfo(defaultValue = "0") val lotwConfirmed: Boolean = false,
@ColumnInfo(defaultValue = "0") val lotwUploaded: Boolean = false,
@ColumnInfo(defaultValue = "0") val wavelogUploaded: Boolean = false,
@ColumnInfo(defaultValue = "''") val wavelogStation: String = "",
@ColumnInfo(defaultValue = "0") val lotwReceived: Boolean = false,
@ColumnInfo(defaultValue = "''") val lotwQslDate: String = "",
@ColumnInfo(defaultValue = "''") val vuccGrids: String = "",
@@ -32,6 +32,7 @@ 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.MIGRATION_QSO_2_3
import com.rtbishop.look4sat.core.data.database.MIGRATION_QSO_3_4
import com.rtbishop.look4sat.core.data.database.MIGRATION_QSO_4_5
import com.rtbishop.look4sat.core.data.database.QsoDatabase
import com.rtbishop.look4sat.core.data.framework.BluetoothReporter
import com.rtbishop.look4sat.core.data.framework.Ft817Controller
@@ -49,8 +50,9 @@ import com.rtbishop.look4sat.core.data.repository.SettingsRepo
import com.rtbishop.look4sat.core.data.repository.UpdateRepository
import com.rtbishop.look4sat.core.data.repository.LoTWRepository
import com.rtbishop.look4sat.core.data.repository.LocationRepo
import com.rtbishop.look4sat.core.data.repository.WavelogRepository
import com.rtbishop.look4sat.core.data.repository.WavelogUploadRepository
import com.rtbishop.look4sat.core.data.repository.WavelogSyncRepository
import com.rtbishop.look4sat.core.data.source.LocalSource
import com.rtbishop.look4sat.core.data.source.RemoteSource
import com.rtbishop.look4sat.core.data.usecase.AddToCalendar
@@ -71,8 +73,9 @@ import com.rtbishop.look4sat.core.domain.repository.ISatelliteRepo
import com.rtbishop.look4sat.core.domain.repository.ISelectionRepo
import com.rtbishop.look4sat.core.domain.repository.ISensorsRepo
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
import com.rtbishop.look4sat.core.domain.repository.IWavelogRepository
import com.rtbishop.look4sat.core.domain.repository.IWavelogUploadRepository
import com.rtbishop.look4sat.core.domain.repository.IWavelogSyncRepository
import com.rtbishop.look4sat.core.domain.repository.MutualPassData
import com.rtbishop.look4sat.core.domain.source.ILocalSource
import com.rtbishop.look4sat.core.domain.source.IRemoteSource
@@ -103,12 +106,14 @@ class MainContainer(private val context: Context) : IMainContainer {
override val databaseRepo = provideDatabaseRepo()
override val amSatRepo by lazy { AmSatRepository(remoteSource, appScope) }
override val updateRepo by lazy { UpdateRepository(remoteSource) }
override val wavelogRepo: IWavelogRepository by lazy { WavelogRepository() }
override val wavelogUploadRepository: IWavelogUploadRepository by lazy { WavelogUploadRepository() }
override val wavelogSyncRepository: IWavelogSyncRepository by lazy { WavelogSyncRepository() }
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, MIGRATION_QSO_2_3, MIGRATION_QSO_3_4)
.addMigrations(MIGRATION_QSO_4_5)
.build()
QsoRepository(database.qsoDao(), Dispatchers.IO)
}
@@ -114,6 +114,19 @@ class LoTWUploadRepository internal constructor(
}
}
override suspend fun previewCertificate(data: ByteArray, password: CharArray): LoTWCertificate = withContext(Dispatchers.IO) {
mutex.withLock {
try {
if (data.isEmpty() || data.size > MAX_CERTIFICATE_BYTES) fail(LoTWProblem.CERTIFICATE_INVALID)
LoTWKeyMaterial.read(data, password, now()).info
} finally {
// Never keep the operator's password; the p12 bytes stay the caller's
// own copy (the dialog re-reads the file for every preview attempt).
password.fill('\u0000')
}
}
}
override suspend fun saveCertificatePassword(password: CharArray): LoTWCertificate = withContext(Dispatchers.IO) {
mutex.withLock {
val stored = storage.read("certificate") ?: fail(LoTWProblem.CERTIFICATE_MISSING)
@@ -52,6 +52,10 @@ private data class ApiReport(
val reportedTimeUtcSec: Long
)
/** One slot's verdict: ARGB colour, the count the page prints, and whether the slot
* has no strict majority (= the page's "Conflicting reports" state). */
private data class SlotStatus(val color: Long, val count: Int, val conflicted: Boolean)
/** AMSAT status repository using RemoteSource (Clean Architecture: data layer handles HTTP). */
class AmSatRepository(
private val remoteSource: IRemoteSource,
@@ -214,7 +218,8 @@ class AmSatRepository(
}
}
/** Build one SatStatus (3 days x 12 slots) per catalog satellite, slotting reports by age. */
/** Build one SatStatus (3 days x 12 slots) per catalog satellite, slotting reports by age.
* Each slot's colour/count follows the AMSAT status page rule — see [slotStatusOf]. */
private fun buildStatuses(names: List<String>, reports: List<ApiReport>, nowSec: Long): List<SatStatus> {
val byName = reports.groupBy { it.name }
val utc = Calendar.getInstance(TimeZone.getTimeZone("UTC"))
@@ -230,11 +235,12 @@ class AmSatRepository(
if (inSlot.isEmpty()) {
SatSlot(statusColor = NO_REPORT_GRAY, count = 0)
} else {
val newest = inSlot.maxByOrNull { it.reportedTimeUtcSec }!!
val status = slotStatusOf(inSlot)
SatSlot(
statusColor = statusColorOf(newest.report),
count = inSlot.size,
reportIds = inSlot.map { it.id }
statusColor = status.color,
count = status.count,
reportIds = inSlot.map { it.id },
isConflicted = status.conflicted
)
}
}
@@ -287,12 +293,28 @@ class AmSatRepository(
)
}
/** Map status text to color value (for UI rendering). */
private fun statusColorOf(report: String): Long = when (report.lowercase()) {
"heard", "crew active" -> ACTIVE_BLUE
"telemetry only" -> TLM_ORANGE
"not heard" -> NOT_HEARD_PINK
else -> CONFLICT_DEEP_ORANGE
/**
* The AMSAT status page's per-block rule, mirrored from the official page source
* (gitlab.amsat.org/open-source/satellite-status, index.php):
* - any Crew Active report wins: purple, count = crew + heard;
* - otherwise one status must be a STRICT majority to take its colour, and the
* count shown is the majority group's count (not the block total);
* - any tie of the remaining statuses is the "Conflicting reports" state;
* - a block whose reports all carry an unknown status stays blank.
*/
private fun slotStatusOf(inSlot: List<ApiReport>): SlotStatus {
val heard = inSlot.count { it.report.equals("Heard", ignoreCase = true) }
val notHeard = inSlot.count { it.report.equals("Not Heard", ignoreCase = true) }
val telemetry = inSlot.count { it.report.equals("Telemetry Only", ignoreCase = true) }
val crew = inSlot.count { it.report.equals("Crew Active", ignoreCase = true) }
return when {
crew > 0 -> SlotStatus(CREW_PURPLE, crew + heard, conflicted = false)
heard > notHeard && heard > telemetry -> SlotStatus(ACTIVE_BLUE, heard, conflicted = false)
notHeard > heard && notHeard > telemetry -> SlotStatus(NOT_HEARD_PINK, notHeard, conflicted = false)
telemetry > notHeard && telemetry > heard -> SlotStatus(TLM_ORANGE, telemetry, conflicted = false)
telemetry + notHeard + heard >= 1 -> SlotStatus(CONFLICT_DEEP_ORANGE, inSlot.size, conflicted = true)
else -> SlotStatus(NO_REPORT_GRAY, inSlot.size, conflicted = false)
}
}
companion object {
@@ -301,6 +323,7 @@ class AmSatRepository(
private const val TLM_ORANGE = 0xFFFFB000
private const val NOT_HEARD_PINK = 0xFFDC267F
private const val CONFLICT_DEEP_ORANGE = 0xFFFE6100
private const val CREW_PURPLE = 0xFF785EF0
private const val NO_REPORT_GRAY = 0xFFC0C0C0
}
}
@@ -59,6 +59,7 @@ class QsoRepository(
id = 0L,
lotwUploaded = false, lotwConfirmed = false, lotwReceived = false,
wavelogUploaded = false,
wavelogStation = "",
lotwQslDate = "", vuccGrids = emptyList(), theirVuccGrids = emptyList(),
dxcc = null, country = "", cqZone = null, region = ""
).toEntity())
@@ -95,9 +96,9 @@ class QsoRepository(
if (stamped.isNotEmpty()) dao.saveBatch(stamped.map { it.toEntity() })
}
override suspend fun markWavelogUploaded(ids: List<Long>) = withContext(dispatcher) {
override suspend fun markWavelogUploaded(ids: List<Long>, stationId: String) = withContext(dispatcher) {
if (ids.isEmpty()) return@withContext
importMutex.withLock { dao.markWavelogUploaded(ids) }
importMutex.withLock { dao.markWavelogUploaded(ids, stationId) }
}
override suspend fun exportAdi(ids: Set<Long>?, includeIncomplete: Boolean): String = withContext(dispatcher) {
@@ -124,6 +125,19 @@ class QsoRepository(
importMutex.withLock { mergeRecords(records, fromLoTW = true) }
}
/**
* Merges QSO records pulled from Wavelog (the sync/download direction).
*
* Matching uses the same [stableQsoKey] identity as every other import, so a record
* already recorded locally (typically uploaded from this app) is not duplicated — it
* simply learns its Wavelog station profile and, where the local row is missing them,
* the opposite-station fields the server kept. Fresh records are inserted already
* marked as uploaded, so a later upload never sends them straight back.
*/
override suspend fun mergeWavelog(records: List<QsoRecord>): AdifImportResult = withContext(dispatcher) {
importMutex.withLock { mergeWavelogRecords(records) }
}
override suspend fun consolidateConfirmations(): Int = withContext(dispatcher) {
importMutex.withLock {
val working = dao.getAll().map(QsoEntity::toDomain).toMutableList()
@@ -216,6 +230,56 @@ class QsoRepository(
return AdifImportResult(imported, skipped, updated)
}
private suspend fun mergeWavelogRecords(records: List<QsoRecord>): AdifImportResult {
val working = dao.getAll().map(QsoEntity::toDomain).toMutableList()
// Rows imported under a tracking-source name join the ARRL name first, so the
// lookup below sees one identity for both sides of a match.
val renames = rewriteOfficialNames(working)
val indexByKey = HashMap<String, Int>(working.size)
working.indices.forEach { index -> indexByKey.putIfAbsent(stableQsoKey(working[index]), index) }
val changes = linkedMapOf<Int, QsoRecord>().apply { putAll(renames) }
var imported = 0
var updated = renames.size
var skipped = 0
val seenKeys = hashSetOf<String>()
records.forEach { remote ->
val key = stableQsoKey(remote)
if (!seenKeys.add(key)) {
skipped++
return@forEach
}
val index = indexByKey[key]
if (index == null) {
val added = remote.copy(
id = 0L,
wavelogUploaded = true,
satelliteName = officialSatelliteName(remote.satelliteName)
)
changes[working.size] = added
indexByKey[stableQsoKey(added)] = working.size
working += added
imported++
} else {
val previous = working[index]
val merged = previous.copy(
wavelogUploaded = true,
wavelogStation = remote.wavelogStation.ifBlank { previous.wavelogStation },
theirGrid = previous.theirGrid.ifBlank { remote.theirGrid },
theirVuccGrids = previous.theirVuccGrids.ifEmpty { remote.theirVuccGrids }
)
if (merged != previous) {
working[index] = merged
changes[index] = merged
updated++
} else {
skipped++
}
}
}
dao.saveBatch(changes.values.map { it.toEntity() })
return AdifImportResult(imported, skipped, updated)
}
/** Result of folding already-split rows back together. */
private data class Consolidation(
/** index in the stored list -> the record to save (local row + its confirmation). */
@@ -294,6 +358,7 @@ private fun QsoEntity.toDomain() = QsoRecord(
lotwConfirmed = lotwConfirmed,
lotwUploaded = lotwUploaded,
wavelogUploaded = wavelogUploaded,
wavelogStation = wavelogStation,
lotwReceived = lotwReceived,
lotwQslDate = lotwQslDate,
vuccGrids = vuccGrids.split(',').filter(String::isNotBlank),
@@ -332,6 +397,7 @@ private fun QsoRecord.toEntity() = QsoEntity(
lotwConfirmed = lotwConfirmed,
lotwUploaded = lotwUploaded,
wavelogUploaded = wavelogUploaded,
wavelogStation = wavelogStation.trim(),
lotwReceived = lotwReceived,
lotwQslDate = lotwQslDate,
vuccGrids = vuccGrids.joinToString(","),
@@ -30,7 +30,7 @@ import com.rtbishop.look4sat.core.domain.model.OtherSettings
import com.rtbishop.look4sat.core.domain.model.PassesSettings
import com.rtbishop.look4sat.core.domain.model.RCSettings
import com.rtbishop.look4sat.core.domain.model.RadioControlSettings
import com.rtbishop.look4sat.core.domain.model.WavelogSettings
import com.rtbishop.look4sat.core.domain.model.WavelogUploadSettings
import com.rtbishop.look4sat.core.domain.predict.GeoPos
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
@@ -148,27 +148,9 @@ class SettingsRepo(
}
//endregion
//region # Wavelog worked-grids settings
private val keyWavelogUrl = "wavelogUrl"
private val keyWavelogToken = "wavelogToken"
//region # Worked-grid data (written by the LoTW / Wavelog syncs)
private val keyWorkedGrids = "workedGrids"
private val _wavelogSettings = MutableStateFlow(getWavelogSettings())
override val wavelogSettings: StateFlow<WavelogSettings> = _wavelogSettings
override fun updateWavelogSettings(settings: WavelogSettings) {
preferences.edit {
putString(keyWavelogUrl, settings.url.trim())
putString(keyWavelogToken, settings.token.trim())
}
_wavelogSettings.value = settings.copy(url = settings.url.trim(), token = settings.token.trim())
}
private fun getWavelogSettings(): WavelogSettings = WavelogSettings(
url = preferences.getString(keyWavelogUrl, null).orEmpty(),
token = preferences.getString(keyWavelogToken, null).orEmpty()
)
override fun getWorkedGrids(): Set<String> {
val json = preferences.getString(keyWorkedGrids, null).orEmpty()
if (json.isBlank()) return emptySet()
@@ -421,6 +403,78 @@ class SettingsRepo(
)
//endregion
//region # Wavelog sync bookkeeping
private val keyWavelogStations = "wavelogStations"
private val keyWavelogSyncCursors = "wavelogSyncCursors"
private val keyWavelogSyncUrl = "wavelogSyncUrl"
private val keyLastWavelogSyncEpochMs = "wavelogLastSyncEpochMs"
override fun getWavelogStations(): List<com.rtbishop.look4sat.core.domain.repository.WavelogStationInfo> {
val json = preferences.getString(keyWavelogStations, null).orEmpty()
if (json.isBlank()) return emptyList()
return try {
val array = org.json.JSONArray(json)
(0 until array.length()).mapNotNull { i ->
val o = array.optJSONObject(i) ?: return@mapNotNull null
val id = o.optString("id").ifBlank { return@mapNotNull null }
com.rtbishop.look4sat.core.domain.repository.WavelogStationInfo(
id = id,
name = o.optString("name"),
callsign = o.optString("call"),
grid = o.optString("grid"),
active = o.optBoolean("active", true)
)
}
} catch (_: Exception) {
emptyList()
}
}
override fun setWavelogStations(stations: List<com.rtbishop.look4sat.core.domain.repository.WavelogStationInfo>) {
val array = org.json.JSONArray()
stations.forEach { station ->
array.put(
org.json.JSONObject()
.put("id", station.id)
.put("name", station.name)
.put("call", station.callsign)
.put("grid", station.grid)
.put("active", station.active)
)
}
preferences.edit { putString(keyWavelogStations, array.toString()) }
}
override fun getWavelogSyncCursors(): Map<String, Long> {
val json = preferences.getString(keyWavelogSyncCursors, null).orEmpty()
if (json.isBlank()) return emptyMap()
return try {
val root = org.json.JSONObject(json)
root.keys().asSequence().associateWith { key -> root.optLong(key, 0L) }
} catch (_: Exception) {
emptyMap()
}
}
override fun setWavelogSyncCursors(cursors: Map<String, Long>) {
val root = org.json.JSONObject()
cursors.forEach { (stationId, cursor) -> root.put(stationId, cursor) }
preferences.edit { putString(keyWavelogSyncCursors, root.toString()) }
}
override fun getWavelogSyncUrl(): String =
preferences.getString(keyWavelogSyncUrl, null).orEmpty()
override fun setWavelogSyncUrl(url: String) =
preferences.edit { putString(keyWavelogSyncUrl, url) }
override fun getLastWavelogSyncEpochMs(): Long =
preferences.getLong(keyLastWavelogSyncEpochMs, 0L)
override fun setLastWavelogSyncEpochMs(value: Long) =
preferences.edit { putLong(keyLastWavelogSyncEpochMs, value) }
//endregion
//region # Transceivers settings
private val _passesSettings = MutableStateFlow(getPassesSettings())
override val passesSettings: StateFlow<PassesSettings> = _passesSettings
@@ -1,97 +0,0 @@
/*
* 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.repository
import com.rtbishop.look4sat.core.domain.repository.IWavelogRepository
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.json.JSONArray
import org.json.JSONObject
/**
* Fetches the list of worked gridsquares from a self-hosted Wavelog instance.
*
* Uses the Wavelog v1 API endpoint (verified against Wavelog 3.0.2):
* POST {base}/index.php/api/logbook_get_worked_grids
* Body: {"key": "<api key>", "logbook_id": "<logbook id>"}
* Response 201: JSON array of 4-char gridsquares, e.g. ["JN47","JO30"]
* (see Wavelog Api.php logbook_get_worked_grids / Api_model::get_grids_worked_in_logbook)
*
* The token field carries "<api_key>:<logbook_id>" so one credential line
* configures both values (the dialog explains this to the user).
*/
class WavelogRepository : IWavelogRepository {
override suspend fun fetchWorkedGrids(url: String, token: String): Set<String>? = withContext(Dispatchers.IO) {
val parts = token.trim().split(":")
val apiKey = parts.getOrNull(0)?.trim().orEmpty()
val logbookId = parts.getOrNull(1)?.trim().orEmpty()
if (url.isBlank() || apiKey.isBlank() || logbookId.isBlank()) return@withContext null
val base = url.trim().trimEnd('/')
val requestUrl = "$base/index.php/api/logbook_get_worked_grids"
try {
val connection = java.net.URL(requestUrl).openConnection() as java.net.HttpURLConnection
connection.connectTimeout = 10_000
connection.readTimeout = 15_000
connection.requestMethod = "POST"
connection.doOutput = true
connection.setRequestProperty("Content-Type", "application/json")
val body = JSONObject().apply {
put("key", apiKey)
put("logbook_id", logbookId)
}.toString().toByteArray()
connection.outputStream.use { it.write(body) }
val code = connection.responseCode
if (code !in 200..299) {
connection.disconnect()
return@withContext null
}
val response = connection.inputStream.bufferedReader().use { it.readText() }
connection.disconnect()
parseWorkedGrids(response)
} catch (e: Exception) {
println("WavelogRepository fetch failure: $e")
null
}
}
internal fun parseWorkedGrids(body: String): Set<String>? {
return try {
val array = when {
body.trimStart().startsWith("[") -> JSONArray(body)
else -> {
// Tolerate {"grids": [...]} wrappers too
JSONObject(body).optJSONArray("grids") ?: return null
}
}
val result = mutableSetOf<String>()
for (i in 0 until array.length()) {
val grid = array.optString(i, "").trim().uppercase()
// Keep only well-formed 4-character Maidenhead squares (e.g. OL62)
if (grid.length == 4 && grid[0] in 'A'..'R' && grid[1] in 'A'..'R'
&& grid[2] in '0'..'9' && grid[3] in '0'..'9'
) {
result.add(grid)
}
}
result
} catch (_: Exception) {
null
}
}
}
@@ -0,0 +1,142 @@
/*
* Look4Sat-BA7OPF. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2026 BA7OPF.
* Based on Look4Sat by 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.repository
import com.rtbishop.look4sat.core.domain.logbook.AdifCodec
import com.rtbishop.look4sat.core.domain.logbook.QsoRecord
import com.rtbishop.look4sat.core.domain.repository.IWavelogSyncRepository
import com.rtbishop.look4sat.core.domain.repository.WavelogStationInfo
import com.rtbishop.look4sat.core.domain.repository.WavelogSyncFetch
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import org.json.JSONArray
import org.json.JSONObject
import java.util.concurrent.CancellationException
import java.util.concurrent.TimeUnit
/**
* Pulls QSO records from a self-hosted Wavelog via the v1 API.
*
* Verified against Wavelog 3.0.2 (`Api.php::get_contacts_adif`):
* - `POST {base}/index.php/api/get_contacts_adif`
* with `{"key", "station_id": [id...], "fetchfromid": <last id>, "limit"}`.
* - Responds `{"status":"successful","lastfetchedid":<id>,"exported_qsos":N,"adif":"..."}`;
* only rows whose internal id is greater than `fetchfromid` are exported, so the cursor
* makes the pull incremental. Each station keeps its own cursor: a station profile added
* later starts from 0 and pulls its full history without touching the others'.
* - The export is a full ADIF document; decoding reuses [AdifCodec].
*/
class WavelogSyncRepository internal constructor(
client: OkHttpClient = OkHttpClient()
) : IWavelogSyncRepository {
private val client = client.newBuilder()
.connectTimeout(15, TimeUnit.SECONDS)
.readTimeout(120, TimeUnit.SECONDS)
.callTimeout(180, TimeUnit.SECONDS)
.retryOnConnectionFailure(false)
.build()
private val jsonType = "application/json; charset=utf-8".toMediaType()
override suspend fun fetchNewRecords(
url: String,
apiKey: String,
stations: List<WavelogStationInfo>,
cursors: Map<String, Long>,
full: Boolean
): WavelogSyncFetch? = withContext(Dispatchers.IO) {
val base = url.trim().trimEnd('/')
val key = apiKey.trim()
if (base.isBlank() || key.isBlank() || stations.isEmpty()) return@withContext null
val records = mutableListOf<QsoRecord>()
val newCursors = mutableMapOf<String, Long>()
val failed = mutableListOf<String>()
// Every station profile is fetched (including ones the server marks inactive:
// their historical QSOs still belong to the account and the logbook shows them).
stations.forEach { station ->
var cursor = if (full) 0L else cursors[station.id] ?: 0L
var advanced = false
var stationFailed = false
try {
var guard = 0
while (guard++ < MAX_CHUNKS_PER_STATION) {
val page = fetchPage(base, key, station.id, cursor)
if (page == null) {
stationFailed = true
break
}
cursor = maxOf(cursor, page.cursor)
advanced = true
page.records.forEach { records += it.copy(wavelogStation = station.id) }
if (page.exported < CHUNK_LIMIT) break
}
} catch (e: CancellationException) {
throw e
} catch (_: Exception) {
stationFailed = true
}
// A partially fetched station keeps the cursor of its last good page, so the
// next sync resumes exactly where this one stopped — nothing is fetched twice
// and nothing is skipped.
if (advanced) newCursors[station.id] = cursor
if (stationFailed) failed += station.id
}
WavelogSyncFetch(records, newCursors, failed)
}
internal data class Page(val records: List<QsoRecord>, val cursor: Long, val exported: Int)
private fun fetchPage(base: String, key: String, stationId: String, fromId: Long): Page? {
val body = JSONObject().apply {
put("key", key)
put("station_id", JSONArray().put(stationId))
put("fetchfromid", fromId)
put("limit", CHUNK_LIMIT)
}.toString()
val request = Request.Builder()
.url("$base/index.php/api/get_contacts_adif")
.post(body.toRequestBody(jsonType))
.build()
return client.newCall(request).execute().use { response ->
if (!response.isSuccessful) return@use null
parsePage(response.body.string())
}
}
internal fun parsePage(text: String): Page? {
val json = runCatching { JSONObject(text) }.getOrNull() ?: return null
if (json.optString("status") != "successful") return null
if (!json.has("lastfetchedid")) return null
val cursor = json.optLong("lastfetchedid", 0L)
val exported = json.optInt("exported_qsos", 0)
val adif = json.optString("adif")
if (adif.isBlank()) return Page(emptyList(), cursor, exported)
return Page(AdifCodec.decode(adif), cursor, exported)
}
private companion object {
const val CHUNK_LIMIT = 5000
const val MAX_CHUNKS_PER_STATION = 200
}
}
@@ -0,0 +1,82 @@
/*
* 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 com.rtbishop.look4sat.core.domain.repository.LoTWOperationException
import com.rtbishop.look4sat.core.domain.repository.LoTWProblem
import kotlinx.coroutines.runBlocking
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertThrows
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* previewCertificate parses a .p12 without touching storage — just enough to
* learn the certificate's DXCC entity, so the settings dialog can offer the
* entity's Province/State dropdown before the operator confirms the import.
*
* Fixture test_tqsl_empty.p12 mirrors a modern TQSL export: PBES2/AES-256,
* empty password, TQSL subject attribute 1.3.6.1.4.1.12348.1.1 = BA7OPF and
* extensions .2/.3/.4 = first/last QSO date, DXCC 318 (China).
*/
class LoTWUploadRepositoryPreviewTest {
private class MemStorage : LoTWStorage {
val files = mutableMapOf<String, ByteArray>()
override fun read(name: String): ByteArray? = files[name]
override fun write(name: String, data: ByteArray) { files[name] = data.copyOf() }
override fun delete(name: String) { files.remove(name) }
}
private fun fixture(name: String): ByteArray =
javaClass.classLoader!!.getResourceAsStream(name)!!.use { it.readBytes() }
private fun repository(storage: MemStorage) = LoTWUploadRepository(
storage, { error("station config not needed") }, now = { System.currentTimeMillis() }
)
@Test
fun `preview parses TQSL-style certificate without persisting it`() {
runBlocking {
val storage = MemStorage()
val repo = repository(storage)
val info = repo.previewCertificate(fixture("test_tqsl_empty.p12"), charArrayOf())
assertEquals("BA7OPF", info.callsign)
assertEquals(318, info.dxcc)
assertEquals("2026-01-01", info.firstQsoDate)
assertTrue("nothing written", storage.files.isEmpty())
assertNull("no certificate stored", repo.certificate())
}
}
@Test
fun `wrong password fails as certificate password and wipes the attempt`() {
runBlocking {
val storage = MemStorage()
val repo = repository(storage)
val password = "definitely-wrong".toCharArray()
val error = assertThrows(LoTWOperationException::class.java) {
runBlocking { repo.previewCertificate(fixture("test_pbes2.p12"), password) }
}
assertEquals(LoTWProblem.CERTIFICATE_PASSWORD, error.reason)
assertTrue("password wiped", password.all { it == '\u0000' })
assertTrue("nothing written", storage.files.isEmpty())
}
}
}
@@ -131,4 +131,40 @@ class Pkcs12ReaderTest {
Pkcs12Reader.read("not a p12 file at all".toByteArray(), "x".toCharArray())
}
}
@Test
fun readsEmptyPasswordCertpbeNoneLayout() {
// Certificates exported without a password must import with an empty char
// array — the reader must decrypt, not demand a password.
val (key, cert) = Pkcs12Reader.read(fixture("test_pbes2_empty.p12"), charArrayOf())
assertEquals("RSA", key.algorithm)
assertTrue("key/cert pair", keyMatches(key, cert))
}
@Test
fun readsEmptyPasswordEncryptedCertLayout() {
val (key, cert) = Pkcs12Reader.read(fixture("test_pbes2_empty_enc.p12"), charArrayOf())
assertEquals("RSA", key.algorithm)
assertTrue("key/cert pair", keyMatches(key, cert))
}
@Test
fun emptyPasswordOnProtectedFileFailsAsDecrypt() {
// Blank input on a password-protected file must fail as a decryption error
// (mapped to "password" upstream) — never silently succeed.
val e = assertThrows(Exception::class.java) {
Pkcs12Reader.read(fixture(), charArrayOf())
}
assertTrue("BadPadding", e is javax.crypto.BadPaddingException)
}
@Test
fun platformKeystoreAcceptsEmptyPassword() {
// LoTWKeyMaterial first tries the platform PKCS12 KeyStore: verify the
// empty-password load/getKey call convention this fix relies on.
val ks = java.security.KeyStore.getInstance("PKCS12")
ks.load(fixture("test_pbes2_empty.p12").inputStream(), charArrayOf())
val alias = ks.aliases().nextElement()
assertNotNull(ks.getKey(alias, charArrayOf()))
}
}
@@ -18,6 +18,7 @@
*/
package com.rtbishop.look4sat.core.data.repository
import com.rtbishop.look4sat.core.domain.model.SatSlot
import com.rtbishop.look4sat.core.domain.model.SatStatus
import com.rtbishop.look4sat.core.domain.model.SatStatusPage
import com.rtbishop.look4sat.core.domain.source.IRemoteSource
@@ -143,6 +144,123 @@ class AmSatRepositoryTest {
val page = repository.fetchStatus()
assertEquals(4, page?.statuses?.single()?.days?.get(0)?.streakCount)
}
@Test
fun buildStatusesStrictMajorityTakesItsColorAndCount() = runTest {
val nowSec = System.currentTimeMillis() / 1000
val heardMost = firstSlotStatus(
report("h1", "AO-7", "Heard", nowSec - 3600),
report("h2", "AO-7", "Heard", nowSec - 4500),
report("t1", "AO-7", "Telemetry Only", nowSec - 5400)
)
assertEquals(0xFF648FFFL, heardMost?.statusColor) // blue
assertEquals(2, heardMost?.count) // majority count, not the block total
val telemetryMost = firstSlotStatus(
report("t1", "AO-7", "Telemetry Only", nowSec - 3600),
report("t2", "AO-7", "Telemetry Only", nowSec - 4500),
report("h1", "AO-7", "Heard", nowSec - 5400)
)
assertEquals(0xFFFFB000L, telemetryMost?.statusColor) // amber
assertEquals(2, telemetryMost?.count)
val notHeardMost = firstSlotStatus(
report("n1", "AO-7", "Not Heard", nowSec - 3600),
report("n2", "AO-7", "Not Heard", nowSec - 4500),
report("n3", "AO-7", "Not Heard", nowSec - 5400),
report("t1", "AO-7", "Telemetry Only", nowSec - 6000)
)
assertEquals(0xFFDC267FL, notHeardMost?.statusColor) // pink
assertEquals(3, notHeardMost?.count)
val single = firstSlotStatus(report("h1", "AO-7", "Heard", nowSec - 3600))
assertEquals(0xFF648FFFL, single?.statusColor)
assertEquals(1, single?.count)
}
@Test
fun buildStatusesShowsConflictWhenNoStrictMajority() = runTest {
val nowSec = System.currentTimeMillis() / 1000
val oneToOne = firstSlotStatus(
report("h1", "AO-7", "Heard", nowSec - 3600),
report("n1", "AO-7", "Not Heard", nowSec - 4500)
)
assertEquals(0xFFFE6100L, oneToOne?.statusColor) // conflicting
assertEquals(2, oneToOne?.count)
val twoToTwo = firstSlotStatus(
report("h1", "AO-7", "Heard", nowSec - 3600),
report("h2", "AO-7", "Heard", nowSec - 4500),
report("t1", "AO-7", "Telemetry Only", nowSec - 5400),
report("t2", "AO-7", "Telemetry Only", nowSec - 6000)
)
assertEquals(0xFFFE6100L, twoToTwo?.statusColor)
val threeWay = firstSlotStatus(
report("h1", "AO-7", "Heard", nowSec - 3600),
report("n1", "AO-7", "Not Heard", nowSec - 4500),
report("t1", "AO-7", "Telemetry Only", nowSec - 5400)
)
assertEquals(0xFFFE6100L, threeWay?.statusColor)
}
@Test
fun buildStatusesCrewActiveWinsWithCrewPlusHeardCount() = runTest {
val nowSec = System.currentTimeMillis() / 1000
val crewWithHeard = firstSlotStatus(
report("c1", "AO-7", "Crew Active", nowSec - 3600),
report("h1", "AO-7", "Heard", nowSec - 4500),
report("h2", "AO-7", "Heard", nowSec - 5400),
report("h3", "AO-7", "Heard", nowSec - 6000)
)
assertEquals(0xFF785EF0L, crewWithHeard?.statusColor) // purple
assertEquals(4, crewWithHeard?.count) // crew + heard
val crewAlone = firstSlotStatus(
report("c1", "AO-7", "Crew Active", nowSec - 3600),
report("n1", "AO-7", "Not Heard", nowSec - 4500),
report("n2", "AO-7", "Not Heard", nowSec - 5400)
)
assertEquals(0xFF785EF0L, crewAlone?.statusColor)
assertEquals(1, crewAlone?.count) // crew + heard (0)
}
@Test
fun buildStatusesMarksOnlyNoStrictMajoritySlotsAsConflicted() = runTest {
val nowSec = System.currentTimeMillis() / 1000
val majority = firstSlotStatus(
report("h1", "AO-7", "Heard", nowSec - 3600),
report("h2", "AO-7", "Heard", nowSec - 4500),
report("t1", "AO-7", "Telemetry Only", nowSec - 5400)
)
assertEquals(false, majority?.isConflicted)
val tie = firstSlotStatus(
report("h1", "AO-7", "Heard", nowSec - 3600),
report("t1", "AO-7", "Telemetry Only", nowSec - 4500)
)
assertEquals(true, tie?.isConflicted)
val crew = firstSlotStatus(
report("c1", "AO-7", "Crew Active", nowSec - 3600),
report("n1", "AO-7", "Not Heard", nowSec - 4500)
)
assertEquals(false, crew?.isConflicted)
val empty = firstSlotStatus()
assertEquals(false, empty?.isConflicted)
}
}
private suspend fun firstSlotStatus(vararg reports: String): SatSlot? {
val page = AmSatRepository(
FakeAmSatRemoteSource(reportsJson = amSatReportsJson(*reports))
).fetchStatus()
return page?.statuses?.single()?.days?.get(0)?.slots?.get(0)
}
private fun AmSatRepository.seedStatusCache(page: SatStatusPage) {
@@ -453,10 +453,9 @@ private class FakeSettingsRepo(dataSources: DataSourcesSettings = defaultDataSou
override fun setAmSatCallsign(callsign: String) = Unit
override val wavelogSettings: StateFlow<com.rtbishop.look4sat.core.domain.model.WavelogSettings> =
MutableStateFlow(com.rtbishop.look4sat.core.domain.model.WavelogSettings())
override fun updateWavelogSettings(settings: com.rtbishop.look4sat.core.domain.model.WavelogSettings) = Unit
override val wavelogUploadSettings: StateFlow<com.rtbishop.look4sat.core.domain.model.WavelogUploadSettings> =
MutableStateFlow(com.rtbishop.look4sat.core.domain.model.WavelogUploadSettings())
@@ -479,6 +478,14 @@ private class FakeSettingsRepo(dataSources: DataSourcesSettings = defaultDataSou
override fun setLastLotwSyncDate(date: String) = Unit
override fun getLastLotwSyncCallsign(): String = ""
override fun setLastLotwSyncCallsign(callsign: String) = Unit
override fun getWavelogStations(): List<com.rtbishop.look4sat.core.domain.repository.WavelogStationInfo> = emptyList()
override fun setWavelogStations(stations: List<com.rtbishop.look4sat.core.domain.repository.WavelogStationInfo>) = Unit
override fun getWavelogSyncCursors(): Map<String, Long> = emptyMap()
override fun setWavelogSyncCursors(cursors: Map<String, Long>) = Unit
override fun getWavelogSyncUrl(): String = ""
override fun setWavelogSyncUrl(url: String) = Unit
override fun getLastWavelogSyncEpochMs(): Long = 0L
override fun setLastWavelogSyncEpochMs(value: Long) = Unit
override fun getWorkedGridQsos(): Map<String, List<com.rtbishop.look4sat.core.domain.model.GridQso>> = emptyMap()
@@ -296,14 +296,15 @@ class QsoRepositoryTest {
}
@Test
fun markWavelogUploaded_setsOnlyTheWavelogFlag() = runBlocking {
fun markWavelogUploaded_setsTheFlagAndTheStationStamp() = runBlocking {
val id = dao.save(loggedInApp("SO-50", lotwUploaded = false).toEntity())
repository.markWavelogUploaded(listOf(id))
repository.markWavelogUploaded(listOf(id), "3")
val row = dao.getAll().single()
assertTrue(row.wavelogUploaded)
assertFalse(row.lotwUploaded)
assertEquals("3", row.wavelogStation)
}
@Test
@@ -316,6 +317,46 @@ class QsoRepositoryTest {
assertEquals(1, dao.getAll().count { it.wavelogUploaded })
}
@Test
fun mergeWavelog_insertsNewRecordsAlreadyMarkedAndStationTagged() = runBlocking {
val result = repository.mergeWavelog(
listOf(loggedInApp("SO-50", lotwUploaded = false).copy(wavelogStation = "2"))
)
assertEquals(1, result.imported)
val row = dao.getAll().single()
assertTrue(row.wavelogUploaded)
assertEquals("2", row.wavelogStation)
assertFalse(row.lotwUploaded)
}
@Test
fun mergeWavelog_matchesALocalRowInsteadOfDuplicating() = runBlocking {
val id = dao.save(loggedInApp("SAUDISAT 1C", lotwUploaded = false).copy(wavelogUploaded = true).toEntity())
repository.mergeWavelog(
listOf(loggedInApp("SAUDISAT 1C", lotwUploaded = false).copy(wavelogStation = "1", theirGrid = "EN52"))
)
val rows = dao.getAll()
assertEquals(1, rows.size)
assertEquals(id, rows.first().id)
assertEquals("1", rows.first().wavelogStation)
assertEquals("EN52", rows.first().theirGrid)
// The row joins the ARRL spelling like every other import path.
assertEquals("SO-50", rows.first().satelliteName)
}
@Test
fun mergeWavelog_isIdempotentAcrossSyncs() = runBlocking {
val records = listOf(loggedInApp("SO-50", lotwUploaded = false).copy(wavelogStation = "1"))
repository.mergeWavelog(records)
repository.mergeWavelog(records)
assertEquals(1, dao.getAll().size)
}
/** A record as the log page creates it: tracker name, repeater pair, uplink band. */
private fun loggedInApp(satelliteName: String, lotwUploaded: Boolean) = QsoRecord(
startUtcMillis = qsoStart,
@@ -377,6 +418,7 @@ class QsoRepositoryTest {
lotwConfirmed = lotwConfirmed,
lotwUploaded = lotwUploaded,
wavelogUploaded = wavelogUploaded,
wavelogStation = wavelogStation,
lotwReceived = lotwReceived,
lotwQslDate = lotwQslDate,
vuccGrids = vuccGrids.joinToString(","),
@@ -416,8 +458,15 @@ class QsoRepositoryTest {
ids.forEach { id -> rows[id]?.let { rows[id] = it.copy(lotwUploaded = true) } }
}
override suspend fun markWavelogUploaded(ids: List<Long>) {
ids.forEach { id -> rows[id]?.let { rows[id] = it.copy(wavelogUploaded = true) } }
override suspend fun markWavelogUploaded(ids: List<Long>, stationId: String) {
ids.forEach { id ->
rows[id]?.let {
rows[id] = it.copy(
wavelogUploaded = true,
wavelogStation = if (stationId.isBlank()) it.wavelogStation else stationId
)
}
}
}
}
}
@@ -344,8 +344,7 @@ private class FakeSettingsRepoForSearch : ISettingsRepo {
override val radioControlSettings: StateFlow<RadioControlSettings> = MutableStateFlow(
RadioControlSettings(false, RadioControlSettings.MODEL_YAESU_FT817, "", "", "", "", 9600)
)
override val wavelogSettings: StateFlow<com.rtbishop.look4sat.core.domain.model.WavelogSettings> =
MutableStateFlow(com.rtbishop.look4sat.core.domain.model.WavelogSettings())
override val wavelogUploadSettings: StateFlow<com.rtbishop.look4sat.core.domain.model.WavelogUploadSettings> =
MutableStateFlow(com.rtbishop.look4sat.core.domain.model.WavelogUploadSettings())
override fun updateWavelogUploadSettings(settings: com.rtbishop.look4sat.core.domain.model.WavelogUploadSettings) = Unit
@@ -377,7 +376,7 @@ private class FakeSettingsRepoForSearch : ISettingsRepo {
override fun setSatelliteOffset(catnum: Int, offset: String) = Unit
override fun getAmSatCallsign(): String = ""
override fun setAmSatCallsign(callsign: String) = Unit
override fun updateWavelogSettings(settings: com.rtbishop.look4sat.core.domain.model.WavelogSettings) = Unit
override fun getWorkedGrids(): Set<String> = emptySet()
override fun setWorkedGrids(grids: Set<String>) = Unit
override fun getWorkedGridQsos(): Map<String, List<com.rtbishop.look4sat.core.domain.model.GridQso>> = emptyMap()
@@ -391,4 +390,12 @@ private class FakeSettingsRepoForSearch : ISettingsRepo {
override fun setLastLotwSyncDate(date: String) = Unit
override fun getLastLotwSyncCallsign(): String = ""
override fun setLastLotwSyncCallsign(callsign: String) = Unit
override fun getWavelogStations(): List<com.rtbishop.look4sat.core.domain.repository.WavelogStationInfo> = emptyList()
override fun setWavelogStations(stations: List<com.rtbishop.look4sat.core.domain.repository.WavelogStationInfo>) = Unit
override fun getWavelogSyncCursors(): Map<String, Long> = emptyMap()
override fun setWavelogSyncCursors(cursors: Map<String, Long>) = Unit
override fun getWavelogSyncUrl(): String = ""
override fun setWavelogSyncUrl(url: String) = Unit
override fun getLastWavelogSyncEpochMs(): Long = 0L
override fun setLastWavelogSyncEpochMs(value: Long) = Unit
}
@@ -0,0 +1,74 @@
/*
* Look4Sat-BA7OPF. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2026 BA7OPF.
* Based on Look4Sat by 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.repository
import org.json.JSONObject
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
/** Parsing of the `get_contacts_adif` response envelope (transport-level wiring is exercised on device). */
class WavelogSyncRepositoryTest {
private val repository = WavelogSyncRepository()
@Test
fun successfulPageParsesRecordsAndCursor() {
val adif = "<ADIF_VER:5>3.1.7\n<EOH>\n" +
"<QSO_DATE:8>20260916<TIME_ON:4>0745<CALL:6>BG5JSB<GRIDSQUARE:4>EN52" +
"<MODE:2>FM<PROP_MODE:3>SAT<SAT_NAME:5>SO-50<EOR>\n"
val text = JSONObject()
.put("status", "successful")
.put("lastfetchedid", 42)
.put("exported_qsos", 1)
.put("adif", adif)
.toString()
val page = repository.parsePage(text)
assertNotNull(page)
assertEquals(42L, page!!.cursor)
assertEquals(1, page.exported)
assertEquals(1, page.records.size)
assertEquals("BG5JSB", page.records.single().theirCallsign)
assertEquals("EN52", page.records.single().theirGrid)
}
@Test
fun failedStatusIsRejected() {
assertNull(repository.parsePage("""{"status":"failed","reason":"missing api key"}"""))
}
@Test
fun missingCursorIsRejected() {
assertNull(repository.parsePage("""{"status":"successful","exported_qsos":0,"adif":null}"""))
assertNull(repository.parsePage("not json at all"))
}
@Test
fun emptyExportKeepsTheCursorAndYieldsNoRecords() {
val page = repository.parsePage("""{"status":"successful","lastfetchedid":7,"exported_qsos":0,"adif":null}""")
assertNotNull(page)
assertEquals(7L, page!!.cursor)
assertTrue(page.records.isEmpty())
}
}
Binary file not shown.
Binary file not shown.
@@ -24,14 +24,26 @@ interface IQsoRepository {
*/
suspend fun markUploaded(ids: List<Long>, grids: List<String> = emptyList())
/** Marks the batch as uploaded to Wavelog (independent of the LoTW upload state). */
suspend fun markWavelogUploaded(ids: List<Long>)
/**
* Marks the batch as uploaded to Wavelog (independent of the LoTW upload state) and
* stamps the station profile (台址) the batch went out through, so the logbook's
* 台址 selector can group the records. An empty [stationId] leaves the stamp as-is.
*/
suspend fun markWavelogUploaded(ids: List<Long>, stationId: String = "")
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
/**
* Merges QSO records pulled from Wavelog by a sync (the download direction):
* matches by the same stable identity as every other import, tags matched and
* freshly inserted rows with their Wavelog station profile, and marks them as
* already uploaded so a later upload never sends them straight back.
*/
suspend fun mergeWavelog(records: List<QsoRecord>): AdifImportResult
/**
* Folds LoTW confirmations that were imported as their own rows back into the local
* record they belong to (see [com.rtbishop.look4sat.core.domain.logbook.splitConfirmationPairs]).
@@ -42,6 +42,10 @@ data class QsoRecord(
val lotwUploaded: Boolean = false,
/** Internal: set after a successful Wavelog upload; keeps the pending filter small. Not shown in any list. */
val wavelogUploaded: Boolean = false,
/** Wavelog station profile (台址) this QSO belongs to: the profile it was uploaded
* through, or the profile a sync pulled it from. Empty for records the app never
* involved with Wavelog — the logbook's 台址 selector shows those under "All" only. */
val wavelogStation: String = "",
val lotwReceived: Boolean = false,
val lotwQslDate: String = "",
val vuccGrids: List<String> = emptyList(),
@@ -124,13 +124,23 @@ fun GridQso.stationGridSetKey(): String? =
myStationGrids().takeIf { it.isNotEmpty() }?.sorted()?.joinToString(",")
/**
* Scopes a per-grid QSO list to the operated-grid selector's choice:
* [stationId] == null ("All") keeps the list as-is, otherwise only records
* whose [stationGridSetKey] matches stay. Records without a MY-side grid
* belong to no 台址 and are dropped under any specific scope.
* Scopes a per-grid QSO list to the operated-grid selector's choice with
* COVERING semantics (2026-10-05): [stationId] is a grid-set key such as
* "OL62" or "OL62,OL63", and a record stays when its own [myStationGrids]
* COVER that key (record grid set ⊇ key set). So a multi-grid 台址 that
* covers a single-grid one (OL62/63 covers OL62) contributes its records to
* the single-grid scope's statistics — map fills, dialog lists and first-call
* labels all derive from this one rule. The reverse does NOT hold: records
* under a narrower 台址 never leak into a wider scope (single OL62 records
* stay out of the OL62/63 scope). Records without a MY-side grid belong to
* no 台址 and are dropped under any specific scope.
*/
fun List<GridQso>.scopedToStation(stationId: String?): List<GridQso> =
if (stationId == null) this else filter { it.stationGridSetKey() == stationId }
if (stationId == null) this
else {
val scope = stationId.split(',').toSet()
filter { it.myStationGrids().containsAll(scope) }
}
/**
* For every grid in the store, the callsign of the earliest QSO — scoped to the
@@ -30,9 +30,10 @@ data class SatReport(
/** State of one 2-hour slot */
data class SatSlot(
val statusColor: Long, // ARGB 状态色(-1 = 无报告)
val count: Int, // 报告数量(0 = 无)
val reportIds: List<String> = emptyList() // 该槽报告 ID 列表
val statusColor: Long, // ARGB 状态色(灰=无报告;判定见 AmSatRepository.slotStatusOf)
val count: Int, // 官网页口径:多数方计数(冲突槽/异常槽=总条数;0 = 无)
val reportIds: List<String> = emptyList(), // 该槽报告 ID 列表
val isConflicted: Boolean = false // 官网页"Conflicting reports":该槽无严格多数(弹窗据此标注)
)
/** One satellite day (12 two-hour slots) */
@@ -102,14 +102,7 @@ data class DataSourcesSettings(
fun isTransceiverEnabled(index: Int): Boolean = transceiversEnabled.getOrElse(index) { true }
}
data class WavelogSettings(
val url: String = "",
val token: String = ""
) {
val isConfigured: Boolean get() = url.isNotBlank() && token.isNotBlank()
}
/** Wavelog QSO-upload configuration (a separate block from the worked-grids sync). */
/** Wavelog configuration — QSO upload both ways; one of LoTW or Wavelog (mutually exclusive). */
data class WavelogUploadSettings(
val url: String = "",
val apiKey: String = "",
@@ -25,6 +25,9 @@ interface ILoTWUploadRepository {
suspend fun certificate(): LoTWCertificate?
suspend fun station(): LoTWStation?
suspend fun importCertificate(data: ByteArray, password: CharArray): LoTWCertificate
/** Parse a .p12 without persisting it — enough to learn the certificate's DXCC
* entity so the station form can offer its region field before the import. */
suspend fun previewCertificate(data: ByteArray, password: CharArray): LoTWCertificate
suspend fun saveCertificatePassword(password: CharArray): LoTWCertificate
suspend fun saveStation(station: LoTWStation): LoTWStation
suspend fun removeCertificate()
@@ -33,8 +33,8 @@ interface IMainContainer {
val databaseRepo: IDatabaseRepo
val amSatRepo: IAmSatRepository
val updateRepo: IUpdateRepository
val wavelogRepo: IWavelogRepository
val wavelogUploadRepository: IWavelogUploadRepository
val wavelogSyncRepository: IWavelogSyncRepository
val lotwRepo: com.rtbishop.look4sat.core.domain.repository.ILoTWRepository
val qsoRepository: IQsoRepository
val lotwUploadRepository: ILoTWUploadRepository
@@ -23,7 +23,7 @@ import com.rtbishop.look4sat.core.domain.model.OtherSettings
import com.rtbishop.look4sat.core.domain.model.PassesSettings
import com.rtbishop.look4sat.core.domain.model.RCSettings
import com.rtbishop.look4sat.core.domain.model.RadioControlSettings
import com.rtbishop.look4sat.core.domain.model.WavelogSettings
import com.rtbishop.look4sat.core.domain.model.WavelogUploadSettings
import com.rtbishop.look4sat.core.domain.predict.GeoPos
import kotlinx.coroutines.flow.StateFlow
@@ -107,9 +107,7 @@ interface ISettingsRepo {
fun setAmSatCallsign(callsign: String)
//endregion
//region # Wavelog worked-grids settings
val wavelogSettings: StateFlow<WavelogSettings>
fun updateWavelogSettings(settings: WavelogSettings)
//region # Worked-grid data (written by the LoTW / Wavelog syncs)
fun getWorkedGrids(): Set<String>
fun setWorkedGrids(grids: Set<String>)
/** Confirmed satellite QSOs grouped by worked 4-char gridsquare (map tap detail). */
@@ -130,6 +128,21 @@ interface ISettingsRepo {
fun updateWavelogUploadSettings(settings: WavelogUploadSettings)
//endregion
//region # Wavelog sync bookkeeping
/** Station profiles as last reported by the server — labels for the 台址 selector and targets for sync. */
fun getWavelogStations(): List<WavelogStationInfo>
fun setWavelogStations(stations: List<WavelogStationInfo>)
/** stationId -> lastfetchedid cursors of the last successful sync (per station profile). */
fun getWavelogSyncCursors(): Map<String, Long>
fun setWavelogSyncCursors(cursors: Map<String, Long>)
/** Wavelog base URL the stored cursors belong to (empty when never synced). */
fun getWavelogSyncUrl(): String
fun setWavelogSyncUrl(url: String)
/** Epoch ms of the last successful Wavelog sync (0 when never synced). */
fun getLastWavelogSyncEpochMs(): Long
fun setLastWavelogSyncEpochMs(value: Long)
//endregion
//region # LoTW confirmed-grids settings
val lotwSettings: StateFlow<com.rtbishop.look4sat.core.domain.model.LoTWSettings>
fun updateLoTWSettings(settings: com.rtbishop.look4sat.core.domain.model.LoTWSettings)
@@ -1,28 +0,0 @@
/*
* 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.domain.repository
/** Fetches worked gridsquares from a self-hosted Wavelog instance (API v2). */
interface IWavelogRepository {
/**
* Fetch worked grids for the configured Wavelog URL/token.
* Returns the grid set, or null on any failure.
*/
suspend fun fetchWorkedGrids(url: String, token: String): Set<String>?
}
@@ -0,0 +1,57 @@
/*
* Look4Sat-BA7OPF. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2026 BA7OPF.
* Based on Look4Sat by 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.domain.repository
import com.rtbishop.look4sat.core.domain.logbook.QsoRecord
/** What a Wavelog sync pulls: everything, or only records newer than the stored cursors. */
enum class WavelogSyncMode { Full, Incremental }
/** Outcome of one sync fetch. */
data class WavelogSyncFetch(
/** Records pulled this run, each tagged with the station profile it came from. */
val records: List<QsoRecord>,
/** stationId -> lastfetchedid to resume from on the next incremental run. */
val cursors: Map<String, Long>,
/** Station ids that could not be fetched (transport/parse); empty when all succeeded. */
val failedStations: List<String>
)
/**
* Pulls QSO records from a self-hosted Wavelog (v1 API, `api/get_contacts_adif`).
* One request per station profile; the server exports only rows newer than the
* `fetchfromid` cursor, so repeated syncs stay incremental per station.
*/
interface IWavelogSyncRepository {
/**
* Fetches records for every entry of [stations]. [cursors] maps stationId to the
* last id already synced (missing -> 0); [full] re-pulls each station from the
* beginning, ignoring the cursors. Returns null when the fetch cannot run at all
* (blank url/key or no stations); individual station failures land in
* [WavelogSyncFetch.failedStations] with the remaining stations still fetched.
*/
suspend fun fetchNewRecords(
url: String,
apiKey: String,
stations: List<WavelogStationInfo>,
cursors: Map<String, Long>,
full: Boolean
): WavelogSyncFetch?
}
@@ -0,0 +1,137 @@
/*
* Look4Sat-BA7OPF. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2026 BA7OPF.
* Based on Look4Sat by 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.domain.repository
import com.rtbishop.look4sat.core.domain.logbook.QsoRecord
import com.rtbishop.look4sat.core.domain.model.GridQso
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.util.Locale
/** Trimmed base form of a Wavelog server URL (".../wavelog/" -> ".../wavelog"). */
fun normalizeWavelogUrl(url: String): String = url.trim().trimEnd('/')
/**
* Resolves the effective sync mode for a request:
* - an explicit [WavelogSyncMode.Full] is always honored;
* - an [WavelogSyncMode.Incremental] request is honored only when the stored sync URL
* matches the current one — a first sync or a server switch falls back to
* [WavelogSyncMode.Full], so cursors from another server never leak in.
*/
fun resolveWavelogSyncMode(storedUrl: String, currentUrl: String, requested: WavelogSyncMode?): WavelogSyncMode {
if (requested == WavelogSyncMode.Full) return WavelogSyncMode.Full
val sameServer = storedUrl.isNotBlank() && storedUrl == normalizeWavelogUrl(currentUrl)
return if (sameServer) WavelogSyncMode.Incremental else WavelogSyncMode.Full
}
/** Grid data derived from Wavelog-synced records, in the same shape the LoTW sync produces. */
data class WavelogGridData(
val grids: Set<String>,
val gridQsos: Map<String, List<GridQso>>,
val roamedGrids: Set<String>
)
internal fun isValidGrid4(grid: String): Boolean =
grid.length == 4 && grid[0] in 'A'..'R' && grid[1] in 'A'..'R' &&
grid[2] in '0'..'9' && grid[3] in '0'..'9'
/**
* Derives the worked-grid set, the per-grid QSO detail and the roamed (own) grids from
* records pulled by a Wavelog sync — mirroring what the LoTW report parser produces, so
* both sync sources feed the map's highlight and its 台址 selector the same data shape.
*/
fun buildWavelogGridData(records: List<QsoRecord>): WavelogGridData {
val grids = linkedSetOf<String>()
val roamed = linkedSetOf<String>()
val gridQsos = linkedMapOf<String, MutableList<GridQso>>()
records.forEach { record ->
val isSatellite = record.satelliteName.isNotBlank() || record.propagationMode.equals("SAT", true)
if (!isSatellite) return@forEach
val theirGrids = (record.theirVuccGrids.ifEmpty { listOf(record.theirGrid) })
.map { it.trim().uppercase(Locale.US).take(4) }
.filter(::isValidGrid4)
.distinct()
if (theirGrids.isEmpty()) return@forEach
val myGrids = (record.vuccGrids.ifEmpty { listOf(record.myGrid) })
.map { it.trim().uppercase(Locale.US).take(4) }
.filter(::isValidGrid4)
.toSet()
roamed += myGrids
val qso = GridQso(
call = record.theirCallsign,
epochMs = record.startUtcMillis,
satName = record.satelliteName,
mode = record.mode,
bandUp = record.band,
bandDown = record.rxBand,
dxcc = record.dxcc,
country = record.country.ifBlank { null },
cqz = record.cqZone,
state = record.region.ifBlank { null },
myGrid = myGrids.sorted().firstOrNull(),
myGrids = myGrids,
myCallsign = record.myCallsign.ifBlank { null },
theirGrids = theirGrids
)
theirGrids.forEach { grid ->
grids += grid
gridQsos.getOrPut(grid) { mutableListOf() }.add(qso)
}
}
return WavelogGridData(grids, gridQsos, roamed)
}
/**
* Applies a successful Wavelog sync to the stored map data and sync bookkeeping.
*
* Grids, per-grid QSO detail and roamed grids only ever GROW (union): both the
* incremental and the full re-pull merge into the stored sets, matching how the LoTW
* sync treats stale data — a full re-pull heals missing records but never deletes map
* data the app already knows about. Cursors are replaced wholesale ([cursors] covers
* every station just fetched), and the sync URL/epoch advance so the next run resumes
* incrementally. Persistence runs on the IO dispatcher — building the per-grid QSO
* JSON blocks the caller, and for large accounts that froze the main thread after a
* manual sync.
*
* @return the resulting number of worked grids.
*/
suspend fun applyWavelogSyncResult(
settingsRepo: ISettingsRepo,
records: List<QsoRecord>,
cursors: Map<String, Long>,
url: String,
now: Long = System.currentTimeMillis()
): Int {
val data = buildWavelogGridData(records)
val existingGrids = settingsRepo.getWorkedGrids()
val existingQsos = settingsRepo.getWorkedGridQsos()
val existingRoamed = settingsRepo.getRoamedGrids()
val mergedGrids = existingGrids + data.grids
val mergedQsos = mergeGridQsos(existingQsos, data.gridQsos)
val mergedRoamed = existingRoamed + data.roamedGrids
settingsRepo.setWavelogSyncCursors(cursors)
settingsRepo.setWavelogSyncUrl(normalizeWavelogUrl(url))
settingsRepo.setLastWavelogSyncEpochMs(now)
withContext(Dispatchers.IO) {
settingsRepo.setWorkedGrids(mergedGrids)
settingsRepo.setWorkedGridQsos(mergedQsos)
settingsRepo.setRoamedGrids(mergedRoamed)
}
return mergedGrids.size
}
@@ -74,12 +74,32 @@ class GridQsoStationScopeTest {
}
@Test
fun `specific scope keeps only the matching station's records`() {
fun `specific scope keeps covering station's records too`() {
// 方案 A 覆盖语义: 选中单格台址 OL62 时,覆盖它的多格台址 (OL62,PM01)
// 的通联也计入;反向不泄漏。
val multi = qso("A", myGrids = setOf("OL62", "PM01"))
val single = qso("B", myGrid = "OL62")
val list = listOf(multi, single)
assertEquals(listOf(multi), list.scopedToStation("OL62,PM01"))
assertEquals(listOf(single), list.scopedToStation("OL62"))
assertEquals(listOf(multi, single), list.scopedToStation("OL62"))
}
@Test
fun `narrower station never leaks into a wider scope`() {
val single = qso("B", myGrid = "OL62")
val pair = qso("A", myGrids = setOf("OL62", "PM01"))
// 宽台址 (OL62,PM01) 选中时,单格 OL62 的记录不出现
assertEquals(emptyList<GridQso>(), listOf(single, pair).scopedToStation("OL62,PM01").filter { it === single })
assertEquals(listOf(pair), listOf(single, pair).scopedToStation("OL62,PM01"))
}
@Test
fun `covering is per-cell set containment not string equality`() {
// 多格台址的 key 是排序拼接,覆盖判定必须按网格集合而非字符串
val quad = qso("A", myGrids = setOf("OL62", "OL63", "OL64", "OL65"))
assertEquals(listOf(quad), listOf(quad).scopedToStation("OL63"))
assertEquals(listOf(quad), listOf(quad).scopedToStation("OL62,OL65"))
assertEquals(emptyList<GridQso>(), listOf(quad).scopedToStation("OL62,PM01"))
}
@Test
@@ -26,7 +26,7 @@ import com.rtbishop.look4sat.core.domain.model.OtherSettings
import com.rtbishop.look4sat.core.domain.model.PassesSettings
import com.rtbishop.look4sat.core.domain.model.RCSettings
import com.rtbishop.look4sat.core.domain.model.RadioControlSettings
import com.rtbishop.look4sat.core.domain.model.WavelogSettings
import com.rtbishop.look4sat.core.domain.model.WavelogUploadSettings
import com.rtbishop.look4sat.core.domain.predict.GeoPos
import kotlinx.coroutines.flow.MutableStateFlow
@@ -248,7 +248,7 @@ class LoTWGridSyncTest {
override val radioControlSettings: StateFlow<RadioControlSettings> = MutableStateFlow(
RadioControlSettings(false, RadioControlSettings.MODEL_YAESU_FT817, "", "", "", "", 9600)
)
override val wavelogSettings: StateFlow<WavelogSettings> = MutableStateFlow(WavelogSettings())
override val wavelogUploadSettings: StateFlow<WavelogUploadSettings> = MutableStateFlow(WavelogUploadSettings())
override val lotwSettings: StateFlow<LoTWSettings> = MutableStateFlow(LoTWSettings())
@@ -272,6 +272,19 @@ class LoTWGridSyncTest {
override fun getLastLotwSyncCallsign(): String = lastSyncCallsign
override fun setLastLotwSyncCallsign(callsign: String) { lastSyncCallsign = callsign }
private var wavelogStations: List<com.rtbishop.look4sat.core.domain.repository.WavelogStationInfo> = emptyList()
private var wavelogSyncCursors: Map<String, Long> = emptyMap()
private var wavelogSyncUrl: String = ""
private var lastWavelogSyncEpochMs: Long = 0L
override fun getWavelogStations(): List<com.rtbishop.look4sat.core.domain.repository.WavelogStationInfo> = wavelogStations
override fun setWavelogStations(stations: List<com.rtbishop.look4sat.core.domain.repository.WavelogStationInfo>) { wavelogStations = stations }
override fun getWavelogSyncCursors(): Map<String, Long> = wavelogSyncCursors
override fun setWavelogSyncCursors(cursors: Map<String, Long>) { wavelogSyncCursors = cursors }
override fun getWavelogSyncUrl(): String = wavelogSyncUrl
override fun setWavelogSyncUrl(url: String) { wavelogSyncUrl = url }
override fun getLastWavelogSyncEpochMs(): Long = lastWavelogSyncEpochMs
override fun setLastWavelogSyncEpochMs(value: Long) { lastWavelogSyncEpochMs = value }
override fun setSelectedIds(ids: List<Int>) = TODO()
override fun setSelectedTypes(types: List<String>) = TODO()
override fun setPassesSettings(settings: PassesSettings) = TODO()
@@ -293,7 +306,7 @@ class LoTWGridSyncTest {
override fun setSatelliteOffset(catnum: Int, offset: String) = TODO()
override fun getAmSatCallsign(): String = ""
override fun setAmSatCallsign(callsign: String) = TODO()
override fun updateWavelogSettings(settings: WavelogSettings) = TODO()
override fun updateWavelogUploadSettings(settings: WavelogUploadSettings) = TODO()
override fun updateLoTWSettings(settings: LoTWSettings) = TODO()
}
@@ -0,0 +1,259 @@
/*
* Look4Sat-BA7OPF. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2026 BA7OPF.
* Based on Look4Sat by 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.domain.repository
import com.rtbishop.look4sat.core.domain.logbook.QsoRecord
import com.rtbishop.look4sat.core.domain.logbook.QsoStatus
import com.rtbishop.look4sat.core.domain.model.DataSourcesSettings
import com.rtbishop.look4sat.core.domain.model.DatabaseState
import com.rtbishop.look4sat.core.domain.model.GridQso
import com.rtbishop.look4sat.core.domain.model.LoTWSettings
import com.rtbishop.look4sat.core.domain.model.MarkedStation
import com.rtbishop.look4sat.core.domain.model.OtherSettings
import com.rtbishop.look4sat.core.domain.model.PassesSettings
import com.rtbishop.look4sat.core.domain.model.RCSettings
import com.rtbishop.look4sat.core.domain.model.RadioControlSettings
import com.rtbishop.look4sat.core.domain.model.WavelogUploadSettings
import com.rtbishop.look4sat.core.domain.predict.GeoPos
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.runBlocking
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
class WavelogSyncTest {
// region # resolveWavelogSyncMode
@Test
fun fullRequestIsAlwaysHonored() {
assertEquals(WavelogSyncMode.Full, resolveWavelogSyncMode("", "http://a", WavelogSyncMode.Full))
assertEquals(WavelogSyncMode.Full, resolveWavelogSyncMode("http://a", "http://a", WavelogSyncMode.Full))
}
@Test
fun incrementalIsHonoredOnlyOnTheSameServer() {
assertEquals(
WavelogSyncMode.Incremental,
resolveWavelogSyncMode("http://a", "http://a/", WavelogSyncMode.Incremental)
)
assertEquals(WavelogSyncMode.Full, resolveWavelogSyncMode("", "http://a", WavelogSyncMode.Incremental))
assertEquals(
WavelogSyncMode.Full,
resolveWavelogSyncMode("http://old", "http://a", WavelogSyncMode.Incremental)
)
}
@Test
fun urlNormalizationTrimsWhitespaceAndSlashes() {
assertEquals("http://a/wavelog", normalizeWavelogUrl(" http://a/wavelog/ "))
}
// endregion
// region # buildWavelogGridData
@Test
fun gridDataCollectsTheirGridsAndRoamedOwnGrids() {
val data = buildWavelogGridData(
listOf(
record(start = 1_000L, theirGrid = "EN52", myGrid = "OL62"),
record(start = 2_000L, theirGrid = "EN53XX", myGrid = "OL62TI")
)
)
assertEquals(setOf("EN52", "EN53"), data.grids)
assertEquals(setOf("OL62"), data.roamedGrids)
assertEquals(1, data.gridQsos.getValue("EN52").size)
assertEquals(1, data.gridQsos.getValue("EN53").size)
}
@Test
fun gridDataSpreadsAMultiGridQsoAcrossItsGrids() {
val data = buildWavelogGridData(
listOf(record(start = 1_000L, theirGrid = "EN52", theirVucc = listOf("EN52", "EN53")))
)
assertEquals(setOf("EN52", "EN53"), data.grids)
assertEquals(1, data.gridQsos.getValue("EN52").size)
assertEquals(1, data.gridQsos.getValue("EN53").size)
// The same QSO object is shared, so its detail matches from either grid.
assertEquals(data.gridQsos.getValue("EN52").single(), data.gridQsos.getValue("EN53").single())
}
@Test
fun gridDataIgnoresNonSatelliteRecordsAndBlankGrids() {
val data = buildWavelogGridData(
listOf(
record(start = 1L, theirGrid = "", sat = ""),
record(start = 2L, theirGrid = "XX99", sat = "SO-50")
)
)
assertTrue(data.grids.isEmpty())
assertTrue(data.roamedGrids.isEmpty())
assertTrue(data.gridQsos.isEmpty())
}
@Test
fun gridQsoCarriesTheFullRecordDetails() {
val data = buildWavelogGridData(listOf(record(start = 1_000L, theirGrid = "EN52", myGrid = "OL62TI")))
val qso = data.gridQsos.getValue("EN52").single()
assertEquals("BG5JVM", qso.call)
assertEquals(1_000L, qso.epochMs)
assertEquals("SO-50", qso.satName)
assertEquals("FM", qso.mode)
assertEquals("2M", qso.bandUp)
assertEquals("70CM", qso.bandDown)
assertEquals("OL62", qso.myGrid)
assertEquals(setOf("OL62"), qso.myGrids)
assertEquals(listOf("EN52"), qso.theirGrids)
}
// endregion
// region # applyWavelogSyncResult
@Test
fun applySyncMergesGridsAndAdvancesBookkeeping() = runBlocking {
val settings = FakeSettings()
settings.setWorkedGrids(setOf("PM01"))
val count = applyWavelogSyncResult(
settings,
listOf(record(start = 1_000L, theirGrid = "EN52", myGrid = "OL62")),
mapOf("1" to 500L),
"http://a/wavelog/",
now = 42L
)
assertEquals(setOf("PM01", "EN52"), settings.getWorkedGrids())
assertEquals(setOf("OL62"), settings.getRoamedGrids())
assertEquals(setOf("EN52"), settings.getWorkedGridQsos().keys)
assertEquals(mapOf("1" to 500L), settings.getWavelogSyncCursors())
assertEquals("http://a/wavelog", settings.getWavelogSyncUrl())
assertEquals(42L, settings.getLastWavelogSyncEpochMs())
assertEquals(2, count)
}
@Test
fun applySyncNeverShrinksStoredData() = runBlocking {
val settings = FakeSettings()
settings.setWorkedGrids(setOf("PM01"))
settings.setRoamedGrids(setOf("OM60"))
applyWavelogSyncResult(settings, listOf(record(1_000L, "EN52", myGrid = "OL62")), mapOf("1" to 10L), "http://a")
applyWavelogSyncResult(settings, listOf(record(2_000L, "EN53", myGrid = "OL63")), mapOf("1" to 20L), "http://a")
assertEquals(setOf("PM01", "EN52", "EN53"), settings.getWorkedGrids())
assertEquals(setOf("OM60", "OL62", "OL63"), settings.getRoamedGrids())
assertEquals(mapOf("1" to 20L), settings.getWavelogSyncCursors())
}
// endregion
private fun record(
start: Long,
theirGrid: String = "",
theirVucc: List<String> = emptyList(),
myGrid: String = "OL62TI",
sat: String = "SO-50"
) = QsoRecord(
startUtcMillis = start,
theirCallsign = "BG5JVM",
myCallsign = "BA7OPF",
theirGrid = theirGrid,
theirVuccGrids = theirVucc,
myGrid = myGrid,
txFrequencyHz = 145_850_000L,
rxFrequencyHz = 436_795_000L,
band = "2M",
rxBand = "70CM",
mode = "FM",
satelliteName = sat,
propagationMode = if (sat.isNotBlank()) "SAT" else "",
status = QsoStatus.COMPLETE
)
private class FakeSettings : ISettingsRepo {
private var workedGrids: Set<String> = emptySet()
private var workedGridQsos: Map<String, List<GridQso>> = emptyMap()
private var roamedGrids: Set<String> = emptySet()
private var cursors: Map<String, Long> = emptyMap()
private var syncUrl: String = ""
private var lastSync: Long = 0L
override fun getWorkedGrids(): Set<String> = workedGrids
override fun setWorkedGrids(grids: Set<String>) { workedGrids = grids }
override fun getWorkedGridQsos(): Map<String, List<GridQso>> = workedGridQsos
override fun setWorkedGridQsos(qsos: Map<String, List<GridQso>>) { workedGridQsos = qsos }
override fun getRoamedGrids(): Set<String> = roamedGrids
override fun setRoamedGrids(grids: Set<String>) { roamedGrids = grids }
override fun getWavelogSyncCursors(): Map<String, Long> = cursors
override fun setWavelogSyncCursors(cursors: Map<String, Long>) { this.cursors = cursors }
override fun getWavelogSyncUrl(): String = syncUrl
override fun setWavelogSyncUrl(url: String) { syncUrl = url }
override fun getLastWavelogSyncEpochMs(): Long = lastSync
override fun setLastWavelogSyncEpochMs(value: Long) { lastSync = value }
override val appVersionName: String get() = TODO()
override val selectedIds: StateFlow<List<Int>> get() = TODO()
override val selectedTypes: StateFlow<List<String>> get() = TODO()
override fun setSelectedIds(ids: List<Int>) = TODO()
override fun setSelectedTypes(types: List<String>) = TODO()
override val passesSettings: StateFlow<PassesSettings> get() = TODO()
override fun setPassesSettings(settings: PassesSettings) = TODO()
override val stationPosition: StateFlow<GeoPos> get() = TODO()
override fun setStationPosition(latitude: Double, longitude: Double, altitude: Double): Boolean = TODO()
override fun setStationPosition(): Boolean = TODO()
override fun setStationPosition(locator: String): Boolean = TODO()
override fun getCurrentGrid(): String? = TODO()
override val databaseState: StateFlow<DatabaseState> get() = TODO()
override fun getSatelliteTypesIds(types: List<String>): List<Int> = TODO()
override fun setSatelliteTypeIds(type: String, ids: List<Int>) = TODO()
override fun updateDatabaseState(state: DatabaseState) = TODO()
override val rcSettings: StateFlow<RCSettings> get() = TODO()
override fun updateRCSettings(settings: RCSettings) = TODO()
override val otherSettings: StateFlow<OtherSettings> get() = TODO()
override fun updateOtherSettings(transform: (OtherSettings) -> OtherSettings) = TODO()
override val dataSourcesSettings: StateFlow<DataSourcesSettings> get() = TODO()
override fun updateDataSourcesSettings(settings: DataSourcesSettings) = TODO()
override val dataSourcesStatus: StateFlow<Map<String, Int>> get() = TODO()
override fun updateDataSourcesStatus(status: Map<String, Int>) = TODO()
override val radioControlSettings: StateFlow<RadioControlSettings> get() = TODO()
override fun updateRadioControlSettings(settings: RadioControlSettings) = TODO()
override fun getSatelliteOffset(catnum: Int): String = TODO()
override fun setSatelliteOffset(catnum: Int, offset: String) = TODO()
override fun getSatelliteMode(catnum: Int): String = TODO()
override fun setSatelliteMode(catnum: Int, mode: String) = TODO()
override fun getAmSatCallsign(): String = TODO()
override fun setAmSatCallsign(callsign: String) = TODO()
override val wavelogUploadSettings: StateFlow<WavelogUploadSettings> get() = TODO()
override fun updateWavelogUploadSettings(settings: WavelogUploadSettings) = TODO()
override fun getWavelogStations(): List<WavelogStationInfo> = emptyList()
override fun setWavelogStations(stations: List<WavelogStationInfo>) = TODO()
override fun getMarkedGridStations(): Map<String, List<MarkedStation>> = TODO()
override fun setMarkedGridStations(stations: Map<String, List<MarkedStation>>) = TODO()
override val lotwSettings: StateFlow<LoTWSettings> get() = TODO()
override fun updateLoTWSettings(settings: LoTWSettings) = TODO()
override fun getLastLotwSyncDate(): String = TODO()
override fun setLastLotwSyncDate(date: String) = TODO()
override fun getLastLotwSyncCallsign(): String = TODO()
override fun setLastLotwSyncCallsign(callsign: String) = TODO()
}
}
@@ -0,0 +1,74 @@
/*
* Look4Sat-BA7OPF. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2026 BA7OPF.
* Based on Look4Sat by 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.presentation
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.rtbishop.look4sat.core.domain.logbook.wavelogSkipSummary
import com.rtbishop.look4sat.core.domain.repository.WavelogUploadPreview
/**
* Confirmation sheet for a Wavelog-mode upload (radar log page and settings logbook):
* shows the station profile, the batch size and the records held back before the POST,
* so the operator commits the batch after seeing what goes out (the Wavelog analog of
* the LoTW preview flow).
*/
@Composable
fun WavelogUploadPreviewDialog(
preview: WavelogUploadPreview,
busy: Boolean,
onConfirm: () -> Unit,
onDismiss: () -> Unit
) {
AlertDialog(
onDismissRequest = onDismiss,
shape = sheetDialogShape(),
containerColor = sheetDialogContainerColor(),
title = { SheetDialogTitle(stringResource(R.string.prefs_logbook_upload_wavelog_title)) },
text = {
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
if (preview.stationLabel.isNotBlank()) {
Text(preview.stationLabel, fontSize = 13.sp)
}
Text("${preview.count} QSO(s) · ${preview.firstUtc} – ${preview.lastUtc}", fontSize = 13.sp)
val skip = wavelogSkipSummary(preview)
if (skip.isNotBlank()) {
Text(skip, fontSize = 12.sp, color = MaterialTheme.colorScheme.error)
}
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)) }
}
)
}
@@ -157,16 +157,20 @@
<string name="prefs_station_lat_text">输入站点的纬度</string>
<string name="prefs_station_lon_text">输入站点的经度</string>
<string name="prefs_locator_title">QTH 网格</string>
<string name="prefs_wavelog_title">Wavelog 已通联网格</string>
<string name="prefs_wavelog_configure">配置</string>
<string name="prefs_wavelog_configured">已从 Wavelog 同步 — 通联 %1$d 个网格</string>
<string name="prefs_wavelog_not_configured">未配置 — 连接你的 Wavelog 日志以在地图上高亮已通联网格</string>
<string name="prefs_wavelog_url">Wavelog 地址</string>
<string name="prefs_wavelog_token">API key :日志 ID</string>
<string name="prefs_wavelog_hint">已同步网格数:%1$d。格式:API key:日志ID。填好直接点同步,会自动保存并拉取。</string>
<string name="prefs_wavelog_sync">立即同步</string>
<string name="prefs_wavelog_syncing">同步中…</string>
<string name="prefs_wavelog_upload_title">Wavelog 日志上传</string>
<string name="prefs_wavelog_upload_title">Wavelog 配置</string>
<string name="prefs_wavelog_exclusive">与 LoTW 二选一配置,两者不兼容。</string>
<string name="prefs_wavelog_sync_incremental">增量同步</string>
<string name="prefs_wavelog_sync_full">全量同步</string>
<string name="prefs_wavelog_clear">清除配置</string>
<string name="prefs_wavelog_clear_title">清除 Wavelog 配置?</string>
<string name="prefs_wavelog_clear_message">清除后将切回 LoTW 模式,LoTW 功能恢复可用。</string>
<string name="prefs_wavelog_switch_title">配置提醒</string>
<string name="prefs_wavelog_switch_message">已配置 LoTW。配置 Wavelog 后,App 内 LoTW 的上传与同步将不可用(两者二选一,不兼容)。继续?</string>
<string name="prefs_wavelog_switch_continue">继续</string>
<string name="prefs_lotw_disabled_wavelog">Wavelog 模式中不可用(二选一)</string>
<string name="prefs_wavelog_upload_not_configured">未配置 — 把本机通联记录上传到 Wavelog</string>
<string name="prefs_wavelog_upload_configured">站点 %1$s — 可以上传</string>
<string name="prefs_wavelog_upload_apikey">API key(读写权限)</string>
@@ -211,7 +215,9 @@
<string name="prefs_logbook_selected">已选 %1$d 条</string>
<string name="prefs_logbook_select_hint">长按记录行可多选重传</string>
<string name="prefs_logbook_resubmit_note">已上传记录将按当前台址就地更新;已被奖项使用的旧网格可能被拒绝。</string>
<string name="prefs_logbook_wavelog_sync">同时同步 Wavelog:%1$d 条</string>
<string name="prefs_logbook_station_label">台址</string>
<string name="prefs_logbook_station_all">全部</string>
<string name="prefs_logbook_upload_wavelog_title">上传到 Wavelog</string>
<string name="lotw_upload_grid_title">上传前核对</string>
<string name="lotw_upload_grid_fix">修改台址</string>
<string name="lotw_upload_grid_ignore">忽略</string>
@@ -223,6 +229,7 @@
<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_password_hint">证书无密码时留空即可</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>
@@ -235,6 +242,8 @@
<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_region_label">省份</string>
<string name="prefs_lotw_upload_region_pending">导入证书后可选</string>
<string name="prefs_lotw_upload_grid">网格(逗号分隔,可多格)</string>
<string name="prefs_lotw_upload_save">保存</string>
<string name="prefs_lotw_upload_close">关闭</string>
@@ -186,16 +186,20 @@
<string name="prefs_station_lat_text">Set your ground station\'s latitude</string>
<string name="prefs_station_lon_text">Set your ground station\'s longitude</string>
<string name="prefs_locator_title">QTH locator settings</string>
<string name="prefs_wavelog_title">Wavelog worked grids</string>
<string name="prefs_wavelog_configure">Configure</string>
<string name="prefs_wavelog_configured">Synced from Wavelog — %1$d grids worked</string>
<string name="prefs_wavelog_not_configured">Not configured — connect your Wavelog logbook to highlight worked grids on the map</string>
<string name="prefs_wavelog_url">Wavelog URL</string>
<string name="prefs_wavelog_token">API key : logbook ID</string>
<string name="prefs_wavelog_hint">Worked grids synced: %1$d. Format: API key, colon, logbook ID. Just tap Sync — credentials are saved automatically.</string>
<string name="prefs_wavelog_sync">Sync now</string>
<string name="prefs_wavelog_syncing">Syncing…</string>
<string name="prefs_wavelog_upload_title">Wavelog log upload</string>
<string name="prefs_wavelog_upload_title">Wavelog configuration</string>
<string name="prefs_wavelog_exclusive">Configure either LoTW or Wavelog — the two cannot be used together.</string>
<string name="prefs_wavelog_sync_incremental">Incremental sync</string>
<string name="prefs_wavelog_sync_full">Full sync</string>
<string name="prefs_wavelog_clear">Clear configuration</string>
<string name="prefs_wavelog_clear_title">Clear the Wavelog configuration?</string>
<string name="prefs_wavelog_clear_message">LoTW features become available again.</string>
<string name="prefs_wavelog_switch_title">Notice</string>
<string name="prefs_wavelog_switch_message">LoTW is already configured. With Wavelog configured, LoTW upload and sync in this app become unavailable (the two are mutually exclusive). Continue?</string>
<string name="prefs_wavelog_switch_continue">Continue</string>
<string name="prefs_lotw_disabled_wavelog">Unavailable while Wavelog is configured</string>
<string name="prefs_wavelog_upload_not_configured">Not configured — upload your local QSO records to Wavelog</string>
<string name="prefs_wavelog_upload_configured">Station %1$s — ready to upload</string>
<string name="prefs_wavelog_upload_apikey">API key (read &amp; write)</string>
@@ -240,7 +244,9 @@
<string name="prefs_logbook_selected">%1$d selected</string>
<string name="prefs_logbook_select_hint">Long-press a row to re-upload corrected records</string>
<string name="prefs_logbook_resubmit_note">Existing contacts will be updated in place — a grid already used for an award may be rejected.</string>
<string name="prefs_logbook_wavelog_sync">Also syncs to Wavelog: %1$d QSO(s)</string>
<string name="prefs_logbook_station_label">Station</string>
<string name="prefs_logbook_station_all">All</string>
<string name="prefs_logbook_upload_wavelog_title">Upload to Wavelog</string>
<string name="lotw_upload_grid_title">Upload check</string>
<string name="lotw_upload_grid_fix">Fix station location</string>
<string name="lotw_upload_grid_ignore">Ignore</string>
@@ -252,6 +258,7 @@
<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_password_hint">Leave blank if the certificate has no 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>
@@ -264,6 +271,8 @@
<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_region_label">Region</string>
<string name="prefs_lotw_upload_region_pending">Import certificate to select</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>
+2
View File
@@ -8,4 +8,6 @@ android {
dependencies {
implementation(libs.other.osmdroid)
// Real org.json for unit tests (android.jar stub throws "not mocked")
testImplementation(libs.test.json)
}
+1 -1
View File
@@ -1 +1 @@
{"type":"dxcc","regions":[{"id":0,"code":176,"name":"Fiji","label_lon":177.9759,"label_lat":-17.9376,"rings":[[[180.0,-16.0671],[180.0,-16.5552],[179.3641,-16.8014],[178.7251,-17.012],[178.5968,-16.6392],[179.0966,-16.434],[179.4135,-16.3791],[180.0,-16.0671]],[[178.1256,-17.5048],[178.3736,-17.3399],[178.7181,-17.6285],[178.5527,-18.1506],[177.9327,-18.288],[177.3815,-18.1643],[177.285,-17.7247],[177.6709,-17.3811]],[[-179.7933,-16.0209],[-179.9174,-16.5018],[-180.0,-16.5552],[-180.0,-16.0671]]]},{"id":1,"code":470,"name":"Tanzania","label_lon":34.1421,"label_lat":-6.2078,"rings":[[[33.9037,-0.95],[34.0726,-1.0598],[37.6987,-3.097],[37.7669,-3.6771],[39.2022,-4.6768],[38.7405,-5.9089],[38.7998,-6.4757],[39.44,-6.84],[39.47,-7.1],[39.1947,-7.7039],[39.252,-8.0078],[39.1865,-8.4855],[39.5357,-9.1124],[39.9496,-10.0984],[40.3166,-10.3171],[40.3166,-10.3171],[39.521,-10.8969],[38.4276,-11.2852],[37.8276,-11.2688],[37.4713,-11.5688],[36.7752,-11.5945],[36.5141,-11.7209],[35.3124,-11.4391],[34.56,-11.52],[34.28,-10.16],[33.9408,-9.6937],[33.7397,-9.4171],[32.7594,-9.2306],[32.1919,-8.9304],[31.5563,-8.762],[31.1578,-8.5946],[30.74,-8.34],[30.74,-8.34],[30.2,-7.08],[29.62,-6.52],[29.42,-5.94],[29.52,-5.42],[29.34,-4.5],[29.7535,-4.4524],[30.1163,-4.0901],[30.5055,-3.5686],[30.7522,-3.3593],[30.743,-3.0343],[30.5277,-2.8076],[30.4697,-2.4139],[30.4697,-2.4138],[30.7583,-2.2872],[30.8161,-1.6989],[30.4191,-1.1347],[30.7699,-1.0146],[31.8662,-1.0274]]]},{"id":2,"code":302,"name":"W. Sahara","label_lon":-12.572,"label_lat":24.2306,"rings":[[[-8.6656,27.6564],[-8.6651,27.5895],[-8.6844,27.3957],[-8.6873,25.8811],[-11.9694,25.9334],[-11.9372,23.3746],[-12.8742,23.2848],[-13.1188,22.7712],[-12.9291,21.3271],[-16.8452,21.3333],[-17.0634,20.9998],[-17.0204,21.4223],[-17.003,21.4207],[-14.751,21.5006],[-14.6308,21.8609],[-14.2212,22.3102],[-13.8911,23.691],[-12.501,24.7701],[-12.0308,26.0309],[-11.7182,26.1041],[-11.3926,26.8834],[-10.5513,26.9908],[-10.1894,26.8609],[-9.7353,26.8609],[-9.413,27.0885],[-8.7949,27.1207],[-8.8178,27.6564]]]},{"id":3,"code":1,"name":"Canada","label_lon":-110.2438,"label_lat":56.7019,"rings":[[[-122.84,49.0],[-122.9742,49.0025],[-124.9102,49.9846],[-125.6246,50.4166],[-127.4356,50.8306],[-127.9928,51.7158],[-127.8503,52.3296],[-129.1298,52.7554],[-129.3052,53.5616],[-130.515,54.2876],[-130.5361,54.8028],[-130.5361,54.8028],[-129.98,55.285],[-130.0078,55.9158],[-131.7078,56.5521],[-132.7304,57.6929],[-133.3556,58.4103],[-134.2711,58.8611],[-134.945,59.2706],[-135.4758,59.7878],[-136.4797,59.4639],[-137.4525,58.905],[-138.3409,59.5621],[-139.039,60.0],[-140.013,60.2768],[-140.9978,60.3064],[-140.9925,66.0],[-140.986,69.712],[-140.986,69.712],[-139.1205,69.471],[-137.5464,68.99],[-136.5036,68.898],[-135.6258,69.3151],[-134.4146,69.6274],[-132.9292,69.5053],[-131.4314,69.9445],[-129.7947,70.1937],[-129.1077,69.7793],[-128.3616,70.0129],[-128.1382,70.4838],[-127.4471,70.3772],[-125.7563,69.4806],[-124.4248,70.1584],[-124.2897,69.3997],[-123.0611,69.5637],[-122.6835,69.8555],[-121.4723,69.7978],[-119.9429,69.3779],[-117.6027,69.0113],[-116.2264,68.8415],[-115.2469,68.9059],[-113.8979,68.3989],[-115.3049,67.9026],[-113.4973,67.6881],[-110.798,67.8061],[-109.9462,67.981],[-108.8802,67.3814],[-107.7924,67.8874],[-108.813,68.3116],[-108.1672,68.6539],[-106.95,68.7],[-106.15,68.8],[-105.3428,68.5612],[-104.3379,68.018],[-103.2211,68.0978],[-101.4543,67.6469],[-99.9019,67.8057],[-98.4432,67.7816],[-98.5586,68.4039],[-97.6695,68.5786],[-96.1199,68.2394],[-96.1259,67.2934],[-95.4894,68.0907],[-94.685,68.0638],[-94.2328,69.069],[-95.3041,69.6857],[-96.4713,70.0898],[-96.3911,71.1948],[-95.2088,71.9205],[-93.89,71.7601],[-92.8782,71.3187],[-91.5196,70.1913],[-92.4069,69.7],[-90.5471,69.4977],[-90.5515,68.475],[-89.2151,69.2587],[-88.0197,68.6151],[-88.3175,67.8734],[-87.3502,67.1987],[-86.3061,67.9215],[-85.5766,68.7846],[-85.522,69.8821],[-84.1008,69.8054],[-82.6226,69.6583],[-81.2804,69.162],[-81.2202,68.6657],[-81.9644,68.1325],[-81.2593,67.5972],[-81.3865,67.1108],[-83.3446,66.4115],[-84.7354,66.2573],[-85.7694,66.5583],[-86.0676,66.0563],[-87.0314,65.213],[-87.3232,64.7756],[-88.483,64.099],[-89.9144,64.0327],[-90.704,63.6102],[-90.77,62.9602],[-91.9334,62.8351],[-93.157,62.0247],[-94.2415,60.8987],[-94.6293,60.1102],[-94.6846,58.9488],[-93.215,58.7821],[-92.7646,57.8457],[-92.297,57.0871],[-90.8977,57.2847],[-89.0395,56.8517],[-88.0398,56.4716],[-87.3242,55.9991],[-86.0712,55.7238],[-85.0118,55.3026],[-83.3606,55.2449],[-82.2729,55.1483],[-82.4362,54.2823],[-82.125,53.277],[-81.4008,52.1579],[-79.9129,51.2084],[-79.143,51.5339],[-78.6019,52.5621],[-79.1242,54.1414],[-79.8296,54.6677],[-78.2287,55.1365],[-77.0956,55.8374],[-76.5414,56.5342],[-76.6232,57.2026],[-77.3023,58.0521],[-78.5169,58.8046],[-77.3368,59.8526],[-77.7727,60.7579],[-78.1069,62.3196],[-77.4107,62.5505],[-75.6962,62.2784],[-74.6682,62.1811],[-73.8399,62.4438],[-72.9085,62.1051],[-71.6771,61.5254],[-71.3737,61.1372],[-69.5904,61.0614],[Line truncated
{"type":"dxcc","regions":[{"id":0,"code":176,"name":"Fiji","label_lon":177.9759,"label_lat":-17.9376,"rings":[[[180.0,-16.0671],[180.0,-16.5552],[179.3641,-16.8014],[178.7251,-17.012],[178.5968,-16.6392],[179.0966,-16.434],[179.4135,-16.3791],[180.0,-16.0671]],[[178.1256,-17.5048],[178.3736,-17.3399],[178.7181,-17.6285],[178.5527,-18.1506],[177.9327,-18.288],[177.3815,-18.1643],[177.285,-17.7247],[177.6709,-17.3811]],[[-179.7933,-16.0209],[-179.9174,-16.5018],[-180.0,-16.5552],[-180.0,-16.0671]]]},{"id":1,"code":470,"name":"Tanzania","label_lon":34.1421,"label_lat":-6.2078,"rings":[[[33.9037,-0.95],[34.0726,-1.0598],[37.6987,-3.097],[37.7669,-3.6771],[39.2022,-4.6768],[38.7405,-5.9089],[38.7998,-6.4757],[39.44,-6.84],[39.47,-7.1],[39.1947,-7.7039],[39.252,-8.0078],[39.1865,-8.4855],[39.5357,-9.1124],[39.9496,-10.0984],[40.3166,-10.3171],[40.3166,-10.3171],[39.521,-10.8969],[38.4276,-11.2852],[37.8276,-11.2688],[37.4713,-11.5688],[36.7752,-11.5945],[36.5141,-11.7209],[35.3124,-11.4391],[34.56,-11.52],[34.28,-10.16],[33.9408,-9.6937],[33.7397,-9.4171],[32.7594,-9.2306],[32.1919,-8.9304],[31.5563,-8.762],[31.1578,-8.5946],[30.74,-8.34],[30.74,-8.34],[30.2,-7.08],[29.62,-6.52],[29.42,-5.94],[29.52,-5.42],[29.34,-4.5],[29.7535,-4.4524],[30.1163,-4.0901],[30.5055,-3.5686],[30.7522,-3.3593],[30.743,-3.0343],[30.5277,-2.8076],[30.4697,-2.4139],[30.4697,-2.4138],[30.7583,-2.2872],[30.8161,-1.6989],[30.4191,-1.1347],[30.7699,-1.0146],[31.8662,-1.0274]]]},{"id":2,"code":302,"name":"W. Sahara","label_lon":-12.572,"label_lat":24.2306,"rings":[[[-8.6656,27.6564],[-8.6651,27.5895],[-8.6844,27.3957],[-8.6873,25.8811],[-11.9694,25.9334],[-11.9372,23.3746],[-12.8742,23.2848],[-13.1188,22.7712],[-12.9291,21.3271],[-16.8452,21.3333],[-17.0634,20.9998],[-17.0204,21.4223],[-17.003,21.4207],[-14.751,21.5006],[-14.6308,21.8609],[-14.2212,22.3102],[-13.8911,23.691],[-12.501,24.7701],[-12.0308,26.0309],[-11.7182,26.1041],[-11.3926,26.8834],[-10.5513,26.9908],[-10.1894,26.8609],[-9.7353,26.8609],[-9.413,27.0885],[-8.7949,27.1207],[-8.8178,27.6564]]]},{"id":3,"code":1,"name":"Canada","label_lon":-110.2438,"label_lat":56.7019,"rings":[[[-122.84,49.0],[-122.9742,49.0025],[-124.9102,49.9846],[-125.6246,50.4166],[-127.4356,50.8306],[-127.9928,51.7158],[-127.8503,52.3296],[-129.1298,52.7554],[-129.3052,53.5616],[-130.515,54.2876],[-130.5361,54.8028],[-130.5361,54.8028],[-129.98,55.285],[-130.0078,55.9158],[-131.7078,56.5521],[-132.7304,57.6929],[-133.3556,58.4103],[-134.2711,58.8611],[-134.945,59.2706],[-135.4758,59.7878],[-136.4797,59.4639],[-137.4525,58.905],[-138.3409,59.5621],[-139.039,60.0],[-140.013,60.2768],[-140.9978,60.3064],[-140.9925,66.0],[-140.986,69.712],[-140.986,69.712],[-139.1205,69.471],[-137.5464,68.99],[-136.5036,68.898],[-135.6258,69.3151],[-134.4146,69.6274],[-132.9292,69.5053],[-131.4314,69.9445],[-129.7947,70.1937],[-129.1077,69.7793],[-128.3616,70.0129],[-128.1382,70.4838],[-127.4471,70.3772],[-125.7563,69.4806],[-124.4248,70.1584],[-124.2897,69.3997],[-123.0611,69.5637],[-122.6835,69.8555],[-121.4723,69.7978],[-119.9429,69.3779],[-117.6027,69.0113],[-116.2264,68.8415],[-115.2469,68.9059],[-113.8979,68.3989],[-115.3049,67.9026],[-113.4973,67.6881],[-110.798,67.8061],[-109.9462,67.981],[-108.8802,67.3814],[-107.7924,67.8874],[-108.813,68.3116],[-108.1672,68.6539],[-106.95,68.7],[-106.15,68.8],[-105.3428,68.5612],[-104.3379,68.018],[-103.2211,68.0978],[-101.4543,67.6469],[-99.9019,67.8057],[-98.4432,67.7816],[-98.5586,68.4039],[-97.6695,68.5786],[-96.1199,68.2394],[-96.1259,67.2934],[-95.4894,68.0907],[-94.685,68.0638],[-94.2328,69.069],[-95.3041,69.6857],[-96.4713,70.0898],[-96.3911,71.1948],[-95.2088,71.9205],[-93.89,71.7601],[-92.8782,71.3187],[-91.5196,70.1913],[-92.4069,69.7],[-90.5471,69.4977],[-90.5515,68.475],[-89.2151,69.2587],[-88.0197,68.6151],[-88.3175,67.8734],[-87.3502,67.1987],[-86.3061,67.9215],[-85.5766,68.7846],[-85.522,69.8821],[-84.1008,69.8054],[-82.6226,69.6583],[-81.2804,69.162],[-81.2202,68.6657],[-81.9644,68.1325],[-81.2593,67.5972],[-81.3865,67.1108],[-83.3446,66.4115],[-84.7354,66.2573],[-85.7694,66.5583],[-86.0676,66.0563],[-87.0314,65.213],[-87.3232,64.7756],[-88.483,64.099],[-89.9144,64.0327],[-90.704,63.6102],[-90.77,62.9602],[-91.9334,62.8351],[-93.157,62.0247],[-94.2415,60.8987],[-94.6293,60.1102],[-94.6846,58.9488],[-93.215,58.7821],[-92.7646,57.8457],[-92.297,57.0871],[-90.8977,57.2847],[-89.0395,56.8517],[-88.0398,56.4716],[-87.3242,55.9991],[-86.0712,55.7238],[-85.0118,55.3026],[-83.3606,55.2449],[-82.2729,55.1483],[-82.4362,54.2823],[-82.125,53.277],[-81.4008,52.1579],[-79.9129,51.2084],[-79.143,51.5339],[-78.6019,52.5621],[-79.1242,54.1414],[-79.8296,54.6677],[-78.2287,55.1365],[-77.0956,55.8374],[-76.5414,56.5342],[-76.6232,57.2026],[-77.3023,58.0521],[-78.5169,58.8046],[-77.3368,59.8526],[-77.7727,60.7579],[-78.1069,62.3196],[-77.4107,62.5505],[-75.6962,62.2784],[-74.6682,62.1811],[-73.8399,62.4438],[-72.9085,62.1051],[-71.6771,61.5254],[-71.3737,61.1372],[-69.5904,61.0614],[Line truncated
@@ -314,16 +314,24 @@ private fun MapScreen(
// 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.
// Each group's `worked` cells are derived through scopedToStation (the
// covering rule): a single-grid 台址 also counts the cells worked by any
// multi-grid 台址 covering it (OL62 ← OL62/63, 四格点同理) — the reverse
// never leaks (2026-10-05 用户定稿, 方案 A).
val stationGroups: List<StationGroup> = remember(uiState.workedGridQsos) {
val byGridSet = LinkedHashMap<String, Pair<Set<String>, MutableSet<String>>>()
for ((grid, qsos) in uiState.workedGridQsos) {
val gridSets = LinkedHashMap<String, Set<String>>()
for ((_, qsos) in uiState.workedGridQsos) {
for (q in qsos) {
val key = q.stationGridSetKey() ?: continue
byGridSet.getOrPut(key) { q.myStationGrids() to mutableSetOf() }.second.add(grid)
gridSets.getOrPut(key) { q.myStationGrids() }
}
}
byGridSet.map { (key, v) -> StationGroup(id = key, grids = v.first, worked = v.second) }
.sortedByDescending { it.workedCount }
gridSets.map { (key, grids) ->
val worked = uiState.workedGridQsos.filterValues { qsos ->
qsos.scopedToStation(key).isNotEmpty()
}.keys
StationGroup(id = key, grids = grids, worked = worked)
}.sortedByDescending { it.workedCount }
}
// Selected 台址 for VUCC counting; defaults to the group with the most
// worked grids. Null = "All" (every 台址 combined). Empty when the store
@@ -94,22 +94,11 @@ class MapViewModel(
}
}
viewModelScope.launch {
settingsRepo.wavelogSettings.collectLatest { _ ->
val workedGrids = settingsRepo.getWorkedGrids()
val markedGrids = settingsRepo.getMarkedGridStations()
// Marked stations auto-clear once their grid becomes worked:
// the reminder has served its purpose (user req: 网格变绿后标记自动清除).
val stale = markedGrids.keys.filter { it in workedGrids }
val activeMarks = if (stale.isEmpty()) markedGrids else markedGrids - stale.toSet()
if (stale.isNotEmpty()) settingsRepo.setMarkedGridStations(activeMarks)
_uiState.update {
it.copy(
workedGrids = workedGrids,
workedGridQsos = settingsRepo.getWorkedGridQsos(),
roamedGrids = settingsRepo.getRoamedGrids(),
markedGrids = activeMarks
)
}
// Grid + mark data reloads whenever the screen becomes visible (and once at
// init): the LoTW/Wavelog syncs run from Settings or the app start, so
// re-entering the map picks their results up.
isScreenVisible.collect { visible ->
if (visible) refreshMapGridData()
}
}
val (selectedCatNum, _) = satelliteRepo.selectedPass.value
@@ -117,6 +106,25 @@ class MapViewModel(
getStationPosition()
}
/** Loads the worked-grid / mark data into the map state (see the visibility collector). */
private fun refreshMapGridData() {
val workedGrids = settingsRepo.getWorkedGrids()
val markedGrids = settingsRepo.getMarkedGridStations()
// Marked stations auto-clear once their grid becomes worked:
// the reminder has served its purpose (user req: 网格变绿后标记自动清除).
val stale = markedGrids.keys.filter { it in workedGrids }
val activeMarks = if (stale.isEmpty()) markedGrids else markedGrids - stale.toSet()
if (stale.isNotEmpty()) settingsRepo.setMarkedGridStations(activeMarks)
_uiState.update {
it.copy(
workedGrids = workedGrids,
workedGridQsos = settingsRepo.getWorkedGridQsos(),
roamedGrids = settingsRepo.getRoamedGrids(),
markedGrids = activeMarks
)
}
}
fun onAction(action: MapAction) {
when (action) {
MapAction.SelectPrev -> scrollSelection(true)
@@ -0,0 +1,183 @@
/*
* 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.map
import java.io.File
import org.json.JSONObject
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* Guards `assets/awards/dxcc.json`, the DXCC boundary asset behind the grid-mode
* award overlay (`AwardBoundaryOverlay` matches worked codes against
* `region.code`). The 2026-10 rebuild split Russia into European (54) /
* Asiatic (15), extracted sub-entities (Alaska, Hawaii, East Malaysia,
* French Guiana, Corsica, Crete, Sardinia, Kaliningrad, Franz Josef Land)
* and partitioned the UK into England/Scotland/Wales/Northern Ireland —
* and re-keyed Germany (81 -> 230) / Palestine (196 -> 510) to current ADIF
* codes — these tests pin the invariants that regeneration must keep.
*/
class DxccAssetTest {
private val assetRelPath = "src/main/assets/awards/dxcc.json"
private data class CityCheck(
val name: String, val lon: Double, val lat: Double,
val inside: Int, val outside: Int
)
private fun loadRegions(): List<JSONObject> {
// Unit-test working dir varies (module dir vs repo root) — walk upwards.
val file = generateSequence(File("").absoluteFile) { it.parentFile }
.mapNotNull { base ->
listOf(File(base, assetRelPath), File(base, "feature/map/$assetRelPath"))
.firstOrNull { it.isFile }
}
.firstOrNull()
?: error("dxcc.json not found starting from ${File("").absolutePath}")
val root = JSONObject(file.readText())
val arr = root.getJSONArray("regions")
return (0 until arr.length()).map { arr.getJSONObject(it) }
}
private fun List<JSONObject>.byCode(code: Int): JSONObject =
first { it.getInt("code") == code }
/** Even-odd ray casting across all rings of a region (rings are disjoint parts). */
private fun regionContains(region: JSONObject, lon: Double, lat: Double): Boolean {
val rings = region.getJSONArray("rings")
var inside = false
for (r in 0 until rings.length()) {
val ring = rings.getJSONArray(r)
val n = ring.length()
for (j in 0 until n) {
val a = ring.getJSONArray(j)
val b = ring.getJSONArray((j + 1) % n)
val y1 = a.getDouble(1)
val y2 = b.getDouble(1)
if ((y1 > lat) != (y2 > lat)) {
val x = a.getDouble(0) + (lat - y1) / (y2 - y1) * (b.getDouble(0) - a.getDouble(0))
if (x > lon) inside = !inside
}
}
}
return inside
}
@Test
fun `required ADIF entity codes are present exactly once`() {
val regions = loadRegions()
val codes = regions.map { it.getInt("code") }
assertEquals("duplicate codes", codes.size, codes.toSet().size)
val required = listOf(
15, 54, // Asiatic / European Russia
6, 110, 291, // Alaska / Hawaii / rest of USA
46, 299, // East / West Malaysia
63, 214, 227, // French Guiana / Corsica / rest of France
40, 236, // Crete / rest of Greece
225, 248, // Sardinia / rest of Italy
126, 61, // Kaliningrad / Franz Josef Land
223, 279, 294, 265, // England / Scotland / Wales / N. Ireland
230, 510 // Germany / Palestine (current ADIF codes)
)
required.forEach { code -> assertTrue("missing code $code", code in codes) }
}
@Test
fun `no region uses an ADIF-deleted entity code`() {
// ADIF 3.1.7 deleted DXCC codes. A polygon keyed to a deleted code
// never matches LoTW exports (they carry the active code, e.g.
// Germany 81 -> 230, Palestine 196 -> 510), so the award never fills.
val deleted = setOf(
2, 8, 19, 23, 25, 26, 28, 30, 39, 42, 44, 55, 57, 58, 59, 67, 68,
81, 85, 93, 101, 102, 113, 115, 119, 127, 128, 134, 139, 151, 154, 155,
164, 178, 183, 184, 186, 193, 194, 196, 198, 200, 208, 210, 218, 220,
226, 228, 229, 231, 243, 244, 255, 258, 261, 264, 267, 268, 271, 307,
488, 493
)
loadRegions().forEach { region ->
val code = region.getInt("code")
assertTrue("${region.getString("name")} uses deleted code $code", code !in deleted)
}
}
@Test
fun `entities carry their ADIF names`() {
val regions = loadRegions()
assertEquals("England", regions.byCode(223).getString("name"))
assertEquals("European Russia", regions.byCode(54).getString("name"))
assertEquals("Asiatic Russia", regions.byCode(15).getString("name"))
assertEquals("Scotland", regions.byCode(279).getString("name"))
assertEquals("Alaska", regions.byCode(6).getString("name"))
}
@Test
fun `every region has drawable rings within coordinate bounds`() {
loadRegions().forEach { region ->
val name = region.getString("name")
val rings = region.getJSONArray("rings")
assertTrue("$name: no rings", rings.length() >= 1)
for (r in 0 until rings.length()) {
val ring = rings.getJSONArray(r)
assertTrue("$name: ring $r has ${ring.length()} pts", ring.length() >= 3)
for (p in 0 until ring.length()) {
val pt = ring.getJSONArray(p)
val lon = pt.getDouble(0)
val lat = pt.getDouble(1)
assertTrue("$name: lon out of range $lon", lon in -180.0..180.0)
assertTrue("$name: lat out of range $lat", lat in -90.0..90.0)
}
}
}
}
@Test
fun `cities fall on the correct side of the rebuilt boundaries`() {
val regions = loadRegions()
val checks = listOf(
// European/Asiatic Russia split along the Urals (OSM ridge + Ural river)
CityCheck("Moscow", 37.61, 55.75, inside = 54, outside = 15),
CityCheck("Perm", 56.24, 58.01, inside = 54, outside = 15),
CityCheck("Yekaterinburg", 60.61, 56.84, inside = 15, outside = 54),
CityCheck("Novosibirsk", 82.93, 55.03, inside = 15, outside = 54),
CityCheck("Kaliningrad", 20.51, 54.71, inside = 126, outside = 54),
// UK four-entity partition
CityCheck("London", -0.13, 51.51, inside = 223, outside = 279),
CityCheck("Edinburgh", -3.19, 55.95, inside = 279, outside = 223),
CityCheck("Cardiff", -3.18, 51.48, inside = 294, outside = 223),
CityCheck("Belfast", -5.93, 54.60, inside = 265, outside = 223),
// Extracted sub-entities
CityCheck("Anchorage", -149.90, 61.22, inside = 6, outside = 291),
CityCheck("Central Oahu", -157.90, 21.45, inside = 110, outside = 291),
CityCheck("Los Angeles", -118.24, 34.05, inside = 291, outside = 6),
CityCheck("Kuching", 110.34, 1.55, inside = 46, outside = 299),
CityCheck("Kuala Lumpur", 101.69, 3.14, inside = 299, outside = 46),
CityCheck("Cayenne", -52.32, 4.94, inside = 63, outside = 227),
CityCheck("Ajaccio", 8.74, 41.93, inside = 214, outside = 227),
CityCheck("Heraklion", 25.14, 35.34, inside = 40, outside = 236),
CityCheck("Cagliari", 9.12, 39.22, inside = 225, outside = 248)
)
for (c in checks) {
assertTrue("${c.name} should be in ${c.inside}",
regionContains(regions.byCode(c.inside), c.lon, c.lat))
assertTrue("${c.name} should NOT be in ${c.outside}",
!regionContains(regions.byCode(c.outside), c.lon, c.lat))
}
}
}
@@ -27,7 +27,7 @@ import com.rtbishop.look4sat.core.domain.model.PassesSettings
import com.rtbishop.look4sat.core.domain.model.RCSettings
import com.rtbishop.look4sat.core.domain.model.RadioControlSettings
import com.rtbishop.look4sat.core.domain.model.SatRadio
import com.rtbishop.look4sat.core.domain.model.WavelogSettings
import com.rtbishop.look4sat.core.domain.model.WavelogUploadSettings
import com.rtbishop.look4sat.core.domain.predict.GeoPos
import com.rtbishop.look4sat.core.domain.predict.OrbitalObject
@@ -107,7 +107,7 @@ class FakeSettingsRepo(
override val radioControlSettings: StateFlow<RadioControlSettings> = MutableStateFlow(
RadioControlSettings(false, RadioControlSettings.MODEL_YAESU_FT817, "", "", "", "", 9600)
)
override val wavelogSettings: StateFlow<WavelogSettings> = MutableStateFlow(WavelogSettings())
override val wavelogUploadSettings: StateFlow<WavelogUploadSettings> = MutableStateFlow(WavelogUploadSettings())
override fun updateWavelogUploadSettings(settings: WavelogUploadSettings) = Unit
override val lotwSettings: StateFlow<LoTWSettings> = MutableStateFlow(LoTWSettings())
@@ -137,7 +137,7 @@ class FakeSettingsRepo(
override fun setSatelliteMode(catnum: Int, mode: String) = TODO()
override fun getAmSatCallsign(): String = ""
override fun setAmSatCallsign(callsign: String) = TODO()
override fun updateWavelogSettings(settings: WavelogSettings) = TODO()
override fun getWorkedGrids(): Set<String> = TODO()
override fun setWorkedGrids(grids: Set<String>) = TODO()
override fun getWorkedGridQsos(): Map<String, List<GridQso>> = TODO()
@@ -151,4 +151,12 @@ class FakeSettingsRepo(
override fun setLastLotwSyncDate(date: String) = Unit
override fun getLastLotwSyncCallsign(): String = ""
override fun setLastLotwSyncCallsign(callsign: String) = Unit
override fun getWavelogStations(): List<com.rtbishop.look4sat.core.domain.repository.WavelogStationInfo> = emptyList()
override fun setWavelogStations(stations: List<com.rtbishop.look4sat.core.domain.repository.WavelogStationInfo>) = Unit
override fun getWavelogSyncCursors(): Map<String, Long> = emptyMap()
override fun setWavelogSyncCursors(cursors: Map<String, Long>) = Unit
override fun getWavelogSyncUrl(): String = ""
override fun setWavelogSyncUrl(url: String) = Unit
override fun getLastWavelogSyncEpochMs(): Long = 0L
override fun setLastWavelogSyncEpochMs(value: Long) = Unit
}
@@ -68,6 +68,7 @@ import com.rtbishop.look4sat.core.presentation.sheetDialogContainerColor
import com.rtbishop.look4sat.core.presentation.sheetDialogShape
import com.rtbishop.look4sat.core.presentation.gridsLabel
import com.rtbishop.look4sat.core.presentation.LoTWPositionWarningDialog
import com.rtbishop.look4sat.core.presentation.WavelogUploadPreviewDialog
@Composable
fun LogPage(
@@ -212,7 +213,7 @@ fun LogPage(
onClick = logViewModel::prepareUpload,
enabled = !logUiState.busy,
modifier = Modifier.weight(1f)
) { Text("上传 LoTW", fontSize = 13.sp) }
) { Text(if (logUiState.wavelogMode) "上传 Wavelog" else "上传 LoTW", fontSize = 13.sp) }
}
if (logUiState.busy) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
@@ -260,19 +261,27 @@ fun LogPage(
UploadPreviewDialog(
preview = preview,
busy = logUiState.busy,
wavelogCount = logUiState.wavelogPending?.count ?: 0,
onConfirm = logViewModel::confirmUpload,
onDismiss = logViewModel::dismissPreview
)
}
}
logUiState.wavelogPreview?.let { preview ->
WavelogUploadPreviewDialog(
preview = preview,
busy = logUiState.busy,
onConfirm = logViewModel::confirmWavelogUpload,
onDismiss = logViewModel::dismissWavelogPreview
)
}
if (logUiState.message.isNotBlank()) {
AlertDialog(
onDismissRequest = logViewModel::clearMessage,
shape = sheetDialogShape(),
containerColor = sheetDialogContainerColor(),
title = { SheetDialogTitle("LoTW Upload") },
title = { SheetDialogTitle(if (logUiState.wavelogMode) "Wavelog Upload" else "LoTW Upload") },
text = { Text(logUiState.message) },
confirmButton = {
TextButton(onClick = logViewModel::clearMessage) { Text("OK") }
@@ -407,7 +416,6 @@ private fun PostDialog(
private fun UploadPreviewDialog(
preview: com.rtbishop.look4sat.core.domain.repository.LoTWUploadPreview,
busy: Boolean,
wavelogCount: Int = 0,
onConfirm: () -> Unit,
onDismiss: () -> Unit
) {
@@ -456,13 +464,6 @@ private fun UploadPreviewDialog(
}
Text(parts.joinToString(" · "), fontSize = 12.sp, color = MaterialTheme.colorScheme.error)
}
if (wavelogCount > 0) {
Text(
"同时同步 Wavelog:$wavelogCount 条",
fontSize = 12.sp,
color = MaterialTheme.colorScheme.primary
)
}
Text(preview.contacts.joinToString("\n") { it }, fontSize = 12.sp, maxLines = 8)
}
},
@@ -86,9 +86,10 @@ data class LogUiState(
val message: String = "",
val postText: String? = null,
val busy: Boolean = false,
/** Wavelog piggyback: batch prepared alongside the LoTW preview (see prepareUpload);
* uploaded right after a confirmed LoTW upload. Null when Wavelog is not ready. */
val wavelogPending: WavelogUploadPreview? = null,
/** True when uploads route through Wavelog (Wavelog configured — LoTW paused). */
val wavelogMode: Boolean = false,
/** Wavelog mode: the prepared batch awaiting the operator's confirmation. */
val wavelogPreview: WavelogUploadPreview? = null,
/** Out-of-window contact waiting for the operator's confirmation (null when closed). */
val outOfWindowLog: OutOfWindowLog? = null
)
@@ -124,6 +125,13 @@ class LogViewModel(
)
}
}
// Upload routing follows the Wavelog configuration: once it is ready the log
// page uploads through Wavelog and skips LoTW entirely (the one-of-two rule).
viewModelScope.launch {
settingsRepo.wavelogUploadSettings.collect { settings ->
_uiState.update { it.copy(wavelogMode = settings.isReady) }
}
}
// The roaming hint tracks the freshest position data: refresh on init and every fix.
viewModelScope.launch {
settingsRepo.stationPosition.collect { refreshPositionHintNow() }
@@ -268,20 +276,16 @@ class LogViewModel(
_uiState.update { it.copy(busy = true, message = "") }
try {
val all = qsoRepository.records.first()
val wavelogSettings = settingsRepo.wavelogUploadSettings.value
if (wavelogSettings.isReady) {
// Wavelog mode: the upload goes to Wavelog only (LoTW stays out).
prepareWavelogUpload(all, wavelogSettings)
return@launch
}
// Only local (non-confirmed) records are candidates for upload;
// LoTW-imported confirmations are the feedback side.
val pending = all.filter { !it.lotwConfirmed && !it.lotwUploaded && it.status == QsoStatus.COMPLETE }
val audit = lotwUploadRepository.audit(pending)
// Wavelog piggyback (option A): prepare its batch while we are here, so
// the LoTW preview can show the count and confirm can post it directly.
val wavelogSettings = settingsRepo.wavelogUploadSettings.value
val wavelogPreview = if (wavelogSettings.isReady) {
runCatching {
wavelogUploadRepository.prepare(wavelogUploadCandidates(all), wavelogSettings)
}.getOrNull()
} else {
null
}
if (audit.pending == 0) {
val msg = when {
audit.unavailable > 0 -> unavailableUploadSummary(
@@ -291,14 +295,7 @@ class LogViewModel(
audit.unknown > 0 -> "${audit.unknown} QSO(s) had an unknown upload result — not retried automatically"
else -> "No pending QSOs to upload"
}
// No LoTW work to do — still flush the Wavelog backlog (catch-up): a
// freshly configured Wavelog syncs without waiting for new QSOs.
val wavelogMsg = wavelogPreview?.let {
if (it.count > 0) uploadWavelogNow(it, wavelogSettings) else wavelogIdleSegment(it)
}
_uiState.update {
it.copy(busy = false, message = listOfNotNull(msg, wavelogMsg).joinToString("\n"))
}
_uiState.update { it.copy(busy = false, message = msg) }
return@launch
}
val preview = lotwUploadRepository.prepare(pending, false)
@@ -309,7 +306,6 @@ class LogViewModel(
it.copy(
busy = false,
preview = preview,
wavelogPending = wavelogPreview,
uploadPositionWarning = positionWarning(settingsRepo.getCurrentGrid(), preview.grids)
)
}
@@ -321,6 +317,24 @@ class LogViewModel(
}
}
/** Wavelog-mode branch of [prepareUpload]: prepare the batch and ask for confirmation. */
private suspend fun prepareWavelogUpload(all: List<QsoRecord>, settings: WavelogUploadSettings) {
val preview = runCatching {
wavelogUploadRepository.prepare(wavelogUploadCandidates(all), settings)
}.getOrNull()
if (preview == null) {
_uiState.update { it.copy(busy = false, message = "Wavelog upload failed to prepare — check the configuration") }
return
}
if (preview.count == 0) {
_uiState.update {
it.copy(busy = false, message = wavelogIdleSegment(preview) ?: "No pending QSOs to upload to Wavelog")
}
return
}
_uiState.update { it.copy(busy = false, wavelogPreview = preview) }
}
fun confirmUpload() {
val preview = _uiState.value.preview ?: return
viewModelScope.launch {
@@ -338,27 +352,28 @@ class LogViewModel(
LoTWUploadResult.Unknown -> "Unknown result — will not auto-retry"
LoTWUploadResult.ExpiredPreview -> "Preview expired — tap upload again"
}
// Wavelog piggyback: rides the same confirmation, independent of the LoTW
// outcome — a Wavelog failure leaves records unmarked for the next upload.
val wavelogPending = _uiState.value.wavelogPending
val wavelogMsg = wavelogPending?.let { wl ->
if (wl.count > 0) uploadWavelogNow(wl, settingsRepo.wavelogUploadSettings.value)
else wavelogIdleSegment(wl)
}
_uiState.update {
it.copy(
busy = false, preview = null, wavelogPending = null,
message = listOfNotNull(msg, wavelogMsg).joinToString("\n")
)
}
_uiState.update { it.copy(busy = false, preview = null, message = msg) }
}
}
fun dismissPreview() = _uiState.update { it.copy(preview = null, wavelogPending = null) }
/** Confirms the Wavelog-mode batch prepared by [prepareUpload]. */
fun confirmWavelogUpload() {
val preview = _uiState.value.wavelogPreview ?: return
viewModelScope.launch {
_uiState.update { it.copy(busy = true) }
val msg = uploadWavelogNow(preview, settingsRepo.wavelogUploadSettings.value)
_uiState.update { it.copy(busy = false, wavelogPreview = null, message = msg) }
}
}
fun dismissPreview() = _uiState.update { it.copy(preview = null) }
fun dismissWavelogPreview() = _uiState.update { it.copy(wavelogPreview = null) }
/**
* Uploads one prepared Wavelog batch (piggyback) and marks what the server
* accepted. Returns the operator-facing message segment for this upload.
* Uploads one prepared Wavelog batch and marks what the server accepted (stamped
* with the station profile the batch went out through). Returns the operator-facing
* message for this upload.
*/
private suspend fun uploadWavelogNow(
preview: WavelogUploadPreview,
@@ -366,7 +381,7 @@ class LogViewModel(
): String = try {
val outcome = wavelogUploadRepository.upload(preview, settings)
if (outcome is WavelogUploadOutcome.Imported && outcome.markIds.isNotEmpty()) {
qsoRepository.markWavelogUploaded(outcome.markIds)
qsoRepository.markWavelogUploaded(outcome.markIds, settings.stationId)
}
wavelogConfirmedSegment(outcome, preview)
} catch (_: Exception) {
@@ -380,7 +395,7 @@ class LogViewModel(
fun abandonForGridFix() {
lotwUploadRepository.discardPreview()
lastUploadedIds = emptyList()
_uiState.update { it.copy(uploadPositionWarning = null, preview = null, wavelogPending = null) }
_uiState.update { it.copy(uploadPositionWarning = null, preview = null) }
}
/** Recompute the log-page roaming hint from the freshest station/position data. */
@@ -44,6 +44,7 @@ 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.draw.alpha
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.KeyboardCapitalization
@@ -58,15 +59,21 @@ 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
import kotlinx.coroutines.delay
@Composable
fun LoTWUploadCard(
hasCertificate: Boolean,
stationGrid: String,
enabled: Boolean = true,
showUploadConfigDialog: () -> Unit
) {
ElevatedCard(modifier = Modifier.fillMaxWidth().clickable { showUploadConfigDialog() }) {
Column(modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)) {
ElevatedCard(modifier = Modifier.fillMaxWidth().clickable(enabled = enabled) { showUploadConfigDialog() }) {
Column(
modifier = Modifier
.padding(horizontal = 8.dp, vertical = 4.dp)
.alpha(if (enabled) 1f else 0.5f)
) {
Text(
text = stringResource(R.string.prefs_lotw_upload_title),
color = MaterialTheme.colorScheme.primary
@@ -81,6 +88,15 @@ fun LoTWUploadCard(
style = MaterialTheme.typography.bodySmall,
maxLines = 2
)
if (!enabled) {
Spacer(modifier = Modifier.height(2.dp))
Text(
text = stringResource(R.string.prefs_lotw_disabled_wavelog),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 2
)
}
}
}
}
@@ -96,6 +112,7 @@ fun LoTWUploadConfigDialog(
initialGrid: String = "",
onDismiss: () -> Unit,
onImport: (ByteArray, CharArray) -> Unit,
onPreview: (ByteArray, CharArray) -> Unit,
onRemove: () -> Unit,
onSaveStation: (LoTWStation) -> Unit
) {
@@ -140,6 +157,19 @@ fun LoTWUploadConfigDialog(
}
}.getOrNull() ?: uri.lastPathSegment.orEmpty()
// Surface the certificate's region field before the import is confirmed: re-read
// the picked .p12 whenever file or password changes (debounced) and hand it to a
// silent preview — a wrong password simply keeps the pending hint, a successful
// parse fills the Province/State dropdown in the station form below.
LaunchedEffect(selectedFile, password) {
val uri = selectedFile ?: return@LaunchedEffect
delay(400)
val bytes = runCatching {
context.contentResolver.openInputStream(uri)?.use { it.readBytes() }
}.getOrNull()
if (bytes != null && bytes.isNotEmpty()) onPreview(bytes, password.toCharArray())
}
SharedDialog(
title = stringResource(R.string.prefs_lotw_upload_title),
onDismissRequest = onDismiss,
@@ -200,6 +230,11 @@ fun LoTWUploadConfigDialog(
visualTransformation = PasswordVisualTransformation(),
modifier = Modifier.fillMaxWidth()
)
Text(
text = stringResource(R.string.prefs_lotw_upload_password_hint),
fontSize = 11.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
CardButton(
onClick = {
val bytes = runCatching {
@@ -212,7 +247,9 @@ fun LoTWUploadConfigDialog(
}
},
text = stringResource(R.string.prefs_lotw_upload_import_confirm),
isEnabled = password.isNotBlank() && !busy,
// An empty field is a legitimate attempt: certificates exported
// without a password import with a blank password.
isEnabled = !busy,
modifier = Modifier.fillMaxWidth()
)
}
@@ -232,12 +269,15 @@ fun LoTWUploadConfigDialog(
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.
// Country-specific region field (US_STATE, CN_PROVINCE, …): the entry is part
// of the form from the first frame (pending hint while no DXCC is known), gets
// its options as soon as a certificate is previewed/imported, and a selection
// fills CQZ/ITUZ. Entities without a region field keep it hidden.
// 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) {
val regionPending = certificate == null && stationMeta == null
if (regionField != null || regionPending) {
var regionExpanded by remember { mutableStateOf(false) }
val fieldShape = MaterialTheme.shapes.extraSmall
Box(
@@ -246,21 +286,25 @@ fun LoTWUploadConfigDialog(
.clip(fieldShape)
.background(MaterialTheme.colorScheme.surface)
.border(1.dp, MaterialTheme.colorScheme.outline, fieldShape)
.clickable { regionExpanded = !regionExpanded }
.clickable(enabled = regionField != null) { regionExpanded = !regionExpanded }
.padding(horizontal = 12.dp, vertical = 8.dp)
) {
Column {
Text(regionField.label, fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
Text(
text = regionField?.label ?: stringResource(R.string.prefs_lotw_upload_region_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"
text = when {
regionPending -> stringResource(R.string.prefs_lotw_upload_region_pending)
region.isBlank() -> stringResource(R.string.prefs_lotw_upload_region_hint)
else -> "$region — $selectedRegionName"
},
fontSize = 16.sp,
color = if (region.isBlank()) {
color = if (regionPending || region.isBlank()) {
MaterialTheme.colorScheme.onSurfaceVariant
} else {
MaterialTheme.colorScheme.onSurface
@@ -277,9 +321,9 @@ fun LoTWUploadConfigDialog(
}
}
}
AnimatedVisibility(visible = regionExpanded) {
AnimatedVisibility(visible = regionExpanded && regionField != null) {
LazyColumn(modifier = Modifier.fillMaxWidth().heightIn(max = 280.dp)) {
items(regionField.options, key = { it.code }) { option ->
items(regionField?.options.orEmpty(), key = { it.code }) { option ->
DropdownMenuItem(
text = { Text("${option.code} — ${option.name}", fontSize = 13.sp) },
onClick = {
@@ -64,6 +64,9 @@ fun LoTWUploadDestination(navigateUp: () -> Unit) {
onImport = { bytes, password ->
viewModel.onAction(SettingsAction.ImportLoTWCertificate(bytes, password))
},
onPreview = { bytes, password ->
viewModel.onAction(SettingsAction.PreviewLoTWCertificate(bytes, password))
},
onRemove = { viewModel.onAction(SettingsAction.RemoveLoTWCertificate) },
onSaveStation = { viewModel.onAction(SettingsAction.SaveLoTWStation(it)) }
)
@@ -13,6 +13,7 @@ import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
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
@@ -23,6 +24,8 @@ 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.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
@@ -54,6 +57,7 @@ import com.rtbishop.look4sat.core.presentation.sheetDialogContainerColor
import com.rtbishop.look4sat.core.presentation.sheetDialogShape
import com.rtbishop.look4sat.core.presentation.gridsLabel
import com.rtbishop.look4sat.core.presentation.LoTWPositionWarningDialog
import com.rtbishop.look4sat.core.presentation.WavelogUploadPreviewDialog
import com.rtbishop.look4sat.core.presentation.R
import com.rtbishop.look4sat.core.presentation.SharedDialog
import com.rtbishop.look4sat.core.presentation.SwipeController
@@ -105,8 +109,14 @@ fun LogbookDialog(
onToggleSelection: (Long) -> Unit = {},
onExitSelection: () -> Unit = {},
onResubmitSelected: () -> Unit = {},
/** Submit count of the Wavelog batch riding this upload (0 hides the note). */
wavelogCount: Int = 0
/** Wavelog mode: the prepared batch awaiting confirmation (null otherwise). */
wavelogPreview: com.rtbishop.look4sat.core.domain.repository.WavelogUploadPreview? = null,
/** True when uploads route through Wavelog — titles and previews follow the mode. */
wavelogMode: Boolean = false,
/** Wavelog station profiles for the 台址 selector (empty hides the selector). */
stationOptions: List<com.rtbishop.look4sat.core.domain.repository.WavelogStationInfo> = emptyList(),
stationFilter: String? = null,
onStationFilterChange: (String?) -> Unit = {}
) {
val swipeController = rememberSwipeController()
// Entering selection mode closes any row left swiped open — reveals are off there.
@@ -147,6 +157,51 @@ fun LogbookDialog(
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
if (stationOptions.isNotEmpty()) {
// 台址 selector: filters the list to one Wavelog station profile
// (its QSOs) — "All" keeps everything. The menu scrolls natively.
var stationMenuOpen by remember { mutableStateOf(false) }
val currentLabel = stationOptions.firstOrNull { it.id == stationFilter }?.let { station ->
listOf(station.name, station.callsign).filter { it.isNotBlank() }.joinToString(" · ")
} ?: stringResource(R.string.prefs_logbook_station_all)
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp)
) {
Text(stringResource(R.string.prefs_logbook_station_label), fontSize = 12.sp)
Box {
TextButton(onClick = { stationMenuOpen = true }) {
Text(currentLabel, fontSize = 12.sp)
}
DropdownMenu(
expanded = stationMenuOpen,
onDismissRequest = { stationMenuOpen = false }
) {
DropdownMenuItem(
text = { Text(stringResource(R.string.prefs_logbook_station_all)) },
onClick = {
onStationFilterChange(null)
stationMenuOpen = false
}
)
stationOptions.forEach { station ->
DropdownMenuItem(
text = {
Text(
listOf(station.name, station.callsign, station.grid)
.filter { it.isNotBlank() }.joinToString(" · ")
)
},
onClick = {
onStationFilterChange(station.id)
stationMenuOpen = false
}
)
}
}
}
}
}
LazyColumn(
modifier = Modifier.fillMaxWidth().heightIn(max = 420.dp),
verticalArrangement = Arrangement.spacedBy(2.dp)
@@ -179,11 +234,17 @@ fun LogbookDialog(
onFixStation = { onFixGrid(listOf(positionWarning.currentGrid)) },
onIgnore = onIgnorePositionWarning
)
} else if (wavelogPreview != null) {
WavelogUploadPreviewDialog(
preview = wavelogPreview,
busy = uploadBusy,
onConfirm = onConfirmUpload,
onDismiss = onDismissPreview
)
} else if (preview != null) {
LogbookUploadPreviewDialog(
preview = preview,
busy = uploadBusy,
wavelogCount = wavelogCount,
onConfirm = onConfirmUpload,
onDismiss = onDismissPreview
)
@@ -193,7 +254,7 @@ fun LogbookDialog(
onDismissRequest = onDismissMessage,
shape = sheetDialogShape(),
containerColor = sheetDialogContainerColor(),
title = { SheetDialogTitle("LoTW Upload") },
title = { SheetDialogTitle(if (wavelogMode) "Wavelog Upload" else "LoTW Upload") },
text = { Text(uploadMessage) },
confirmButton = {
TextButton(onClick = onDismissMessage) { Text("OK") }
@@ -218,7 +279,6 @@ fun LogbookDialog(
private fun LogbookUploadPreviewDialog(
preview: com.rtbishop.look4sat.core.domain.repository.LoTWUploadPreview,
busy: Boolean,
wavelogCount: Int,
onConfirm: () -> Unit,
onDismiss: () -> Unit
) {
@@ -259,13 +319,6 @@ private fun LogbookUploadPreviewDialog(
color = MaterialTheme.colorScheme.primary
)
}
if (wavelogCount > 0) {
Text(
stringResource(R.string.prefs_logbook_wavelog_sync, wavelogCount),
fontSize = 12.sp,
color = MaterialTheme.colorScheme.primary
)
}
Text(preview.contacts.joinToString("\n") { it }, fontSize = 12.sp, maxLines = 8)
}
},
@@ -28,6 +28,8 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress
import androidx.compose.foundation.gestures.scrollBy
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -39,6 +41,7 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
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.layout.size
import androidx.compose.foundation.lazy.LazyColumn
@@ -46,6 +49,7 @@ import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Checkbox
import androidx.compose.material3.FilterChip
import androidx.compose.material3.Icon
@@ -69,6 +73,7 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.runtime.toMutableStateList
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -158,72 +163,6 @@ fun LocatorDialog(qthLocator: String, dismiss: () -> Unit, save: (String) -> Uni
}
}
@Composable
fun WavelogDialog(
initialSettings: com.rtbishop.look4sat.core.domain.model.WavelogSettings,
workedGridsCount: Int,
isSyncing: Boolean,
message: String?,
dismiss: () -> Unit,
onSave: (com.rtbishop.look4sat.core.domain.model.WavelogSettings) -> Unit,
onSync: (com.rtbishop.look4sat.core.domain.model.WavelogSettings) -> Unit
) {
val url = rememberSaveable { mutableStateOf(initialSettings.url) }
val token = rememberSaveable { mutableStateOf(initialSettings.token) }
SharedDialog(
title = stringResource(R.string.prefs_wavelog_title),
onCancel = dismiss,
onAccept = {
onSave(com.rtbishop.look4sat.core.domain.model.WavelogSettings(url.value, token.value))
dismiss()
}
) {
OutlinedTextField(
value = url.value,
onValueChange = { url.value = it },
label = { Text(text = stringResource(id = R.string.prefs_wavelog_url)) },
placeholder = { Text(text = "http://192.168.1.10") },
singleLine = true,
modifier = Modifier.fillMaxWidth().padding(horizontal = LocalSpacing.current.large),
)
OutlinedTextField(
value = token.value,
onValueChange = { token.value = it },
label = { Text(text = stringResource(id = R.string.prefs_wavelog_token)) },
placeholder = { Text(text = "abcdef123456:1") },
singleLine = true,
modifier = Modifier.fillMaxWidth().padding(horizontal = LocalSpacing.current.large),
)
Text(
text = stringResource(R.string.prefs_wavelog_hint, workedGridsCount),
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(horizontal = LocalSpacing.current.large)
)
if (message != null) {
Text(
text = message,
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(horizontal = LocalSpacing.current.large)
)
}
// Sync uses the values typed in the fields directly — saving and syncing
// happen in one step, no need to close and reopen the dialog.
Row(
horizontalArrangement = Arrangement.End,
modifier = Modifier.fillMaxWidth().padding(horizontal = LocalSpacing.current.large)
) {
TextButton(
onClick = { onSync(com.rtbishop.look4sat.core.domain.model.WavelogSettings(url.value, token.value)) },
enabled = !isSyncing
) {
Text(text = if (isSyncing) stringResource(R.string.prefs_wavelog_syncing)
else stringResource(R.string.prefs_wavelog_sync))
}
}
Spacer(modifier = Modifier.height(0.dp))
}
}
@Composable
fun WavelogUploadDialog(
@@ -231,14 +170,20 @@ fun WavelogUploadDialog(
stations: List<com.rtbishop.look4sat.core.domain.repository.WavelogStationInfo>,
rights: String,
probeBusy: Boolean,
isSyncing: Boolean,
lastSyncEpochMs: Long,
message: String?,
dismiss: () -> Unit,
onSave: (com.rtbishop.look4sat.core.domain.model.WavelogUploadSettings) -> Unit,
onFetchStations: (url: String, apiKey: String) -> Unit,
onSelectStation: (com.rtbishop.look4sat.core.domain.repository.WavelogStationInfo) -> Unit
onSelectStation: (com.rtbishop.look4sat.core.domain.repository.WavelogStationInfo) -> Unit,
onSyncIncremental: () -> Unit,
onSyncFull: () -> Unit,
onClear: () -> Unit
) {
val url = rememberSaveable { mutableStateOf(initialSettings.url) }
val apiKey = rememberSaveable { mutableStateOf(initialSettings.apiKey) }
var showClearConfirm by remember { mutableStateOf(false) }
SharedDialog(
title = stringResource(R.string.prefs_wavelog_upload_title),
onCancel = dismiss,
@@ -293,18 +238,27 @@ fun WavelogUploadDialog(
modifier = Modifier.padding(horizontal = LocalSpacing.current.large)
)
} else {
stations.forEach { station ->
val selected = station.id == initialSettings.stationId
Text(
text = (if (selected) "● " else "○ ") + listOf(station.name, station.callsign, station.grid)
.filter { it.isNotBlank() }.joinToString(" · "),
color = if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface,
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier
.fillMaxWidth()
.clickable { onSelectStation(station) }
.padding(horizontal = LocalSpacing.current.large, vertical = 2.dp)
)
// Many station profiles must stay reachable: the list scrolls inside a
// bounded height instead of stretching (or clipping) the dialog.
Column(
modifier = Modifier
.fillMaxWidth()
.heightIn(max = 240.dp)
.verticalScroll(rememberScrollState())
) {
stations.forEach { station ->
val selected = station.id == initialSettings.stationId
Text(
text = (if (selected) "● " else "○ ") + listOf(station.name, station.callsign, station.grid)
.filter { it.isNotBlank() }.joinToString(" · "),
color = if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface,
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier
.fillMaxWidth()
.clickable { onSelectStation(station) }
.padding(horizontal = LocalSpacing.current.large, vertical = 2.dp)
)
}
}
}
if (initialSettings.stationId.isNotBlank()) {
@@ -319,6 +273,43 @@ fun WavelogUploadDialog(
modifier = Modifier.padding(horizontal = LocalSpacing.current.large)
)
}
if (lastSyncEpochMs != 0L) {
val timePattern = stringResource(R.string.prefs_updated_time)
val lastSyncText = remember(lastSyncEpochMs) {
java.text.SimpleDateFormat(timePattern, java.util.Locale.getDefault())
.format(java.util.Date(lastSyncEpochMs))
}
Text(
text = stringResource(R.string.prefs_updated_title, lastSyncText),
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(horizontal = LocalSpacing.current.large)
)
}
// Sync pulls the server's QSOs (and the grid data the map highlights) — the
// incremental button resumes from the stored cursor, the full one re-pulls.
Row(
horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier.fillMaxWidth().padding(horizontal = LocalSpacing.current.large)
) {
CardButton(
onClick = onSyncFull,
text = stringResource(R.string.prefs_wavelog_sync_full),
isEnabled = !isSyncing && !probeBusy
)
CardButton(
onClick = onSyncIncremental,
text = stringResource(R.string.prefs_wavelog_sync_incremental),
isEnabled = !isSyncing && !probeBusy
)
}
if (isSyncing) {
Text(
text = stringResource(R.string.prefs_wavelog_syncing),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = LocalSpacing.current.large)
)
}
if (message != null) {
Text(
text = message,
@@ -332,8 +323,49 @@ fun WavelogUploadDialog(
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(horizontal = LocalSpacing.current.large)
)
TextButton(
onClick = { showClearConfirm = true },
modifier = Modifier.padding(horizontal = LocalSpacing.current.large)
) {
Text(text = stringResource(R.string.prefs_wavelog_clear), color = MaterialTheme.colorScheme.error)
}
Spacer(modifier = Modifier.height(0.dp))
}
if (showClearConfirm) {
AlertDialog(
onDismissRequest = { showClearConfirm = false },
title = { Text(stringResource(R.string.prefs_wavelog_clear_title)) },
text = { Text(stringResource(R.string.prefs_wavelog_clear_message)) },
confirmButton = {
TextButton(onClick = {
showClearConfirm = false
onClear()
}) { Text(stringResource(R.string.btn_accept)) }
},
dismissButton = {
TextButton(onClick = { showClearConfirm = false }) { Text(stringResource(R.string.btn_cancel)) }
}
)
}
}
/** LoTW→Wavelog hand-over guard: shown when completing the Wavelog config would switch the mode. */
@Composable
fun WavelogSwitchWarningDialog(
onConfirm: () -> Unit,
onCancel: () -> Unit
) {
AlertDialog(
onDismissRequest = onCancel,
title = { Text(stringResource(R.string.prefs_wavelog_switch_title)) },
text = { Text(stringResource(R.string.prefs_wavelog_switch_message)) },
confirmButton = {
TextButton(onClick = onConfirm) { Text(stringResource(R.string.prefs_wavelog_switch_continue)) }
},
dismissButton = {
TextButton(onClick = onCancel) { Text(stringResource(R.string.btn_cancel)) }
}
)
}
@Composable
@@ -54,6 +54,7 @@ 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.draw.alpha
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.res.painterResource
@@ -69,11 +70,12 @@ import androidx.lifecycle.viewmodel.compose.viewModel
import com.rtbishop.look4sat.core.domain.model.DataSourcesSettings
import com.rtbishop.look4sat.core.domain.model.OtherSettings
import com.rtbishop.look4sat.core.domain.model.RadioControlSettings
import com.rtbishop.look4sat.core.domain.model.WavelogSettings
import com.rtbishop.look4sat.core.domain.predict.GeoPos
import com.rtbishop.look4sat.core.domain.repository.CompassAccuracy
import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
import com.rtbishop.look4sat.core.domain.repository.LoTWSyncMode
import com.rtbishop.look4sat.core.domain.repository.WavelogSyncMode
import com.rtbishop.look4sat.core.presentation.CardButton
import com.rtbishop.look4sat.core.presentation.SharedDialog
import com.rtbishop.look4sat.core.presentation.IconCard
@@ -286,8 +288,9 @@ private fun SettingsScreen(
)
}
if (dialogs.logbook) {
val stationFilter = uiState.logbookStationFilter
LogbookDialog(
records = uiState.logbookRecords,
records = uiState.logbookRecords.filter { stationFilter == null || it.wavelogStation == stationFilter },
satelliteCandidates = uiState.satelliteCatalog,
uploadBusy = uiState.logbookUploadBusy,
uploadMessage = uiState.logbookUploadMessage,
@@ -319,7 +322,11 @@ private fun SettingsScreen(
onToggleSelection = { onAction(SettingsAction.ToggleLogbookSelection(it)) },
onExitSelection = { onAction(SettingsAction.ExitLogbookSelection) },
onResubmitSelected = { onAction(SettingsAction.ResubmitSelectedLogbook) },
wavelogCount = uiState.wavelogPending?.count ?: 0
wavelogPreview = uiState.logbookWavelogPreview,
wavelogMode = uiState.wavelogUploadSettings.isReady,
stationOptions = if (uiState.wavelogUploadSettings.isReady) uiState.wavelogUploadStations else emptyList(),
stationFilter = uiState.logbookStationFilter,
onStationFilterChange = { onAction(SettingsAction.SetLogbookStationFilter(it)) }
)
}
if (dialogs.lotwUpload) {
@@ -336,6 +343,7 @@ private fun SettingsScreen(
onClearStationGridPrefill()
},
onImport = { bytes, password -> onAction(SettingsAction.ImportLoTWCertificate(bytes, password)) },
onPreview = { bytes, password -> onAction(SettingsAction.PreviewLoTWCertificate(bytes, password)) },
onRemove = { onAction(SettingsAction.RemoveLoTWCertificate) },
onSaveStation = { onAction(SettingsAction.SaveLoTWStation(it)) }
)
@@ -347,11 +355,22 @@ private fun SettingsScreen(
stations = uiState.wavelogUploadStations,
rights = uiState.wavelogUploadRights,
probeBusy = uiState.wavelogUploadProbeBusy,
isSyncing = uiState.wavelogSyncing,
lastSyncEpochMs = uiState.wavelogLastSyncEpochMs,
message = uiState.wavelogUploadMessage,
dismiss = { dialogs.wavelogUpload = false },
onSave = { onAction(SettingsAction.UpdateWavelogUpload(it)) },
onFetchStations = { url, apiKey -> onAction(SettingsAction.FetchWavelogUploadStations(url, apiKey)) },
onSelectStation = { onAction(SettingsAction.SelectWavelogUploadStation(it)) }
onSelectStation = { onAction(SettingsAction.SelectWavelogUploadStation(it)) },
onSyncIncremental = { onAction(SettingsAction.SyncWavelog(WavelogSyncMode.Incremental)) },
onSyncFull = { onAction(SettingsAction.SyncWavelog(WavelogSyncMode.Full)) },
onClear = { onAction(SettingsAction.ClearWavelogConfig) }
)
}
if (uiState.wavelogSwitchWarning) {
WavelogSwitchWarningDialog(
onConfirm = { onAction(SettingsAction.ConfirmWavelogSwitch) },
onCancel = { onAction(SettingsAction.CancelWavelogSwitch) }
)
}
@@ -423,6 +442,7 @@ private fun SettingsScreen(
}
) { _ ->
val isVerticalLayout = isVerticalLayout()
val wavelogMode = uiState.wavelogUploadSettings.isReady
LazyVerticalGrid(
columns = GridCells.Fixed(if (isVerticalLayout) 1 else 2),
horizontalArrangement = Arrangement.spacedBy(6.dp),
@@ -465,6 +485,7 @@ private fun SettingsScreen(
LoTWUploadCard(
hasCertificate = uiState.lotwCertificate != null,
stationGrid = uiState.lotwStation?.grid.orEmpty(),
enabled = !wavelogMode,
showUploadConfigDialog = { onAction(SettingsAction.LoadLoTWUploadStatus); dialogs.lotwUpload = true }
)
}
@@ -473,12 +494,14 @@ private fun SettingsScreen(
settings = uiState.lotwSettings,
workedGridsCount = uiState.workedGridsCount,
lastSyncEpochMs = uiState.lotwLastSyncEpochMs,
enabled = !wavelogMode,
showLoTWDialog = { dialogs.lotw = true }
)
}
item {
WavelogUploadCard(
settings = uiState.wavelogUploadSettings,
lastSyncEpochMs = uiState.wavelogLastSyncEpochMs,
showDialog = { dialogs.wavelogUpload = true }
)
}
@@ -486,6 +509,7 @@ private fun SettingsScreen(
OtherCard(
settings = uiState.otherSettings,
onCompassCalibration = { dialogs.compassCalibration = true },
loTWEnabled = !wavelogMode,
onAction = onAction
)
}
@@ -710,6 +734,7 @@ private fun MapSettingsCard(onClick: () -> Unit) {
private fun OtherCard(
settings: OtherSettings,
onCompassCalibration: () -> Unit,
loTWEnabled: Boolean = true,
onAction: (SettingsAction) -> Unit
) {
ElevatedCard(
@@ -728,7 +753,11 @@ private fun OtherCard(
SwitchRow(R.string.prefs_other_switch_update, settings.stateOfAutoUpdate) {
onAction(SettingsAction.ToggleUpdate(it))
}
SwitchRow(R.string.prefs_other_switch_lotw_sync, settings.stateOfAutoLotwSync) {
SwitchRow(
R.string.prefs_other_switch_lotw_sync,
settings.stateOfAutoLotwSync,
enabled = loTWEnabled
) {
onAction(SettingsAction.ToggleAutoLotwSync(it))
}
SwitchRow(R.string.prefs_other_switch_sweep, settings.stateOfSweep) {
@@ -868,10 +897,15 @@ private fun LoTWCard(
settings: com.rtbishop.look4sat.core.domain.model.LoTWSettings,
workedGridsCount: Int,
lastSyncEpochMs: Long,
enabled: Boolean = true,
showLoTWDialog: () -> Unit
) {
ElevatedCard(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)) {
Column(
modifier = Modifier
.padding(horizontal = 8.dp, vertical = 4.dp)
.alpha(if (enabled) 1f else 0.5f)
) {
Text(
text = stringResource(id = R.string.prefs_lotw_title),
color = MaterialTheme.colorScheme.primary
@@ -894,10 +928,20 @@ private fun LoTWCard(
style = MaterialTheme.typography.bodySmall
)
}
if (!enabled) {
Spacer(modifier = Modifier.height(2.dp))
Text(
text = stringResource(R.string.prefs_lotw_disabled_wavelog),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 2
)
}
Spacer(modifier = Modifier.height(4.dp))
CardButton(
onClick = showLoTWDialog,
text = stringResource(id = R.string.prefs_wavelog_configure),
isEnabled = enabled,
modifier = Modifier.fillMaxWidth()
)
}
@@ -907,6 +951,7 @@ private fun LoTWCard(
@Composable
private fun WavelogUploadCard(
settings: com.rtbishop.look4sat.core.domain.model.WavelogUploadSettings,
lastSyncEpochMs: Long,
showDialog: () -> Unit
) {
ElevatedCard(modifier = Modifier.fillMaxWidth()) {
@@ -916,6 +961,13 @@ private fun WavelogUploadCard(
color = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.height(2.dp))
Text(
text = stringResource(R.string.prefs_wavelog_exclusive),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 2
)
Spacer(modifier = Modifier.height(2.dp))
Text(
text = if (settings.isReady) {
stringResource(
@@ -929,6 +981,13 @@ private fun WavelogUploadCard(
style = MaterialTheme.typography.bodySmall,
maxLines = 2
)
if (lastSyncEpochMs != 0L) {
Spacer(modifier = Modifier.height(2.dp))
Text(
text = formatUpdateTime(updateTime = lastSyncEpochMs),
style = MaterialTheme.typography.bodySmall
)
}
Spacer(modifier = Modifier.height(4.dp))
CardButton(
onClick = showDialog,
@@ -22,7 +22,6 @@ import com.rtbishop.look4sat.core.domain.model.LatestRelease
import com.rtbishop.look4sat.core.domain.model.OtherSettings
import com.rtbishop.look4sat.core.domain.model.RCSettings
import com.rtbishop.look4sat.core.domain.model.RadioControlSettings
import com.rtbishop.look4sat.core.domain.model.WavelogSettings
import com.rtbishop.look4sat.core.domain.model.WavelogUploadSettings
import com.rtbishop.look4sat.core.domain.predict.GeoPos
import com.rtbishop.look4sat.core.domain.repository.CompassAccuracy
@@ -58,20 +57,25 @@ data class SettingsState(
val radioControlSettings: RadioControlSettings,
val dataSourcesSettings: DataSourcesSettings,
val dataSourcesStatus: Map<String, Int> = emptyMap(),
val wavelogSettings: WavelogSettings = WavelogSettings(),
val workedGridsCount: Int = 0,
val wavelogSyncing: Boolean = false,
val wavelogMessage: String? = null,
/** Wavelog upload: its own config block + probe/upload transient state. */
/** Wavelog configuration (one block; LoTW and Wavelog are mutually exclusive). */
val wavelogUploadSettings: WavelogUploadSettings = WavelogUploadSettings(),
val wavelogUploadStations: List<com.rtbishop.look4sat.core.domain.repository.WavelogStationInfo> = emptyList(),
val wavelogUploadRights: String = "",
val wavelogUploadProbeBusy: Boolean = false,
/** Wavelog batch prepared alongside the logbook LoTW preview; uploaded when the
* LoTW upload is confirmed (null when Wavelog is not ready). */
val wavelogPending: com.rtbishop.look4sat.core.domain.repository.WavelogUploadPreview? = null,
/** Config-block feedback (probe failures etc.); also shown inside the config dialog. */
/** Config-block feedback (probe failures, sync results); also shown inside the config dialog. */
val wavelogUploadMessage: String? = null,
/** Wavelog sync (download of QSOs + grids). */
val wavelogSyncing: Boolean = false,
/** Epoch ms of the last successful Wavelog sync (0 = never). */
val wavelogLastSyncEpochMs: Long = 0L,
/** Mode-switch guard: a change that would hand the logbook over to Wavelog is
* waiting for the operator's confirmation (LoTW is configured). */
val wavelogSwitchWarning: Boolean = false,
/** Logbook 台址 selector: selected Wavelog station id (null = 全部). */
val logbookStationFilter: String? = null,
/** Wavelog-mode logbook upload: prepared batch awaiting confirmation. */
val logbookWavelogPreview: com.rtbishop.look4sat.core.domain.repository.WavelogUploadPreview? = null,
val lotwSettings: com.rtbishop.look4sat.core.domain.model.LoTWSettings = com.rtbishop.look4sat.core.domain.model.LoTWSettings(),
val lotwSyncing: Boolean = false,
val lotwSyncMode: LoTWSyncMode? = null,
@@ -157,14 +161,20 @@ sealed interface SettingsAction {
// Data sources
data class UpdateDataSources(val settings: DataSourcesSettings) : SettingsAction
// Wavelog worked grids
data class UpdateWavelog(val settings: WavelogSettings) : SettingsAction
data class SyncWorkedGrids(val settings: WavelogSettings) : SettingsAction
// Wavelog upload (separate config block + upload flow)
// Wavelog configuration (single block; LoTW and Wavelog are mutually exclusive)
data class UpdateWavelogUpload(val settings: WavelogUploadSettings) : SettingsAction
data class FetchWavelogUploadStations(val url: String, val apiKey: String) : SettingsAction
data class SelectWavelogUploadStation(val station: com.rtbishop.look4sat.core.domain.repository.WavelogStationInfo) : SettingsAction
/** Download QSOs (+ grids) from Wavelog. */
data class SyncWavelog(val mode: com.rtbishop.look4sat.core.domain.repository.WavelogSyncMode) : SettingsAction
/** Confirmed the LoTW→Wavelog mode switch warning. */
data object ConfirmWavelogSwitch : SettingsAction
/** Declined the switch warning — nothing changes. */
data object CancelWavelogSwitch : SettingsAction
/** Wipe the Wavelog config (back to LoTW mode). */
data object ClearWavelogConfig : SettingsAction
/** Logbook 台址 selector choice (null = 全部). */
data class SetLogbookStationFilter(val stationId: String?) : SettingsAction
// LoTW confirmed grids
data class UpdateLoTW(val settings: com.rtbishop.look4sat.core.domain.model.LoTWSettings) : SettingsAction
@@ -198,6 +208,8 @@ sealed interface SettingsAction {
// LoTW upload configuration (certificate + station)
data object LoadLoTWUploadStatus : SettingsAction
data class ImportLoTWCertificate(val bytes: ByteArray, val password: CharArray) : SettingsAction
/** Pre-import .p12 parse (not persisted) so the region field appears before confirming. */
data class PreviewLoTWCertificate(val bytes: ByteArray, val password: CharArray) : SettingsAction
data object RemoveLoTWCertificate : SettingsAction
data class SaveLoTWStation(val station: com.rtbishop.look4sat.core.domain.repository.LoTWStation) : SettingsAction
@@ -23,7 +23,6 @@ import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
import com.rtbishop.look4sat.core.domain.repository.IDatabaseRepo
import com.rtbishop.look4sat.core.domain.model.WavelogSettings
import com.rtbishop.look4sat.core.domain.model.WavelogUploadSettings
import com.rtbishop.look4sat.core.domain.repository.IMainContainer
import com.rtbishop.look4sat.core.domain.repository.ISensorsRepo
@@ -36,8 +35,12 @@ import com.rtbishop.look4sat.core.domain.repository.lotwCursorApi
import com.rtbishop.look4sat.core.domain.repository.lotwCursorEpochMs
import com.rtbishop.look4sat.core.domain.repository.resolveLoTWSyncMode
import com.rtbishop.look4sat.core.domain.repository.positionWarning
import com.rtbishop.look4sat.core.domain.repository.IWavelogRepository
import com.rtbishop.look4sat.core.domain.repository.IWavelogUploadRepository
import com.rtbishop.look4sat.core.domain.repository.IWavelogSyncRepository
import com.rtbishop.look4sat.core.domain.repository.WavelogSyncFetch
import com.rtbishop.look4sat.core.domain.repository.WavelogSyncMode
import com.rtbishop.look4sat.core.domain.repository.applyWavelogSyncResult
import com.rtbishop.look4sat.core.domain.repository.resolveWavelogSyncMode
import com.rtbishop.look4sat.core.domain.repository.WavelogStationInfo
import com.rtbishop.look4sat.core.domain.repository.WavelogUploadOutcome
import com.rtbishop.look4sat.core.domain.repository.WavelogUploadPreview
@@ -62,7 +65,7 @@ class SettingsViewModel(
private val databaseRepo: IDatabaseRepo,
private val settingsRepo: ISettingsRepo,
private val updateRepo: IUpdateRepository,
private val wavelogRepo: IWavelogRepository,
private val wavelogSyncRepository: IWavelogSyncRepository,
private val wavelogUploadRepository: IWavelogUploadRepository,
private val lotwRepo: com.rtbishop.look4sat.core.domain.repository.ILoTWRepository,
private val qsoRepository: com.rtbishop.look4sat.core.domain.logbook.IQsoRepository,
@@ -88,8 +91,9 @@ class SettingsViewModel(
radioControlSettings = settingsRepo.radioControlSettings.value,
dataSourcesSettings = settingsRepo.dataSourcesSettings.value,
dataSourcesStatus = settingsRepo.dataSourcesStatus.value,
wavelogSettings = settingsRepo.wavelogSettings.value,
wavelogUploadSettings = settingsRepo.wavelogUploadSettings.value,
wavelogUploadStations = settingsRepo.getWavelogStations(),
wavelogLastSyncEpochMs = settingsRepo.getLastWavelogSyncEpochMs(),
workedGridsCount = settingsRepo.getWorkedGrids().size,
compassAccuracy = sensorsRepo.compassAccuracy.value,
compassHeadingDegrees = correctedCompassHeading(),
@@ -162,11 +166,6 @@ class SettingsViewModel(
_uiState.update { it.copy(radioControlSettings = settings) }
}
}
viewModelScope.launch {
settingsRepo.wavelogSettings.collect { settings ->
_uiState.update { it.copy(wavelogSettings = settings) }
}
}
viewModelScope.launch {
settingsRepo.wavelogUploadSettings.collect { settings ->
_uiState.update { it.copy(wavelogUploadSettings = settings) }
@@ -238,13 +237,15 @@ class SettingsViewModel(
is SettingsAction.UpdateRC -> settingsRepo.updateRCSettings(action.settings)
is SettingsAction.UpdateRadioControl -> settingsRepo.updateRadioControlSettings(action.settings)
is SettingsAction.UpdateDataSources -> settingsRepo.updateDataSourcesSettings(action.settings)
// Wavelog worked grids
is SettingsAction.UpdateWavelog -> settingsRepo.updateWavelogSettings(action.settings)
is SettingsAction.SyncWorkedGrids -> syncWorkedGrids(action.settings)
// Wavelog upload config (the upload itself rides the LoTW logbook flow)
is SettingsAction.UpdateWavelogUpload -> settingsRepo.updateWavelogUploadSettings(action.settings)
// Wavelog configuration (single block; LoTW and Wavelog are mutually exclusive)
is SettingsAction.UpdateWavelogUpload -> applyWavelogUploadSettings(action.settings)
is SettingsAction.FetchWavelogUploadStations -> fetchWavelogUploadStations(action.url, action.apiKey)
is SettingsAction.SelectWavelogUploadStation -> selectWavelogUploadStation(action.station)
is SettingsAction.SelectWavelogUploadStation -> applyWavelogUploadStation(action.station)
is SettingsAction.SyncWavelog -> syncWavelog(action.mode)
SettingsAction.ConfirmWavelogSwitch -> confirmWavelogSwitch()
SettingsAction.CancelWavelogSwitch -> cancelWavelogSwitch()
SettingsAction.ClearWavelogConfig -> clearWavelogConfig()
is SettingsAction.SetLogbookStationFilter -> _uiState.update { it.copy(logbookStationFilter = action.stationId) }
// LoTW confirmed grids
is SettingsAction.UpdateLoTW -> settingsRepo.updateLoTWSettings(action.settings)
is SettingsAction.SyncLoTWGrids -> syncLoTWGrids(action.settings, action.mode)
@@ -266,6 +267,7 @@ class SettingsViewModel(
// LoTW upload configuration
SettingsAction.LoadLoTWUploadStatus -> loadLoTWUploadStatus()
is SettingsAction.ImportLoTWCertificate -> importLoTWCertificate(action.bytes, action.password)
is SettingsAction.PreviewLoTWCertificate -> previewLoTWCertificate(action.bytes, action.password)
SettingsAction.RemoveLoTWCertificate -> removeLoTWCertificate()
is SettingsAction.SaveLoTWStation -> saveLoTWStation(action.station)
// Update checker
@@ -277,30 +279,62 @@ class SettingsViewModel(
}
}
// region Wavelog worked grids
// Wavelog configuration + sync
private fun syncWorkedGrids(settings: WavelogSettings) {
if (!settings.isConfigured) {
_uiState.update { it.copy(wavelogMessage = "Wavelog URL/token not configured") }
/** A switch that needs the LoTW→Wavelog hand-over confirmation before it applies. */
private var pendingWavelogSwitch: (() -> Unit)? = null
private fun currentLoTWConfigured(): Boolean =
settingsRepo.lotwSettings.value.isConfigured || _uiState.value.lotwCertificate != null
/**
* Applies a Wavelog config change. Completing the configuration (the block turning
* ready) while LoTW is configured pauses for the operator's confirmation: from then
* on uploads and syncs go through Wavelog only and the LoTW sides stay unavailable
* (the one-of-two rule). A half-filled config changes nothing about the routing.
*/
private fun applyWavelogUploadSettings(settings: WavelogUploadSettings) {
val current = settingsRepo.wavelogUploadSettings.value
if (!current.isReady && settings.isReady && currentLoTWConfigured()) {
pendingWavelogSwitch = { settingsRepo.updateWavelogUploadSettings(settings) }
_uiState.update { it.copy(wavelogSwitchWarning = true) }
return
}
// Persist the credentials first, then sync with the freshly-typed values.
settingsRepo.updateWavelogSettings(settings)
_uiState.update { it.copy(wavelogSyncing = true, wavelogMessage = null) }
viewModelScope.launch {
val grids = wavelogRepo.fetchWorkedGrids(settings.url, settings.token)
_uiState.update {
if (grids != null) {
settingsRepo.setWorkedGrids(grids)
it.copy(wavelogSyncing = false, workedGridsCount = grids.size, wavelogMessage = null)
} else {
it.copy(wavelogSyncing = false, wavelogMessage = "Sync failed — check URL/token/network")
}
}
}
settingsRepo.updateWavelogUploadSettings(settings)
}
// Wavelog upload (separate config block + upload flow)
private fun applyWavelogUploadStation(station: WavelogStationInfo) {
val current = settingsRepo.wavelogUploadSettings.value
val updated = current.copy(
stationId = station.id,
stationName = station.name,
stationCallsign = station.callsign,
stationGrid = station.grid
)
if (!current.isReady && updated.isReady && currentLoTWConfigured()) {
pendingWavelogSwitch = { settingsRepo.updateWavelogUploadSettings(updated) }
_uiState.update { it.copy(wavelogSwitchWarning = true) }
return
}
settingsRepo.updateWavelogUploadSettings(updated)
}
private fun confirmWavelogSwitch() {
pendingWavelogSwitch?.invoke()
pendingWavelogSwitch = null
_uiState.update { it.copy(wavelogSwitchWarning = false) }
}
private fun cancelWavelogSwitch() {
pendingWavelogSwitch = null
_uiState.update { it.copy(wavelogSwitchWarning = false) }
}
/** Wipe the configuration — routing falls back to LoTW on the next check. */
private fun clearWavelogConfig() {
settingsRepo.updateWavelogUploadSettings(WavelogUploadSettings())
_uiState.update { it.copy(logbookStationFilter = null, logbookWavelogPreview = null) }
}
private fun fetchWavelogUploadStations(url: String, apiKey: String) {
if (_uiState.value.wavelogUploadProbeBusy) return
@@ -316,6 +350,9 @@ class SettingsViewModel(
}
return@launch
}
// Persist the full list: the logbook's 台址 selector and the sync use it,
// not just the dialog that fetched it.
settingsRepo.setWavelogStations(probe.stations)
_uiState.update {
it.copy(
wavelogUploadProbeBusy = false,
@@ -328,36 +365,87 @@ class SettingsViewModel(
val current = settingsRepo.wavelogUploadSettings.value
val savedStillPresent = current.stationId.isNotBlank() && probe.stations.any { it.id == current.stationId }
val auto = if (probe.stations.size == 1 && !savedStillPresent) probe.stations.single() else null
if (auto != null) selectWavelogUploadStation(auto)
if (auto != null) applyWavelogUploadStation(auto)
}
}
private fun selectWavelogUploadStation(station: WavelogStationInfo) {
val current = settingsRepo.wavelogUploadSettings.value
settingsRepo.updateWavelogUploadSettings(
current.copy(
stationId = station.id,
stationName = station.name,
stationCallsign = station.callsign,
stationGrid = station.grid
)
)
/**
* Downloads QSOs from Wavelog and folds them into the logbook + map grid data.
* Incremental resumes from the per-station cursors; the full mode re-pulls each
* station from the start (healing anything a cursor missed) — neither ever deletes
* data the app already has.
*/
private fun syncWavelog(requested: WavelogSyncMode) {
if (_uiState.value.wavelogSyncing) return
val settings = settingsRepo.wavelogUploadSettings.value
if (!settings.isConfigured) {
_uiState.update { it.copy(wavelogUploadMessage = "Wavelog URL/key not configured") }
return
}
val stations = settingsRepo.getWavelogStations()
if (stations.isEmpty()) {
_uiState.update { it.copy(wavelogUploadMessage = "Fetch stations first — no station profile known") }
return
}
val mode = resolveWavelogSyncMode(settingsRepo.getWavelogSyncUrl(), settings.url, requested)
_uiState.update { it.copy(wavelogSyncing = true, wavelogUploadMessage = null) }
viewModelScope.launch {
try {
val fetch = wavelogSyncRepository.fetchNewRecords(
settings.url,
settings.apiKey,
stations,
settingsRepo.getWavelogSyncCursors(),
full = mode == WavelogSyncMode.Full
)
if (fetch == null) {
_uiState.update {
it.copy(wavelogSyncing = false, wavelogUploadMessage = "Wavelog sync failed — check URL/key/network")
}
return@launch
}
val merge = if (fetch.records.isEmpty()) {
com.rtbishop.look4sat.core.domain.logbook.AdifImportResult(0, 0, 0)
} else {
qsoRepository.mergeWavelog(fetch.records)
}
// Stations that failed keep their previous cursor; fetched ones advance.
val cursors = settingsRepo.getWavelogSyncCursors() + fetch.cursors
applyWavelogSyncResult(settingsRepo, fetch.records, cursors, settings.url)
_uiState.update {
it.copy(
wavelogSyncing = false,
workedGridsCount = settingsRepo.getWorkedGrids().size,
wavelogLastSyncEpochMs = settingsRepo.getLastWavelogSyncEpochMs(),
wavelogUploadMessage = wavelogSyncSummary(mode, fetch, merge, stations.size)
)
}
} catch (e: kotlinx.coroutines.CancellationException) {
throw e
} catch (_: Exception) {
_uiState.update {
it.copy(wavelogSyncing = false, wavelogUploadMessage = "Wavelog sync failed — check URL/key/network")
}
}
}
}
/** Prepares the Wavelog batch riding the LoTW upload (null when Wavelog is not ready). */
private suspend fun prepareWavelogPiggyback(
records: List<com.rtbishop.look4sat.core.domain.logbook.QsoRecord>
): WavelogUploadPreview? {
val settings = settingsRepo.wavelogUploadSettings.value
if (!settings.isReady) return null
return runCatching {
wavelogUploadRepository.prepare(wavelogUploadCandidates(records), settings)
}.getOrNull()
private fun wavelogSyncSummary(
mode: WavelogSyncMode,
fetch: WavelogSyncFetch,
merge: com.rtbishop.look4sat.core.domain.logbook.AdifImportResult,
totalStations: Int
): String {
val head = if (mode == WavelogSyncMode.Full) "Full Wavelog sync" else "Incremental Wavelog sync"
val base = "$head — ${merge.imported} new, ${merge.skipped} already there"
return if (fetch.failedStations.isEmpty()) base
else "$base · ${fetch.failedStations.size}/$totalStations station(s) failed"
}
/**
* Uploads one prepared Wavelog batch (piggyback) and marks what the server
* accepted. Returns the operator-facing message segment for this upload.
* Uploads one prepared Wavelog batch and marks what the server accepted (stamped
* with the station profile the batch went out through). Returns the operator-facing
* message for this upload.
*/
private suspend fun uploadWavelogNow(
preview: WavelogUploadPreview,
@@ -365,7 +453,7 @@ class SettingsViewModel(
): String = try {
val outcome = wavelogUploadRepository.upload(preview, settings)
if (outcome is WavelogUploadOutcome.Imported && outcome.markIds.isNotEmpty()) {
qsoRepository.markWavelogUploaded(outcome.markIds)
qsoRepository.markWavelogUploaded(outcome.markIds, settings.stationId)
}
wavelogConfirmedSegment(outcome, preview)
} catch (_: Exception) {
@@ -477,13 +565,16 @@ class SettingsViewModel(
_uiState.update { it.copy(logbookUploadBusy = true, logbookUploadMessage = "") }
try {
val all = qsoRepository.records.first()
val wavelogSettings = settingsRepo.wavelogUploadSettings.value
if (wavelogSettings.isReady) {
// Wavelog mode: the logbook upload goes to Wavelog only.
prepareLogbookWavelogUpload(all, wavelogSettings)
return@launch
}
// Only local (non-confirmed) records are candidates for upload;
// LoTW-imported confirmations are the feedback side.
val pending = all.filter { !it.lotwConfirmed && !it.lotwUploaded && it.status == com.rtbishop.look4sat.core.domain.logbook.QsoStatus.COMPLETE }
val audit = lotwUploadRepository.audit(pending)
// Wavelog piggyback (option A): prepare its batch while we are here, so
// the logbook preview can show the count and confirm can post it directly.
val wavelogPreview = prepareWavelogPiggyback(all)
if (audit.pending == 0) {
val msg = when {
audit.unavailable > 0 -> com.rtbishop.look4sat.core.domain.logbook.unavailableUploadSummary(
@@ -493,17 +584,7 @@ class SettingsViewModel(
audit.unknown > 0 -> "${audit.unknown} QSO(s) had an unknown upload result — not retried automatically"
else -> "No pending QSOs to upload"
}
// No LoTW work to do — still flush the Wavelog backlog (catch-up): a
// freshly configured Wavelog syncs without waiting for new QSOs.
val wavelogMsg = wavelogPreview?.let {
if (it.count > 0) uploadWavelogNow(it, settingsRepo.wavelogUploadSettings.value) else wavelogIdleSegment(it)
}
_uiState.update {
it.copy(
logbookUploadBusy = false,
logbookUploadMessage = listOfNotNull(msg, wavelogMsg).joinToString("\n")
)
}
_uiState.update { it.copy(logbookUploadBusy = false, logbookUploadMessage = msg) }
return@launch
}
val preview = lotwUploadRepository.prepare(pending, false)
@@ -514,7 +595,6 @@ class SettingsViewModel(
it.copy(
logbookUploadBusy = false,
logbookPreview = preview,
wavelogPending = wavelogPreview,
logbookPositionWarning = positionWarning(settingsRepo.getCurrentGrid(), preview.grids)
)
}
@@ -526,7 +606,42 @@ class SettingsViewModel(
}
}
/** Wavelog-mode branch of [prepareLogbookUpload]: prepare the batch, then confirm. */
private suspend fun prepareLogbookWavelogUpload(
all: List<com.rtbishop.look4sat.core.domain.logbook.QsoRecord>,
settings: WavelogUploadSettings
) {
val preview = runCatching {
wavelogUploadRepository.prepare(wavelogUploadCandidates(all), settings)
}.getOrNull()
if (preview == null) {
_uiState.update {
it.copy(logbookUploadBusy = false, logbookUploadMessage = "Wavelog upload failed to prepare — check the configuration")
}
return
}
if (preview.count == 0) {
_uiState.update {
it.copy(
logbookUploadBusy = false,
logbookUploadMessage = wavelogIdleSegment(preview) ?: "No pending QSOs to upload to Wavelog"
)
}
return
}
_uiState.update { it.copy(logbookUploadBusy = false, logbookWavelogPreview = preview) }
}
private fun confirmLogbookUpload() {
val wavelogPreview = _uiState.value.logbookWavelogPreview
if (wavelogPreview != null) {
viewModelScope.launch {
_uiState.update { it.copy(logbookUploadBusy = true) }
val msg = uploadWavelogNow(wavelogPreview, settingsRepo.wavelogUploadSettings.value)
_uiState.update { it.copy(logbookUploadBusy = false, logbookWavelogPreview = null, logbookUploadMessage = msg) }
}
return
}
val preview = _uiState.value.logbookPreview ?: return
viewModelScope.launch {
_uiState.update { it.copy(logbookUploadBusy = true) }
@@ -542,19 +657,11 @@ class SettingsViewModel(
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"
}
// Wavelog piggyback: rides the same confirmation, independent of the LoTW
// outcome — a Wavelog failure leaves records unmarked for the next upload.
val wavelogPending = _uiState.value.wavelogPending
val wavelogMsg = wavelogPending?.let { wl ->
if (wl.count > 0) uploadWavelogNow(wl, settingsRepo.wavelogUploadSettings.value)
else wavelogIdleSegment(wl)
}
_uiState.update {
it.copy(
logbookUploadBusy = false,
logbookPreview = null,
wavelogPending = null,
logbookUploadMessage = listOfNotNull(msg, wavelogMsg).joinToString("\n"),
logbookUploadMessage = msg,
// An accepted resubmit is done — drop the checks; a failed one keeps
// them so retrying stays one tap away.
logbookSelectionMode = if (accepted) false else it.logbookSelectionMode,
@@ -564,7 +671,7 @@ class SettingsViewModel(
}
}
private fun dismissLogbookPreview() = _uiState.update { it.copy(logbookPreview = null, wavelogPending = null) }
private fun dismissLogbookPreview() = _uiState.update { it.copy(logbookPreview = null, logbookWavelogPreview = null) }
/** Operator chose "ignore" on the position check: keep the prepared preview. */
private fun ignoreLogbookPositionWarning() = _uiState.update { it.copy(logbookPositionWarning = null) }
@@ -573,7 +680,7 @@ class SettingsViewModel(
private fun abandonLogbookForGridFix() {
lotwUploadRepository.discardPreview()
lastLogbookUploadIds = emptyList()
_uiState.update { it.copy(logbookPositionWarning = null, logbookPreview = null, wavelogPending = null) }
_uiState.update { it.copy(logbookPositionWarning = null, logbookPreview = null) }
}
/** Long-press entry: selection mode with the pressed record checked. */
@@ -601,6 +708,39 @@ class SettingsViewModel(
_uiState.update { it.copy(logbookUploadBusy = true, logbookUploadMessage = "") }
try {
val all = qsoRepository.records.first()
val wavelogSettings = settingsRepo.wavelogUploadSettings.value
if (wavelogSettings.isReady) {
// Wavelog mode: re-send the checked records through Wavelog (the
// server dedupes what is already there).
val selected = all.filter {
it.id in _uiState.value.logbookSelectedIds &&
it.status == com.rtbishop.look4sat.core.domain.logbook.QsoStatus.COMPLETE
}
if (selected.isEmpty()) {
_uiState.update { it.copy(logbookUploadBusy = false, logbookUploadMessage = "No complete QSOs in the selection") }
return@launch
}
val preview = runCatching {
wavelogUploadRepository.prepare(selected, wavelogSettings)
}.getOrNull()
if (preview == null) {
_uiState.update {
it.copy(logbookUploadBusy = false, logbookUploadMessage = "Wavelog upload failed to prepare — check the configuration")
}
return@launch
}
if (preview.count == 0) {
_uiState.update {
it.copy(
logbookUploadBusy = false,
logbookUploadMessage = wavelogIdleSegment(preview) ?: "No QSOs to resubmit"
)
}
return@launch
}
_uiState.update { it.copy(logbookUploadBusy = false, logbookWavelogPreview = preview) }
return@launch
}
val selected = resubmitCandidates(all, _uiState.value.logbookSelectedIds)
if (selected.isEmpty()) {
_uiState.update { it.copy(logbookUploadBusy = false, logbookUploadMessage = "No complete QSOs in the selection") }
@@ -617,13 +757,10 @@ class SettingsViewModel(
return@launch
}
lastLogbookUploadIds = preview.submittedIds
// Wavelog piggyback: the resubmit confirmation syncs the backlog too.
val wavelogPending = prepareWavelogPiggyback(all)
_uiState.update {
it.copy(
logbookUploadBusy = false,
logbookPreview = preview,
wavelogPending = wavelogPending,
logbookPositionWarning = positionWarning(settingsRepo.getCurrentGrid(), preview.grids)
)
}
@@ -652,7 +789,10 @@ class SettingsViewModel(
_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) }
// Publish the region field for the fresh certificate immediately —
// previously it only appeared after closing and reopening the dialog.
val meta = runCatching { lotwUploadRepository.stationMeta(cert.dxcc) }.getOrNull()
_uiState.update { it.copy(lotwCertificate = cert, lotwStationMeta = meta, 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
@@ -667,6 +807,24 @@ class SettingsViewModel(
}
}
// Monotonic token so a slow stale preview never overwrites a newer one's meta.
private var certificatePreviewSeq = 0
private fun previewLoTWCertificate(bytes: ByteArray, password: CharArray) {
val seq = ++certificatePreviewSeq
viewModelScope.launch {
// Silent by design: a wrong password or a non-p12 file must not disturb
// the dialog — only a successful parse publishes the region field.
val meta = runCatching { lotwUploadRepository.previewCertificate(bytes, password) }.fold(
onSuccess = { cert -> runCatching { lotwUploadRepository.stationMeta(cert.dxcc) }.getOrNull() },
onFailure = { null }
)
if (meta != null && seq == certificatePreviewSeq) {
_uiState.update { it.copy(lotwStationMeta = meta) }
}
}
}
private fun removeLoTWCertificate() {
viewModelScope.launch {
lotwUploadRepository.removeCertificate()
@@ -821,7 +979,7 @@ class SettingsViewModel(
databaseRepo = container.databaseRepo,
settingsRepo = container.settingsRepo,
updateRepo = container.updateRepo,
wavelogRepo = container.wavelogRepo,
wavelogSyncRepository = container.wavelogSyncRepository,
wavelogUploadRepository = container.wavelogUploadRepository,
lotwRepo = container.lotwRepo,
qsoRepository = container.qsoRepository,
@@ -400,6 +400,7 @@ private fun ReportDialog(
onDismiss: () -> Unit
) {
val dayReports = day.slots.flatMap { it.reportIds }.mapNotNull { reports[it] }
val conflictedIds = day.slots.filter { it.isConflicted }.flatMap { it.reportIds }.toSet()
InfoDialog(
title = "$statusName · ${day.dateLabel}",
onDismiss = onDismiss,
@@ -448,6 +449,10 @@ private fun ReportDialog(
)
Spacer(modifier = Modifier.width(6.dp))
Text(text = report.statusText, fontSize = 14.sp, fontWeight = FontWeight.Bold)
if (report.id in conflictedIds) {
Spacer(modifier = Modifier.width(8.dp))
ConflictBadge()
}
}
Text(
text = "${report.call} ${report.dateUtc} ${report.timeUtc}" +
@@ -463,6 +468,23 @@ private fun ReportDialog(
}
}
/** Orange "冲突" tag on reports whose 2h slot has no strict majority (Conflicting reports). */
@Composable
private fun ConflictBadge() {
Box(
modifier = Modifier
.clip(RoundedCornerShape(4.dp))
.background(Color(0xFFFE6100).copy(alpha = 0.15f))
.padding(horizontal = 5.dp, vertical = 1.dp)
) {
Text(
text = stringResource(id = R.string.amsat_conflict),
fontSize = 11.sp,
color = Color(0xFFFE6100)
)
}
}
@Composable
private fun AmSatUploadPanel(
statusName: String,
+2 -2
View File
@@ -1,8 +1,8 @@
[versions]
#noinspection UnusedVersionCatalogEntry
appVersionCode = "567"
appVersionCode = "569"
#noinspection UnusedVersionCatalogEntry
appVersionName = "4.4.8-ba7opf.9.2"
appVersionName = "4.4.8-ba7opf.9.4"
#noinspection UnusedVersionCatalogEntry
compileSdk = "37"
#noinspection UnusedVersionCatalogEntry