Compare commits

...
23 Commits
Author SHA1 Message Date
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
atsunatsu e7c0df0954 build: release version 4.4.8-ba7opf.9.2 (567) 2026-10-05 02:44:18 +08:00
atsunatsu 5a8c4281a3 feat(wavelog): upload rides the LoTW flow — auto-sync + catch-up (option A)
Standalone Wavelog upload entries are gone: the batch is prepared alongside the LoTW preview (count shown in both preview dialogs), posted right after the LoTW confirm in the radar, settings-logbook and resubmit flows, and the backlog flushes when LoTW has nothing pending. Failures stay unmarked and retry on the next upload.
2026-10-05 02:19:11 +08:00
atsunatsu a459cfb046 fix(map): scope first-call labels to the selected operated grid (台址) 2026-10-05 02:19:06 +08:00
atsunatsu 7589f3f37d fix(map): scope grid QSO detail to the selected operated grid (台址)
The VUCC grid-detail dialog listed every station location's QSOs even when a specific operated grid was selected; only 'All' should combine them. The 台址 grid-set key derivation is now shared between the selector's grouping and the dialog filter (core/domain extensions + unit tests).
2026-10-05 02:02:55 +08:00
atsunatsu 6a263d8422 build: candidate version 4.4.8-ba7opf.9.1 (566) 2026-10-05 01:37:27 +08:00
atsunatsu 1558919482 feat(wavelog): upload UI — settings block, radar log button, logbook dialog 2026-10-05 01:37:01 +08:00
atsunatsu 9e5b9b6ac6 feat(wavelog): v1 upload repository (probe/prepare/classify) + DI wiring 2026-10-05 01:22:48 +08:00
atsunatsu c050e7bb9b feat(wavelog): wavelog export option + upload helpers (ADIF, candidates, messages) 2026-10-05 01:22:44 +08:00
atsunatsu 73ac41ff85 feat(wavelog): separate upload settings block (model, repo, fakes) 2026-10-05 01:22:40 +08:00
atsunatsu 733f660d78 feat(wavelog): per-record Wavelog upload state (model, migration, dao, repo) 2026-10-05 01:22:34 +08:00
atsunatsu cbe3e50f86 build: release version 4.4.8-ba7opf.8 (563) 2026-10-04 11:57:03 +08:00
atsunatsu 38b29e3015 build: candidate version 4.4.8-ba7opf.7.2 (562) 2026-10-04 02:32:42 +08:00
atsunatsu 1a3a05c8b6 feat(sources): 默认 TLE 源对齐上游(9 项),保留 fork 新增源
新默认顺序(上游"most specific → most generic",fork 新增源保持原位):
LAPAN-A2 → ARISS → R4UAB → AutoTLE(GitHub) → AutoTLE(Mirror) → Classified
→ Amsat → All (CelesTrak active) → SatNOGS;Other 占位保留。
删掉 22 个 CelesTrak 分类组与 McCants inttles.zip(上游 4.4.4 起即如此,
单一 active 组已覆盖),同步请求数 32 → 9。

转发器源顺序维持 SatNOGS → R4UAB(与上游相反)并注明实测理由:两源同源
(共享 2760 uuid),去重先到先得,SatNOGS 副本更新鲜且独有最新星的发射机。
2026-10-04 02:32:39 +08:00
atsunatsu 1e33a10fb4 fix(satellites): All 类型名单为空时回退为不筛选
CelesTrak 对重复下载(2 小时内数据未更新)返回 403,同步会把该源的
type id 名单写成空串,选 All 时 resolveTypeIds 得空集 → 列表一条不剩。
现在只有 "All"(= CelesTrak active 全集)在名单为空时回退为不筛选(显示
全部卫星);其它源名类型与三个虚拟类型(AMSAT Live FM/Linear、Live SSTV)
保持"空即显示空"。Sources 增加 allSourceType 常量并注明背景。

Tests: SelectionRepoSearchTest 新增 2 例(All 空→显示全部、其它源空→仍空)
2026-10-04 02:32:31 +08:00
atsunatsu 6546e32360 docs(agents): release notes must be bilingual (English first, then Chinese) 2026-10-03 03:28:35 +08:00
atsunatsu c86893f6b1 build: release version 4.4.8-ba7opf.6 (560) 2026-10-03 03:07:19 +08:00
atsunatsu cad085c25b feat(settings): 数据源分组补提示,说明该顺序只影响卫星名
轨道元素改为按历元取最新后,列表顺序只决定显示名;UI 不说明的话用户会
误以为顺序仍决定用哪份轨道(本轮实测的困惑即来自此)。补两组提示项并在
分组标题下小字展示(排版与上游一致:onSurfaceVariant,top 2dp/bottom 8dp):

- 卫星数据(中文):轨道数据优先取列表最新星历,此顺序仅影响卫星名称
- 收发器数据:仍为"列表靠前优先"(tx 记录按 uuid 去重取首个源),提示沿用
  上游措辞;es/ru/si/tr/uk 五种语言同样沿用上游译文
2026-10-03 03:02:11 +08:00
atsunatsu cba5eb7efe feat(data): 多源星历按历元取最新(对齐上游),源顺序只影响卫星名
采用上游 6f3e3351 的合并口径:同一 NORAD 号的轨道元素取历元最新者,名字取
列表中第一个提供该星的源,源顺序因此退化为命名偏好。fork 此前是
flatMap + distinctBy{catnum}(列表靠前者全胜)——实测 R4UAB 的元素平均比
CelesTrak 旧 8.4h(78 颗共有业余星中 67 颗更旧),却因排在列表前面而抢占。

- OrbitalData:暴露 epochDaynum / timeToDaynum / epochToDaynum(跨源比较必须
  走 daynum,原始 YYDDD 跨十年不可比)
- DatabaseRepo:新增 mergeEntries(历元最新元素 + 首个提供者的名字)、
  insertFresherEntries(只写入更新过的元素,并对未更新者原地改名,使重排源
  顺序立即生效)、pruneStaleEntries(30 天无源刷新的星清理)
- updateFromRemote 改为按源分别解析;保留 fork 的 importedTypeIds 类型映射
  (按每源结果统计,供筛选 chips 与自定义源使用);全部源失败时保留上次成功
  时间戳,下次启动重试
- updateTLEFromFile 走同一合并链路(手工导入同样只升不降)
- 新增 5 个单测(含 prune 与失败保时间戳),fixture 历元改为当前(旧历元
  会被新 prune 规则清掉);两个 fake 补齐 ILocalSource 新接口

测试:core:domain 171 / core:data 121 全绿。
2026-10-03 03:02:00 +08:00
atsunatsu 74c245a7d2 build: release version 4.4.8-ba7opf.4 (557) 2026-10-02 23:37:15 +08:00
atsunatsu 135ecb861d feat(map): 网格通联详情呼号列加宽(次数列不再等分,字样位置不变)
工作网格 QSO 详情弹窗里,中间那一列(通联行的「n QSO」、标记行的「标记」)
原来和左右两列一样占 weight(1f)(各 1/3 宽),而该列文字是居中的,字样两侧的
空白被白白占用。现改为按内容宽度占位(去掉 weight(1f)),左右两列仍各占
weight(1f):两外列等分剩余宽度 ⇒ 中列起点正好落在原居中字样的左边界,
因此

- 「n QSO」「标记」字形位置与之前逐像素一致,日期与展开箭头(右对齐)不动;
- 省出的中列左半空白全部交给呼号列,呼号列宽度由 W/3 增至 (W - 字样宽)/2,
  411dp 屏约 +33dp,长呼号(含 /P、活动特别呼号)多显示约 3 个字符,
  并可一直排到「n QSO」字样左侧才省略。

仅影响地图页「网格通联详情」弹窗内的两类行;设置页日志本行本就是
「呼号 weight + 尾部标签 wrap-content」,口径已一致,未改动。
2026-10-02 23:37:06 +08:00
53 changed files with 2144 additions and 105 deletions

No files matched your search

+11
View File
@@ -112,6 +112,17 @@ transition to OMM/CSV automatically.
- `build-logic/convention/` contains all shared Gradle configuration — edit there, not in individual modules. - `build-logic/convention/` contains all shared Gradle configuration — edit there, not in individual modules.
- ProGuard is enabled for release builds — don't add reflection-based libs or any other dependencies without asking. - ProGuard is enabled for release builds — don't add reflection-based libs or any other dependencies without asking.
## Release Notes Must Be Bilingual (BA7OPF fork convention)
Every GitHub release body carries **two sections — English first, then Chinese** (`## <tag>` each),
listing only that release's changes, in the same order with the same claims and the same numbers.
Both sections end with the verification line (which unit tests ran, APK signature / ABI check).
A single-language body is a defect: fix it before the release counts as done.
中文规则:每次 release 的说明必须**中英双语,先英文后中文**,两段一一对应(同一顺序、同一结论、
同一数字,不做自由意译),只列本版改动;两段末尾都写验证情况(跑了哪些单测、签名/ABI 校验)。
只写一种语言视为缺陷,需补齐后再算发布完成。
## Copilot Working Mode: Code-Only ## Copilot Working Mode: Code-Only
- Default to code changes only. Provide explanations in chat only. - Default to code changes only. Provide explanations in chat only.
@@ -19,6 +19,7 @@ package com.rtbishop.look4sat.core.data.database
import androidx.room.Dao import androidx.room.Dao
import androidx.room.Insert import androidx.room.Insert
import androidx.room.MapColumn
import androidx.room.OnConflictStrategy import androidx.room.OnConflictStrategy
import androidx.room.Query import androidx.room.Query
import androidx.room.Transaction import androidx.room.Transaction
@@ -39,6 +40,18 @@ interface Look4SatDao {
@Query("SELECT * FROM entries WHERE catnum IN (:selectedIds)") @Query("SELECT * FROM entries WHERE catnum IN (:selectedIds)")
suspend fun getEntriesWithIds(selectedIds: List<Int>): List<SatEntry> suspend fun getEntriesWithIds(selectedIds: List<Int>): List<SatEntry>
@Query("SELECT catnum, epoch FROM entries")
suspend fun getEntriesEpochs(): Map<@MapColumn("catnum") Int, @MapColumn("epoch") Double>
@Query("SELECT catnum, name FROM entries")
suspend fun getEntriesNames(): Map<@MapColumn("catnum") Int, @MapColumn("name") String>
@Query("UPDATE entries SET name = :name WHERE catnum = :catnum")
suspend fun renameEntry(catnum: Int, name: String)
@Query("DELETE FROM entries WHERE catnum IN (:ids)")
suspend fun deleteEntriesWithIds(ids: List<Int>)
@Insert(onConflict = OnConflictStrategy.REPLACE) @Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertEntries(entries: List<SatEntry>) suspend fun insertEntries(entries: List<SatEntry>)
@@ -47,4 +47,7 @@ interface QsoDao {
@Query("UPDATE qso_records SET lotwUploaded = 1 WHERE id IN (:ids)") @Query("UPDATE qso_records SET lotwUploaded = 1 WHERE id IN (:ids)")
suspend fun markUploaded(ids: List<Long>) suspend fun markUploaded(ids: List<Long>)
@Query("UPDATE qso_records SET wavelogUploaded = 1 WHERE id IN (:ids)")
suspend fun markWavelogUploaded(ids: List<Long>)
} }
@@ -20,7 +20,7 @@ import com.rtbishop.look4sat.core.data.database.entity.QsoEntity
* upload receipts). Kept apart from [Look4SatDb] so existing installations * upload receipts). Kept apart from [Look4SatDb] so existing installations
* never need a migration of the satellite database. * never need a migration of the satellite database.
*/ */
@Database(entities = [QsoEntity::class], version = 3, exportSchema = false) @Database(entities = [QsoEntity::class], version = 4, exportSchema = false)
abstract class QsoDatabase : RoomDatabase() { abstract class QsoDatabase : RoomDatabase() {
abstract fun qsoDao(): QsoDao abstract fun qsoDao(): QsoDao
} }
@@ -38,3 +38,10 @@ val MIGRATION_QSO_2_3 = object : Migration(2, 3) {
db.execSQL("ALTER TABLE qso_records ADD COLUMN theirVuccGrids TEXT NOT NULL DEFAULT ''") db.execSQL("ALTER TABLE qso_records ADD COLUMN theirVuccGrids TEXT NOT NULL DEFAULT ''")
} }
} }
/** v3 → v4: track whether a QSO was uploaded to Wavelog (independent of the LoTW state). */
val MIGRATION_QSO_3_4 = object : Migration(3, 4) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE qso_records ADD COLUMN wavelogUploaded INTEGER NOT NULL DEFAULT 0")
}
}
@@ -44,6 +44,7 @@ data class QsoEntity(
@ColumnInfo(defaultValue = "''") val propagationMode: String = "", @ColumnInfo(defaultValue = "''") val propagationMode: String = "",
@ColumnInfo(defaultValue = "0") val lotwConfirmed: Boolean = false, @ColumnInfo(defaultValue = "0") val lotwConfirmed: Boolean = false,
@ColumnInfo(defaultValue = "0") val lotwUploaded: Boolean = false, @ColumnInfo(defaultValue = "0") val lotwUploaded: Boolean = false,
@ColumnInfo(defaultValue = "0") val wavelogUploaded: Boolean = false,
@ColumnInfo(defaultValue = "0") val lotwReceived: Boolean = false, @ColumnInfo(defaultValue = "0") val lotwReceived: Boolean = false,
@ColumnInfo(defaultValue = "''") val lotwQslDate: String = "", @ColumnInfo(defaultValue = "''") val lotwQslDate: String = "",
@ColumnInfo(defaultValue = "''") val vuccGrids: String = "", @ColumnInfo(defaultValue = "''") val vuccGrids: String = "",
@@ -31,6 +31,7 @@ import com.rtbishop.look4sat.core.data.database.MIGRATION_1_2
import com.rtbishop.look4sat.core.data.database.MIGRATION_2_3 import com.rtbishop.look4sat.core.data.database.MIGRATION_2_3
import com.rtbishop.look4sat.core.data.database.MIGRATION_QSO_1_2 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_2_3
import com.rtbishop.look4sat.core.data.database.MIGRATION_QSO_3_4
import com.rtbishop.look4sat.core.data.database.QsoDatabase import com.rtbishop.look4sat.core.data.database.QsoDatabase
import com.rtbishop.look4sat.core.data.framework.BluetoothReporter import com.rtbishop.look4sat.core.data.framework.BluetoothReporter
import com.rtbishop.look4sat.core.data.framework.Ft817Controller import com.rtbishop.look4sat.core.data.framework.Ft817Controller
@@ -49,6 +50,7 @@ import com.rtbishop.look4sat.core.data.repository.UpdateRepository
import com.rtbishop.look4sat.core.data.repository.LoTWRepository import com.rtbishop.look4sat.core.data.repository.LoTWRepository
import com.rtbishop.look4sat.core.data.repository.LocationRepo import com.rtbishop.look4sat.core.data.repository.LocationRepo
import com.rtbishop.look4sat.core.data.repository.WavelogRepository import com.rtbishop.look4sat.core.data.repository.WavelogRepository
import com.rtbishop.look4sat.core.data.repository.WavelogUploadRepository
import com.rtbishop.look4sat.core.data.source.LocalSource import com.rtbishop.look4sat.core.data.source.LocalSource
import com.rtbishop.look4sat.core.data.source.RemoteSource import com.rtbishop.look4sat.core.data.source.RemoteSource
import com.rtbishop.look4sat.core.data.usecase.AddToCalendar import com.rtbishop.look4sat.core.data.usecase.AddToCalendar
@@ -70,6 +72,7 @@ import com.rtbishop.look4sat.core.domain.repository.ISelectionRepo
import com.rtbishop.look4sat.core.domain.repository.ISensorsRepo import com.rtbishop.look4sat.core.domain.repository.ISensorsRepo
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
import com.rtbishop.look4sat.core.domain.repository.IWavelogRepository import com.rtbishop.look4sat.core.domain.repository.IWavelogRepository
import com.rtbishop.look4sat.core.domain.repository.IWavelogUploadRepository
import com.rtbishop.look4sat.core.domain.repository.MutualPassData import com.rtbishop.look4sat.core.domain.repository.MutualPassData
import com.rtbishop.look4sat.core.domain.source.ILocalSource import com.rtbishop.look4sat.core.domain.source.ILocalSource
import com.rtbishop.look4sat.core.domain.source.IRemoteSource import com.rtbishop.look4sat.core.domain.source.IRemoteSource
@@ -101,10 +104,11 @@ class MainContainer(private val context: Context) : IMainContainer {
override val amSatRepo by lazy { AmSatRepository(remoteSource, appScope) } override val amSatRepo by lazy { AmSatRepository(remoteSource, appScope) }
override val updateRepo by lazy { UpdateRepository(remoteSource) } override val updateRepo by lazy { UpdateRepository(remoteSource) }
override val wavelogRepo: IWavelogRepository by lazy { WavelogRepository() } override val wavelogRepo: IWavelogRepository by lazy { WavelogRepository() }
override val wavelogUploadRepository: IWavelogUploadRepository by lazy { WavelogUploadRepository() }
override val lotwRepo: ILoTWRepository by lazy { LoTWRepository() } override val lotwRepo: ILoTWRepository by lazy { LoTWRepository() }
override val qsoRepository: IQsoRepository by lazy { override val qsoRepository: IQsoRepository by lazy {
val database = Room.databaseBuilder(context, QsoDatabase::class.java, "Look4SatQsoDB") val database = Room.databaseBuilder(context, QsoDatabase::class.java, "Look4SatQsoDB")
.addMigrations(MIGRATION_QSO_1_2, MIGRATION_QSO_2_3) .addMigrations(MIGRATION_QSO_1_2, MIGRATION_QSO_2_3, MIGRATION_QSO_3_4)
.build() .build()
QsoRepository(database.qsoDao(), Dispatchers.IO) QsoRepository(database.qsoDao(), Dispatchers.IO)
} }
@@ -52,6 +52,10 @@ private data class ApiReport(
val reportedTimeUtcSec: Long 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). */ /** AMSAT status repository using RemoteSource (Clean Architecture: data layer handles HTTP). */
class AmSatRepository( class AmSatRepository(
private val remoteSource: IRemoteSource, 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> { private fun buildStatuses(names: List<String>, reports: List<ApiReport>, nowSec: Long): List<SatStatus> {
val byName = reports.groupBy { it.name } val byName = reports.groupBy { it.name }
val utc = Calendar.getInstance(TimeZone.getTimeZone("UTC")) val utc = Calendar.getInstance(TimeZone.getTimeZone("UTC"))
@@ -230,11 +235,12 @@ class AmSatRepository(
if (inSlot.isEmpty()) { if (inSlot.isEmpty()) {
SatSlot(statusColor = NO_REPORT_GRAY, count = 0) SatSlot(statusColor = NO_REPORT_GRAY, count = 0)
} else { } else {
val newest = inSlot.maxByOrNull { it.reportedTimeUtcSec }!! val status = slotStatusOf(inSlot)
SatSlot( SatSlot(
statusColor = statusColorOf(newest.report), statusColor = status.color,
count = inSlot.size, count = status.count,
reportIds = inSlot.map { it.id } 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()) { * The AMSAT status page's per-block rule, mirrored from the official page source
"heard", "crew active" -> ACTIVE_BLUE * (gitlab.amsat.org/open-source/satellite-status, index.php):
"telemetry only" -> TLM_ORANGE * - any Crew Active report wins: purple, count = crew + heard;
"not heard" -> NOT_HEARD_PINK * - otherwise one status must be a STRICT majority to take its colour, and the
else -> CONFLICT_DEEP_ORANGE * 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 { companion object {
@@ -301,6 +323,7 @@ class AmSatRepository(
private const val TLM_ORANGE = 0xFFFFB000 private const val TLM_ORANGE = 0xFFFFB000
private const val NOT_HEARD_PINK = 0xFFDC267F private const val NOT_HEARD_PINK = 0xFFDC267F
private const val CONFLICT_DEEP_ORANGE = 0xFFFE6100 private const val CONFLICT_DEEP_ORANGE = 0xFFFE6100
private const val CREW_PURPLE = 0xFF785EF0
private const val NO_REPORT_GRAY = 0xFFC0C0C0 private const val NO_REPORT_GRAY = 0xFFC0C0C0
} }
} }
@@ -46,8 +46,8 @@ class DatabaseRepo(
override suspend fun updateTLEFromFile(uri: String): Int = withContext(dispatcher) { override suspend fun updateTLEFromFile(uri: String): Int = withContext(dispatcher) {
var importedCount = 0 var importedCount = 0
remoteSource.getFileStream(uri)?.let { stream -> remoteSource.getFileStream(uri)?.let { stream ->
val entries = parseSatelliteStream(uri, unwrapIfZipped(uri, stream)) val entries = mergeEntries(listOf(parseSatelliteStream(uri, unwrapIfZipped(uri, stream))))
localSource.insertEntries(entries) insertFresherEntries(entries)
settingsRepo.setSatelliteTypeIds(customSourceType, entries.map { it.catnum }) settingsRepo.setSatelliteTypeIds(customSourceType, entries.map { it.catnum })
importedCount = entries.size importedCount = entries.size
} }
@@ -93,14 +93,15 @@ class DatabaseRepo(
settingsRepo.updateDataSourcesStatus( settingsRepo.updateDataSourcesStatus(
(tleResults + radioResults).associate { (url, result) -> url to result.code } (tleResults + radioResults).associate { (url, result) -> url to result.code }
) )
// parse fetched data concurrently and associate known built-in URLs with existing type filters. // parse each source on its own: the type filters need every source's catnums, while the
val importedEntries = tleResults.flatMap { (rawUrl, result) -> // stored elements are merged across sources below by freshness.
val parsedPerSource = tleResults.map { (rawUrl, result) ->
val normUrl = normalizeUrl(rawUrl) val normUrl = normalizeUrl(rawUrl)
val entries = result.stream?.let { parseSatelliteStream(normUrl, unwrapIfZipped(normUrl, it)) }.orEmpty() val entries = result.stream?.let { parseSatelliteStream(normUrl, unwrapIfZipped(normUrl, it)) }.orEmpty()
val type = builtinTypesByUrl[normUrl] ?: customSourceType val type = builtinTypesByUrl[normUrl] ?: customSourceType
importedTypeIds.getOrPut(type) { mutableListOf() }.addAll(entries.map { it.catnum }) importedTypeIds.getOrPut(type) { mutableListOf() }.addAll(entries.map { it.catnum })
entries entries
}.distinctBy { it.catnum } }
importedTypeIds.forEach { (type, ids) -> settingsRepo.setSatelliteTypeIds(type, ids.distinct()) } importedTypeIds.forEach { (type, ids) -> settingsRepo.setSatelliteTypeIds(type, ids.distinct()) }
val importedRadios = radioResults.flatMap { (rawUrl, result) -> val importedRadios = radioResults.flatMap { (rawUrl, result) ->
val normUrl = normalizeUrl(rawUrl) val normUrl = normalizeUrl(rawUrl)
@@ -115,8 +116,14 @@ class DatabaseRepo(
localSource.deleteManagedRadios() localSource.deleteManagedRadios()
localSource.insertRadios(importedRadios) localSource.insertRadios(importedRadios)
} }
if (importedEntries.isNotEmpty()) localSource.insertEntries(importedEntries) // Elements always come from the freshest source for every satellite; only
setUpdateSuccessful(System.currentTimeMillis()) // elements newer than the stored ones are written back.
insertFresherEntries(mergeEntries(parsedPerSource))
// keep the previous timestamp when every source failed, so the next launch retries
val hasFetchedData = parsedPerSource.any { entries -> entries.isNotEmpty() } || importedRadios.isNotEmpty()
val previousTimestamp = settingsRepo.databaseState.value.updateTimestamp
if (hasFetchedData) pruneStaleEntries()
setUpdateSuccessful(if (hasFetchedData) System.currentTimeMillis() else previousTimestamp)
} }
override suspend fun clearAllData() = withContext(dispatcher) { override suspend fun clearAllData() = withContext(dispatcher) {
@@ -155,6 +162,62 @@ class DatabaseRepo(
line.count { it == ',' } >= 4 line.count { it == ',' } >= 4
} }
/**
* Merges the data of every source: orbital elements always come from the set with the newest
* epoch, while the name comes from the first source that provides it. Source order is thus a
* naming preference only, which also keeps names stable when sources leapfrog each other.
*/
private fun mergeEntries(sourceEntries: List<List<OrbitalData>>): List<OrbitalData> {
val preferredNames = mutableMapOf<Int, String>()
val freshestEntries = mutableMapOf<Int, OrbitalData>()
sourceEntries.forEach { entries ->
entries.forEach { entry ->
val name = entry.name.trim()
if (name.isNotBlank()) preferredNames.getOrPut(entry.catnum) { name }
val current = freshestEntries[entry.catnum]
if (current == null || entry.epochDaynum > current.epochDaynum) {
freshestEntries[entry.catnum] = entry
}
}
}
return freshestEntries.values.map { entry ->
val name = preferredNames[entry.catnum]
if (name == null || name == entry.name) entry else entry.copy(name = name)
}
}
/** Stores the entries that are newer than the ones already saved, renaming the rest in place. */
private suspend fun insertFresherEntries(entries: List<OrbitalData>) {
val storedEpochs = localSource.getEntriesEpochs()
val (fresherEntries, staleEntries) = entries.partition { entry ->
val storedEpoch = storedEpochs[entry.catnum]
storedEpoch == null || entry.epochDaynum > OrbitalData.epochToDaynum(storedEpoch)
}
localSource.insertEntries(fresherEntries)
// a reordered source list has to rename satellites right away, even the ones holding
// elements that are newer than the ones just parsed
if (staleEntries.isNotEmpty()) {
val storedNames = localSource.getEntriesNames()
val renamedEntries = staleEntries.filter { entry -> entry.name != storedNames[entry.catnum] }
if (renamedEntries.isNotEmpty()) {
localSource.renameEntries(renamedEntries.associate { it.catnum to it.name })
}
}
}
/**
* Drops satellites that no enabled source has refreshed for a month: they either decayed or
* disappeared from every catalog. Manually imported data ages out the same way, and every
* source republishes active satellites well within that window.
*/
private suspend fun pruneStaleEntries() {
val currentDaynum = OrbitalData.timeToDaynum(System.currentTimeMillis())
val staleIds = localSource.getEntriesEpochs()
.filterValues { epoch -> currentDaynum - OrbitalData.epochToDaynum(epoch) > 30.0 }
.keys.toList()
if (staleIds.isNotEmpty()) localSource.deleteEntriesWithIds(staleIds)
}
private suspend fun setUpdateSuccessful(timestamp: Long) { private suspend fun setUpdateSuccessful(timestamp: Long) {
settingsRepo.updateDatabaseState( settingsRepo.updateDatabaseState(
DatabaseState(localSource.getRadiosTotal(), localSource.getEntriesTotal(), timestamp) DatabaseState(localSource.getRadiosTotal(), localSource.getEntriesTotal(), timestamp)
@@ -51,12 +51,14 @@ class QsoRepository(
// place: that contact already exists on LoTW under the old values. The original row // place: that contact already exists on LoTW under the old values. The original row
// keeps its upload/confirmation state, and the edited content is saved as a NEW // keeps its upload/confirmation state, and the edited content is saved as a NEW
// record (without any LoTW state, so it can be uploaded again). // record (without any LoTW state, so it can be uploaded again).
if (previous != null && (previous.lotwUploaded || previous.lotwConfirmed) && if (previous != null &&
(previous.lotwUploaded || previous.lotwConfirmed || previous.wavelogUploaded) &&
!record.sameEditableContentAs(previous) !record.sameEditableContentAs(previous)
) { ) {
dao.save(record.copy( dao.save(record.copy(
id = 0L, id = 0L,
lotwUploaded = false, lotwConfirmed = false, lotwReceived = false, lotwUploaded = false, lotwConfirmed = false, lotwReceived = false,
wavelogUploaded = false,
lotwQslDate = "", vuccGrids = emptyList(), theirVuccGrids = emptyList(), lotwQslDate = "", vuccGrids = emptyList(), theirVuccGrids = emptyList(),
dxcc = null, country = "", cqZone = null, region = "" dxcc = null, country = "", cqZone = null, region = ""
).toEntity()) ).toEntity())
@@ -93,6 +95,11 @@ class QsoRepository(
if (stamped.isNotEmpty()) dao.saveBatch(stamped.map { it.toEntity() }) if (stamped.isNotEmpty()) dao.saveBatch(stamped.map { it.toEntity() })
} }
override suspend fun markWavelogUploaded(ids: List<Long>) = withContext(dispatcher) {
if (ids.isEmpty()) return@withContext
importMutex.withLock { dao.markWavelogUploaded(ids) }
}
override suspend fun exportAdi(ids: Set<Long>?, includeIncomplete: Boolean): String = withContext(dispatcher) { override suspend fun exportAdi(ids: Set<Long>?, includeIncomplete: Boolean): String = withContext(dispatcher) {
val records = dao.getAll() val records = dao.getAll()
.asSequence() .asSequence()
@@ -286,6 +293,7 @@ private fun QsoEntity.toDomain() = QsoRecord(
propagationMode = propagationMode, propagationMode = propagationMode,
lotwConfirmed = lotwConfirmed, lotwConfirmed = lotwConfirmed,
lotwUploaded = lotwUploaded, lotwUploaded = lotwUploaded,
wavelogUploaded = wavelogUploaded,
lotwReceived = lotwReceived, lotwReceived = lotwReceived,
lotwQslDate = lotwQslDate, lotwQslDate = lotwQslDate,
vuccGrids = vuccGrids.split(',').filter(String::isNotBlank), vuccGrids = vuccGrids.split(',').filter(String::isNotBlank),
@@ -323,6 +331,7 @@ private fun QsoRecord.toEntity() = QsoEntity(
propagationMode = propagationMode.ifBlank { if (satelliteName.isNotBlank()) "SAT" else "" }, propagationMode = propagationMode.ifBlank { if (satelliteName.isNotBlank()) "SAT" else "" },
lotwConfirmed = lotwConfirmed, lotwConfirmed = lotwConfirmed,
lotwUploaded = lotwUploaded, lotwUploaded = lotwUploaded,
wavelogUploaded = wavelogUploaded,
lotwReceived = lotwReceived, lotwReceived = lotwReceived,
lotwQslDate = lotwQslDate, lotwQslDate = lotwQslDate,
vuccGrids = vuccGrids.joinToString(","), vuccGrids = vuccGrids.joinToString(","),
@@ -51,7 +51,15 @@ class SelectionRepo(
null // null = no filtering null // null = no filtering
} else { } else {
val ids = resolveTypeIds(types) val ids = resolveTypeIds(types)
if (ids.isEmpty()) emptySet() else ids.toHashSet() when {
ids.isNotEmpty() -> ids.toHashSet()
// "All" is the CelesTrak active group, i.e. the whole catalogue: an empty
// persisted list must mean "no filtering" (show every satellite), not an
// empty list. Its list can be empty simply because CelesTrak answered 403
// to a repeated download inside the 2-hour update window.
Sources.allSourceType in types -> null
else -> emptySet()
}
} }
currentItems.map { items -> currentItems.map { items ->
if (catnumSet == null) items else items.filter { it.catnum in catnumSet } if (catnumSet == null) items else items.filter { it.catnum in catnumSet }
@@ -31,6 +31,7 @@ import com.rtbishop.look4sat.core.domain.model.PassesSettings
import com.rtbishop.look4sat.core.domain.model.RCSettings import com.rtbishop.look4sat.core.domain.model.RCSettings
import com.rtbishop.look4sat.core.domain.model.RadioControlSettings import com.rtbishop.look4sat.core.domain.model.RadioControlSettings
import com.rtbishop.look4sat.core.domain.model.WavelogSettings 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.GeoPos
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
import com.rtbishop.look4sat.core.domain.utility.positionToQth import com.rtbishop.look4sat.core.domain.utility.positionToQth
@@ -386,6 +387,40 @@ class SettingsRepo(
preferences.edit { putString(keyLastLotwSyncCallsign, callsign.trim().uppercase()) } preferences.edit { putString(keyLastLotwSyncCallsign, callsign.trim().uppercase()) }
//endregion //endregion
//region # Wavelog upload settings
private val keyWavelogUploadUrl = "wavelogUploadUrl"
private val keyWavelogUploadApiKey = "wavelogUploadApiKey"
private val keyWavelogUploadStationId = "wavelogUploadStationId"
private val keyWavelogUploadStationName = "wavelogUploadStationName"
private val keyWavelogUploadStationCallsign = "wavelogUploadStationCallsign"
private val keyWavelogUploadStationGrid = "wavelogUploadStationGrid"
private val _wavelogUploadSettings = MutableStateFlow(getWavelogUploadSettings())
override val wavelogUploadSettings: StateFlow<WavelogUploadSettings> = _wavelogUploadSettings
override fun updateWavelogUploadSettings(settings: WavelogUploadSettings) {
val trimmed = settings.copy(url = settings.url.trim(), apiKey = settings.apiKey.trim())
preferences.edit {
putString(keyWavelogUploadUrl, trimmed.url)
putString(keyWavelogUploadApiKey, trimmed.apiKey)
putString(keyWavelogUploadStationId, trimmed.stationId)
putString(keyWavelogUploadStationName, trimmed.stationName)
putString(keyWavelogUploadStationCallsign, trimmed.stationCallsign)
putString(keyWavelogUploadStationGrid, trimmed.stationGrid)
}
_wavelogUploadSettings.value = trimmed
}
private fun getWavelogUploadSettings(): WavelogUploadSettings = WavelogUploadSettings(
url = preferences.getString(keyWavelogUploadUrl, null).orEmpty(),
apiKey = preferences.getString(keyWavelogUploadApiKey, null).orEmpty(),
stationId = preferences.getString(keyWavelogUploadStationId, null).orEmpty(),
stationName = preferences.getString(keyWavelogUploadStationName, null).orEmpty(),
stationCallsign = preferences.getString(keyWavelogUploadStationCallsign, null).orEmpty(),
stationGrid = preferences.getString(keyWavelogUploadStationGrid, null).orEmpty()
)
//endregion
//region # Transceivers settings //region # Transceivers settings
private val _passesSettings = MutableStateFlow(getPassesSettings()) private val _passesSettings = MutableStateFlow(getPassesSettings())
override val passesSettings: StateFlow<PassesSettings> = _passesSettings override val passesSettings: StateFlow<PassesSettings> = _passesSettings
@@ -0,0 +1,327 @@
/*
* 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.logbook.QsoStatus
import com.rtbishop.look4sat.core.domain.logbook.displayMode
import com.rtbishop.look4sat.core.domain.model.WavelogUploadSettings
import com.rtbishop.look4sat.core.domain.repository.IWavelogUploadRepository
import com.rtbishop.look4sat.core.domain.repository.WavelogProblem
import com.rtbishop.look4sat.core.domain.repository.WavelogProbe
import com.rtbishop.look4sat.core.domain.repository.WavelogStationInfo
import com.rtbishop.look4sat.core.domain.repository.WavelogUploadOutcome
import com.rtbishop.look4sat.core.domain.repository.WavelogUploadPreview
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
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.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import java.util.TimeZone
import java.util.concurrent.TimeUnit
/**
* Uploads local QSO records to a self-hosted Wavelog via the v1 API.
*
* The server contract (verified against Wavelog 3.0.2, `Api.php` / `Logbook_model.php`):
* - `POST {base}/index.php/api/qso` with `{"key","station_profile_id","type":"adif","string"}`.
* - A clean import answers HTTP 201; skipped records (duplicates, differing locator/callsign)
* answer HTTP 400 with one message line per record while the remaining records ARE inserted.
* Duplicates count as "already in Wavelog" and are marked; `SKIPPED` records are not.
* - The export omits `STATION_CALLSIGN` so the server fills it from the chosen station profile
* (a present-but-different callsign would make the server skip the record).
*/
class WavelogUploadRepository internal constructor(
client: OkHttpClient = OkHttpClient()
) : IWavelogUploadRepository {
private val client = client.newBuilder()
.connectTimeout(15, TimeUnit.SECONDS)
.readTimeout(60, TimeUnit.SECONDS)
.callTimeout(120, TimeUnit.SECONDS)
.retryOnConnectionFailure(false)
.build()
private val jsonType = "application/json; charset=utf-8".toMediaType()
private val mutex = Mutex()
@Volatile private var pending: Pending? = null
private data class Pending(
val submittedIds: List<Long>,
val callsById: Map<Long, String>,
val adif: ByteArray,
val created: Long
)
override suspend fun probe(url: String, apiKey: String): WavelogProbe? = withContext(Dispatchers.IO) {
val base = url.trim().trimEnd('/')
if (base.isBlank() || apiKey.isBlank()) return@withContext null
try {
val authBody = get("$base/index.php/api/check_auth/$apiKey") ?: return@withContext null
val rights = JSONObject(authBody).optString("rights")
val stationsBody = get("$base/index.php/api/station_info/$apiKey") ?: return@withContext null
WavelogProbe(rights, parseStations(stationsBody))
} catch (_: Exception) {
null
}
}
override suspend fun prepare(records: List<QsoRecord>, settings: WavelogUploadSettings): WavelogUploadPreview =
withContext(Dispatchers.IO) {
mutex.withLock {
discardPending()
if (settings.url.isBlank() || settings.apiKey.isBlank()) {
return@withLock blocked(settings, WavelogProblem.NOT_CONFIGURED)
}
if (settings.stationId.isBlank()) return@withLock blocked(settings, WavelogProblem.NO_STATION)
var skipped = 0
val reasons = mutableMapOf<WavelogProblem, Int>()
val unique = hashSetOf<String>()
val submitted = records.sortedBy { it.startUtcMillis }.filter { record ->
when {
record.status != QsoStatus.COMPLETE -> {
skipped++
false
}
!gridCompatible(record.myGrid, settings.stationGrid) -> {
skipped++
reasons[WavelogProblem.GRID_MISMATCH] = (reasons[WavelogProblem.GRID_MISMATCH] ?: 0) + 1
false
}
!unique.add(stableQsoKey(record)) -> {
skipped++
false
}
else -> true
}
}
val preview = WavelogUploadPreview(
count = submitted.size,
skipped = skipped,
reasons = reasons,
stationLabel = stationLabel(settings),
firstUtc = submitted.firstOrNull()?.let { utcText(it.startUtcMillis) }.orEmpty(),
lastUtc = submitted.lastOrNull()?.let { utcText(it.startUtcMillis) }.orEmpty(),
contacts = submitted.map { record ->
"${shortText(record.startUtcMillis)} ${record.theirCallsign} ${record.displayMode} " +
record.satelliteName.substringBefore('(').trim()
},
submittedIds = submitted.map { it.id }
)
if (submitted.isNotEmpty()) {
pending = Pending(
submittedIds = submitted.map { it.id },
callsById = submitted.associate { it.id to it.theirCallsign.trim().uppercase(Locale.US) },
adif = AdifCodec.encode(submitted, includeStationCallsign = false).toByteArray(Charsets.UTF_8),
created = System.currentTimeMillis()
)
}
preview
}
}
override suspend fun upload(preview: WavelogUploadPreview, settings: WavelogUploadSettings): WavelogUploadOutcome =
withContext(Dispatchers.IO) {
mutex.withLock {
val payload = pending
pending = null // a preview is a one-shot authorization, including across failures
val now = System.currentTimeMillis()
if (payload == null || payload.submittedIds != preview.submittedIds || now - payload.created !in 0..900_000) {
return@withLock WavelogUploadOutcome.Expired
}
try {
val base = settings.url.trim().trimEnd('/')
val body = JSONObject().apply {
put("key", settings.apiKey.trim())
put("station_profile_id", settings.stationId)
put("type", "adif")
put("string", String(payload.adif, Charsets.UTF_8))
}.toString()
val request = Request.Builder()
.url("$base/index.php/api/qso")
.post(body.toRequestBody(jsonType))
.build()
client.newCall(request).execute().use { response ->
val text = response.body.string().take(64 * 1024)
classifyUploadResponse(response.code, text, payload.callsById)
}
} catch (_: Exception) {
WavelogUploadOutcome.Failed("Upload failed — check server address/network (Tailscale on?)")
} finally {
payload.adif.fill(0)
}
}
}
private fun discardPending() {
pending?.adif?.fill(0)
pending = null
}
private fun blocked(settings: WavelogUploadSettings, reason: WavelogProblem) = WavelogUploadPreview(
count = 0,
skipped = 0,
reasons = emptyMap(),
stationLabel = stationLabel(settings),
firstUtc = "",
lastUtc = "",
contacts = emptyList(),
submittedIds = emptyList(),
blockedBy = reason
)
private fun stationLabel(settings: WavelogUploadSettings): String = listOf(
settings.stationName, settings.stationCallsign, settings.stationGrid
).map(String::trim).filter(String::isNotBlank).joinToString(" · ")
private fun get(url: String): String? {
val request = Request.Builder().url(url).get().build()
return client.newCall(request).execute().use { response ->
if (!response.isSuccessful) null else response.body.string().take(256 * 1024)
}
}
}
/**
* Whether a record's logged grid is consistent with the Wavelog station profile grid.
* Mirror of the server's `adif_grid_check_location` prefix rule; either side blank
* disables the check, exactly like the server.
*/
internal fun gridCompatible(adifGrid: String, stationGrid: String): Boolean {
val adif = adifGrid.trim().uppercase(Locale.US)
val station = stationGrid.trim().uppercase(Locale.US)
if (adif.isEmpty() || station.isEmpty()) return true
val adifParts = adif.split(',').map(String::trim).filter(String::isNotEmpty)
val stationParts = station.split(',').map(String::trim).filter(String::isNotEmpty)
if (adifParts.isEmpty() || stationParts.isEmpty()) return true
val adifMargin = adifParts.size > 1
val stationMargin = stationParts.size > 1
return when {
adifMargin && stationMargin -> adifParts.sorted() == stationParts.sorted()
adifMargin -> false
stationMargin -> stationParts.any { adif.startsWith(it) }
else -> adif.startsWith(station) || station.startsWith(adif)
}
}
/**
* Classifies Wavelog's upload response.
*
* 2xx = everything imported. 400 = per-record errors: duplicates count as "already in Wavelog"
* (they are marked, so a re-run converges), `SKIPPED` records are excluded from marking, and
* any other message is surfaced verbatim with nothing marked.
*/
internal fun classifyUploadResponse(code: Int, body: String, callsById: Map<Long, String>): WavelogUploadOutcome = when {
code in 200..299 -> WavelogUploadOutcome.Imported(
imported = callsById.size, duplicates = 0, skipped = 0, markIds = callsById.keys.toList()
)
code == 401 -> WavelogUploadOutcome.Failed("Wavelog rejected the API key or station (HTTP 401)")
code == 403 -> WavelogUploadOutcome.Failed(
"API key has no write permission — create a read & write key in Wavelog (Settings → API)"
)
else -> classifyAbortMessages(body, callsById)
}
private val skipCallRegex = Regex("importing QSO with\\s*<b>([^<]+)</b>", RegexOption.IGNORE_CASE)
private fun classifyAbortMessages(body: String, callsById: Map<Long, String>): WavelogUploadOutcome {
val raw = rawMessages(body)
val lines = stripTags(raw).split('\n').map(String::trim).filter(String::isNotEmpty)
if (lines.isEmpty()) {
return WavelogUploadOutcome.Failed("Wavelog did not accept the upload — check the server log")
}
val duplicates = lines.count { it.contains("Duplicate for", ignoreCase = true) }
val skipLines = lines.filter { it.contains("SKIPPED", ignoreCase = true) }
val others = lines.filter {
!it.contains("Duplicate for", ignoreCase = true) && !it.contains("SKIPPED", ignoreCase = true)
}
if (others.isNotEmpty()) {
return WavelogUploadOutcome.Rejected("Wavelog reported: ${others.first().take(200)}")
}
val submittedCalls = callsById.values.toSet()
val skipCalls = skipCallRegex.findAll(raw)
.map { it.groupValues[1].trim().uppercase(Locale.US) }.toSet()
if (skipLines.isNotEmpty() && (skipCalls.isEmpty() || skipCalls.any { it !in submittedCalls })) {
// A skip we cannot map back to a submitted record: stay conservative, mark nothing.
return WavelogUploadOutcome.Rejected("Some records were skipped by Wavelog — nothing was marked; review and retry")
}
val markIds = callsById.filterValues { it !in skipCalls }.keys.toList()
val skipped = callsById.size - markIds.size
val imported = (callsById.size - duplicates - skipped).coerceAtLeast(0)
return WavelogUploadOutcome.Imported(imported, duplicates, skipped, markIds)
}
private fun rawMessages(body: String): String {
val json = runCatching { JSONObject(body) }.getOrNull()
return if (json != null) {
val messages = json.optJSONArray("messages")
if (messages != null) (0 until messages.length()).joinToString("") { messages.optString(it) }
else json.optString("reason")
} else {
body
}
}
private fun stripTags(raw: String): String = raw
.replace(Regex("(?i)<br\\s*/?>"), "\n")
.replace(Regex("<[^>]*>"), " ")
.replace("&quot;", "\"").replace("&#39;", "'").replace("&amp;", "&")
.replace(Regex("[ \\t]+"), " ")
.lines().joinToString("\n") { it.trim() }
/** Parses the v1 station_info array (only the fields the app uses). */
internal fun parseStations(body: String): List<WavelogStationInfo> {
val array = JSONArray(body)
return (0 until array.length()).mapNotNull { index ->
val item = array.optJSONObject(index) ?: return@mapNotNull null
val id = item.optString("station_id")
if (id.isBlank()) return@mapNotNull null
WavelogStationInfo(
id = id,
name = item.optString("station_profile_name"),
callsign = item.optString("station_callsign"),
grid = item.optString("station_gridsquare"),
active = item.optString("station_active") == "1"
)
}
}
private fun utcText(millis: Long): String = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US).apply {
timeZone = TimeZone.getTimeZone("UTC")
}.format(Date(millis))
private fun shortText(millis: Long): String = SimpleDateFormat("MM-dd HH:mm", Locale.US).apply {
timeZone = TimeZone.getTimeZone("UTC")
}.format(Date(millis))
@@ -43,6 +43,16 @@ class LocalSource(private val look4SatDao: Look4SatDao) : ILocalSource {
return selectedOrbitalObjects return selectedOrbitalObjects
} }
override suspend fun getEntriesEpochs() = look4SatDao.getEntriesEpochs()
override suspend fun getEntriesNames() = look4SatDao.getEntriesNames()
override suspend fun renameEntries(names: Map<Int, String>) =
names.forEach { (catnum, name) -> look4SatDao.renameEntry(catnum, name) }
override suspend fun deleteEntriesWithIds(ids: List<Int>) =
ids.chunked(999).forEach { idsPart -> look4SatDao.deleteEntriesWithIds(idsPart) }
override suspend fun insertEntries(entries: List<OrbitalData>) = look4SatDao.insertEntries(entries.toEntity()) override suspend fun insertEntries(entries: List<OrbitalData>) = look4SatDao.insertEntries(entries.toEntity())
override suspend fun deleteEntries() = look4SatDao.deleteEntries() override suspend fun deleteEntries() = look4SatDao.deleteEntries()
@@ -18,6 +18,7 @@
*/ */
package com.rtbishop.look4sat.core.data.repository 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.SatStatus
import com.rtbishop.look4sat.core.domain.model.SatStatusPage import com.rtbishop.look4sat.core.domain.model.SatStatusPage
import com.rtbishop.look4sat.core.domain.source.IRemoteSource import com.rtbishop.look4sat.core.domain.source.IRemoteSource
@@ -143,6 +144,123 @@ class AmSatRepositoryTest {
val page = repository.fetchStatus() val page = repository.fetchStatus()
assertEquals(4, page?.statuses?.single()?.days?.get(0)?.streakCount) 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) { private fun AmSatRepository.seedStatusCache(page: SatStatusPage) {
@@ -44,6 +44,7 @@ import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue import org.junit.Assert.assertTrue
import org.junit.Test import org.junit.Test
import java.io.InputStream import java.io.InputStream
import java.time.LocalDate
@OptIn(ExperimentalCoroutinesApi::class) @OptIn(ExperimentalCoroutinesApi::class)
class DatabaseRepoTest { class DatabaseRepoTest {
@@ -51,6 +52,10 @@ class DatabaseRepoTest {
private val dispatcher = StandardTestDispatcher() private val dispatcher = StandardTestDispatcher()
private val dataParser = DataParser(dispatcher) private val dataParser = DataParser(dispatcher)
// fixtures carry a current epoch: stale entries are pruned on every remote update
private val todayEpoch = LocalDate.now().let { "%02d%03d".format(it.year % 100, it.dayOfYear) }
private val fresherEpoch = "$todayEpoch.71955234".toDouble()
@Test @Test
fun `manual satellite import parses csv stream from content uri`() = runTest(dispatcher) { fun `manual satellite import parses csv stream from content uri`() = runTest(dispatcher) {
val uri = "content://look4sat/import/satellites" val uri = "content://look4sat/import/satellites"
@@ -150,20 +155,144 @@ class DatabaseRepoTest {
assertEquals(listOf(25544), settingsRepo.satelliteTypeIdsByType["All"]) assertEquals(listOf(25544), settingsRepo.satelliteTypeIdsByType["All"])
} }
@Test
fun `remote update takes the freshest elements and the preferred name`() = runTest(dispatcher) {
val primaryUrl = "https://example.com/primary.txt"
val secondaryUrl = "https://example.com/secondary.txt"
val localSource = FakeLocalSource()
val remoteSource = FakeRemoteSource().apply {
networkStreams[primaryUrl] = { validTleStream() }
networkStreams[secondaryUrl] = { fresherTleStream() }
}
val settingsRepo = FakeSettingsRepo(
dataSources = DataSourcesSettings(
satelliteUrls = listOf(primaryUrl, secondaryUrl),
transceiversUrls = emptyList()
)
)
val repository = DatabaseRepo(dispatcher, dataParser, localSource, remoteSource, settingsRepo)
repository.updateFromRemote()
val entry = localSource.insertedEntries.single { it.catnum == 25544 }
assertEquals("ISS (ZARYA)", entry.name)
assertEquals(fresherEpoch, entry.epoch, 1e-8)
}
@Test
fun `manual satellite import does not overwrite fresher data`() = runTest(dispatcher) {
val freshUri = "content://look4sat/import/fresh"
val staleUri = "content://look4sat/import/stale"
val localSource = FakeLocalSource()
val remoteSource = FakeRemoteSource().apply {
fileStreams[freshUri] = { fresherTleStream() }
fileStreams[staleUri] = { validTleStream() }
}
val settingsRepo = FakeSettingsRepo()
val repository = DatabaseRepo(dispatcher, dataParser, localSource, remoteSource, settingsRepo)
assertEquals(1, repository.updateTLEFromFile(freshUri))
assertEquals(1, repository.updateTLEFromFile(staleUri))
assertEquals(fresherEpoch, localSource.insertedEntries.single().epoch, 1e-8)
}
@Test
fun `remote update renames satellites even when their elements are not newer`() = runTest(dispatcher) {
val preferredUrl = "https://example.com/preferred.txt"
val freshUrl = "https://example.com/fresh.txt"
val localSource = FakeLocalSource()
val remoteSource = FakeRemoteSource().apply {
networkStreams[freshUrl] = { fresherTleStream() }
}
val settingsRepo = FakeSettingsRepo(
dataSources = DataSourcesSettings(
satelliteUrls = listOf(freshUrl),
transceiversUrls = emptyList()
)
)
val repository = DatabaseRepo(dispatcher, dataParser, localSource, remoteSource, settingsRepo)
repository.updateFromRemote()
assertEquals("ISS", localSource.insertedEntries.single().name)
// the user moves a source with older elements, but a better name, to the top of the list
remoteSource.networkStreams[preferredUrl] = { validTleStream() }
settingsRepo.dataSourcesSettings.value = DataSourcesSettings(
satelliteUrls = listOf(preferredUrl, freshUrl),
transceiversUrls = emptyList()
)
repository.updateFromRemote()
val entry = localSource.insertedEntries.single()
assertEquals("ISS (ZARYA)", entry.name)
assertEquals(fresherEpoch, entry.epoch, 1e-8)
}
@Test
fun `failed remote update records the attempt without claiming success`() = runTest(dispatcher) {
val failingUrl = "https://example.com/offline.txt"
val localSource = FakeLocalSource()
val remoteSource = FakeRemoteSource()
val settingsRepo = FakeSettingsRepo(
dataSources = DataSourcesSettings(
satelliteUrls = listOf(failingUrl),
transceiversUrls = emptyList()
)
)
settingsRepo.databaseState.value = DatabaseState(0, 0, 1_000L)
val repository = DatabaseRepo(dispatcher, dataParser, localSource, remoteSource, settingsRepo)
repository.updateFromRemote()
// the update timestamp stays put so the data is not presented as fresh, while the recorded
// attempt keeps the next app launch from hammering a source that is already failing
assertEquals(1_000L, settingsRepo.databaseState.value.updateTimestamp)
}
@Test
fun `remote update prunes satellites no source refreshed for a month`() = runTest(dispatcher) {
val url = "https://example.com/fresh.txt"
val localSource = FakeLocalSource()
val remoteSource = FakeRemoteSource().apply {
networkStreams[url] = { fresherTleStream() }
}
val settingsRepo = FakeSettingsRepo(
dataSources = DataSourcesSettings(
satelliteUrls = listOf(url),
transceiversUrls = emptyList()
)
)
val repository = DatabaseRepo(dispatcher, dataParser, localSource, remoteSource, settingsRepo)
// a satellite that disappeared from every catalog: elements stored a month and a half ago
val staleDate = LocalDate.now().minusDays(45)
val staleEpoch = "%02d%03d".format(staleDate.year % 100, staleDate.dayOfYear).toDouble() + 0.5
localSource.insertEntries(
listOf(OrbitalData("OLD SAT", staleEpoch, 15.5, 0.001, 51.6, 309.4, 203.6, 299.8, 99999, 0.0003, 0.0000128))
)
repository.updateFromRemote()
assertEquals(listOf(25544), localSource.insertedEntries.map { it.catnum })
}
private fun validCsvStream(): InputStream = """ private fun validCsvStream(): InputStream = """
OBJECT_NAME,OBJECT_ID,EPOCH,MEAN_MOTION,ECCENTRICITY,INCLINATION,RA_OF_ASC_NODE,ARG_OF_PERICENTER,MEAN_ANOMALY,EPHEMERIS_TYPE,CLASSIFICATION_TYPE,NORAD_CAT_ID,ELEMENT_SET_NO,REV_AT_EPOCH,BSTAR,MEAN_MOTION_DOT,MEAN_MOTION_DDOT OBJECT_NAME,OBJECT_ID,EPOCH,MEAN_MOTION,ECCENTRICITY,INCLINATION,RA_OF_ASC_NODE,ARG_OF_PERICENTER,MEAN_ANOMALY,EPHEMERIS_TYPE,CLASSIFICATION_TYPE,NORAD_CAT_ID,ELEMENT_SET_NO,REV_AT_EPOCH,BSTAR,MEAN_MOTION_DOT,MEAN_MOTION_DDOT
ISS (ZARYA),1998-067A,2021-11-16T12:28:09.322176,15.48582035,.0004694,51.6447,309.4881,203.6966,299.8876,0,U,25544,999,31220,.31985E-4,.1288E-4,0 ISS (ZARYA),1998-067A,${LocalDate.now()}T12:28:09.322176,15.48582035,.0004694,51.6447,309.4881,203.6966,299.8876,0,U,25544,999,31220,.31985E-4,.1288E-4,0
""".trimIndent().byteInputStream() """.trimIndent().byteInputStream()
private fun validTleStream(): InputStream = """ private fun validTleStream(): InputStream = """
ISS (ZARYA) ISS (ZARYA)
1 25544U 98067A 21320.51955234 .00001288 00000+0 31985-4 0 9990 1 25544U 98067A $todayEpoch.51955234 .00001288 00000+0 31985-4 0 9990
2 25544 51.6447 309.4881 0004694 203.6966 299.8876 15.48582035312205
""".trimIndent().byteInputStream()
private fun fresherTleStream(): InputStream = """
ISS
1 25544U 98067A $todayEpoch.71955234 .00001288 00000+0 31985-4 0 9990
2 25544 51.6447 309.4881 0004694 203.6966 299.8876 15.48582035312205 2 25544 51.6447 309.4881 0004694 203.6966 299.8876 15.48582035312205
""".trimIndent().byteInputStream() """.trimIndent().byteInputStream()
private fun jamxTleStream(): InputStream = """ private fun jamxTleStream(): InputStream = """
JAMX-0825b JAMX-0825b
1 98248U 26237.16675926 .00015724 00000-0 97477-3 0 00013 1 98248U $todayEpoch.16675926 .00015724 00000-0 97477-3 0 00013
2 98248 097.5373 310.9694 0011309 278.1232 340.7230 15.09766181000012 2 98248 097.5373 310.9694 0011309 278.1232 340.7230 15.09766181000012
""".trimIndent().byteInputStream() """.trimIndent().byteInputStream()
} }
@@ -195,6 +324,21 @@ private class FakeLocalSource : ILocalSource {
override suspend fun getEntriesWithIds(ids: List<Int>): List<OrbitalObject> = emptyList() override suspend fun getEntriesWithIds(ids: List<Int>): List<OrbitalObject> = emptyList()
override suspend fun getEntriesEpochs(): Map<Int, Double> =
insertedEntries.associate { entry -> entry.catnum to entry.epoch }
override suspend fun getEntriesNames(): Map<Int, String> =
insertedEntries.associate { entry -> entry.catnum to entry.name }
override suspend fun renameEntries(names: Map<Int, String>) = names.forEach { (catnum, name) ->
val index = insertedEntries.indexOfFirst { entry -> entry.catnum == catnum }
if (index >= 0) insertedEntries[index] = insertedEntries[index].copy(name = name)
}
override suspend fun deleteEntriesWithIds(ids: List<Int>) {
insertedEntries.removeAll { entry -> entry.catnum in ids }
}
override suspend fun insertEntries(entries: List<OrbitalData>) { override suspend fun insertEntries(entries: List<OrbitalData>) {
insertedEntries += entries insertedEntries += entries
} }
@@ -314,6 +458,11 @@ private class FakeSettingsRepo(dataSources: DataSourcesSettings = defaultDataSou
override fun updateWavelogSettings(settings: com.rtbishop.look4sat.core.domain.model.WavelogSettings) = Unit 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())
override fun updateWavelogUploadSettings(settings: com.rtbishop.look4sat.core.domain.model.WavelogUploadSettings) = Unit
override fun getWorkedGrids(): Set<String> = emptySet() override fun getWorkedGrids(): Set<String> = emptySet()
private val workedGrids = MutableStateFlow(emptySet<String>()) private val workedGrids = MutableStateFlow(emptySet<String>())
@@ -295,6 +295,27 @@ class QsoRepositoryTest {
assertEquals(1, dao.getAll().size) assertEquals(1, dao.getAll().size)
} }
@Test
fun markWavelogUploaded_setsOnlyTheWavelogFlag() = runBlocking {
val id = dao.save(loggedInApp("SO-50", lotwUploaded = false).toEntity())
repository.markWavelogUploaded(listOf(id))
val row = dao.getAll().single()
assertTrue(row.wavelogUploaded)
assertFalse(row.lotwUploaded)
}
@Test
fun save_editingAWavelogUploadedRecordCreatesANewRecord() = runBlocking {
val id = dao.save(loggedInApp("SO-50", lotwUploaded = false).copy(wavelogUploaded = true).toEntity())
repository.save(loggedInApp("SO-50", lotwUploaded = false).copy(id = id, theirCallsign = "BG5JVM"))
assertEquals(2, dao.getAll().size)
assertEquals(1, dao.getAll().count { it.wavelogUploaded })
}
/** A record as the log page creates it: tracker name, repeater pair, uplink band. */ /** A record as the log page creates it: tracker name, repeater pair, uplink band. */
private fun loggedInApp(satelliteName: String, lotwUploaded: Boolean) = QsoRecord( private fun loggedInApp(satelliteName: String, lotwUploaded: Boolean) = QsoRecord(
startUtcMillis = qsoStart, startUtcMillis = qsoStart,
@@ -355,6 +376,7 @@ class QsoRepositoryTest {
propagationMode = propagationMode, propagationMode = propagationMode,
lotwConfirmed = lotwConfirmed, lotwConfirmed = lotwConfirmed,
lotwUploaded = lotwUploaded, lotwUploaded = lotwUploaded,
wavelogUploaded = wavelogUploaded,
lotwReceived = lotwReceived, lotwReceived = lotwReceived,
lotwQslDate = lotwQslDate, lotwQslDate = lotwQslDate,
vuccGrids = vuccGrids.joinToString(","), vuccGrids = vuccGrids.joinToString(","),
@@ -393,5 +415,9 @@ class QsoRepositoryTest {
override suspend fun markUploaded(ids: List<Long>) { override suspend fun markUploaded(ids: List<Long>) {
ids.forEach { id -> rows[id]?.let { rows[id] = it.copy(lotwUploaded = true) } } 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) } }
}
} }
} }
@@ -23,6 +23,7 @@ import com.rtbishop.look4sat.core.domain.predict.OrbitalObject
import com.rtbishop.look4sat.core.domain.repository.ISelectionRepo import com.rtbishop.look4sat.core.domain.repository.ISelectionRepo
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
import com.rtbishop.look4sat.core.domain.source.ILocalSource import com.rtbishop.look4sat.core.domain.source.ILocalSource
import com.rtbishop.look4sat.core.domain.source.Sources
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
@@ -241,6 +242,26 @@ class SelectionRepoSearchTest {
assertEquals(sampleItems.map { it.catnum }.toSet(), results.map { it.catnum }.toSet()) assertEquals(sampleItems.map { it.catnum }.toSet(), results.map { it.catnum }.toSet())
} }
@Test
fun `All with empty persisted ids falls back to no filtering`() = runTest {
// CelesTrak answers 403 to a repeated download inside its 2-hour update window,
// so the persisted "All" id list can be empty. Selecting All then means "all
// satellites" (no filtering) instead of blanking the list.
val repo = createRepo(sampleItems)
repo.setTypes(listOf(Sources.allSourceType))
val results = repo.getEntriesFlow().first()
assertEquals(sampleItems.map { it.catnum }.toSet(), results.map { it.catnum }.toSet())
}
@Test
fun `other source type with empty persisted ids still shows empty`() = runTest {
// Only "All" falls back to no filtering; a regular source without persisted ids
// keeps the previous behaviour (empty list).
val repo = createRepo(sampleItems)
repo.setTypes(listOf("Amateur"))
assertTrue(repo.getEntriesFlow().first().isEmpty())
}
@Test @Test
fun `getTypesList excludes only the Other placeholder`() = runTest { fun `getTypesList excludes only the Other placeholder`() = runTest {
val repo = createRepo(sampleItems) val repo = createRepo(sampleItems)
@@ -282,6 +303,10 @@ private class FakeLocalSourceForSearch(
override suspend fun getEntriesTotal(): Int = items.size override suspend fun getEntriesTotal(): Int = items.size
override suspend fun getEntriesList(): List<SatItem> = items override suspend fun getEntriesList(): List<SatItem> = items
override suspend fun getEntriesWithIds(ids: List<Int>): List<OrbitalObject> = emptyList() override suspend fun getEntriesWithIds(ids: List<Int>): List<OrbitalObject> = emptyList()
override suspend fun getEntriesEpochs(): Map<Int, Double> = emptyMap()
override suspend fun getEntriesNames(): Map<Int, String> = emptyMap()
override suspend fun renameEntries(names: Map<Int, String>) = Unit
override suspend fun deleteEntriesWithIds(ids: List<Int>) = Unit
override suspend fun insertEntries(entries: List<OrbitalData>) = Unit override suspend fun insertEntries(entries: List<OrbitalData>) = Unit
override suspend fun deleteEntries() = Unit override suspend fun deleteEntries() = Unit
override suspend fun getIdsWithModes(modes: List<String>): List<Int> = sstvIds override suspend fun getIdsWithModes(modes: List<String>): List<Int> = sstvIds
@@ -321,6 +346,9 @@ private class FakeSettingsRepoForSearch : ISettingsRepo {
) )
override val wavelogSettings: StateFlow<com.rtbishop.look4sat.core.domain.model.WavelogSettings> = override val wavelogSettings: StateFlow<com.rtbishop.look4sat.core.domain.model.WavelogSettings> =
MutableStateFlow(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
override val lotwSettings: StateFlow<com.rtbishop.look4sat.core.domain.model.LoTWSettings> = override val lotwSettings: StateFlow<com.rtbishop.look4sat.core.domain.model.LoTWSettings> =
MutableStateFlow(com.rtbishop.look4sat.core.domain.model.LoTWSettings()) MutableStateFlow(com.rtbishop.look4sat.core.domain.model.LoTWSettings())
@@ -0,0 +1,197 @@
/*
* 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.
*/
package com.rtbishop.look4sat.core.data.repository
import com.rtbishop.look4sat.core.domain.logbook.QsoRecord
import com.rtbishop.look4sat.core.domain.logbook.QsoStatus
import com.rtbishop.look4sat.core.domain.model.WavelogUploadSettings
import com.rtbishop.look4sat.core.domain.repository.WavelogProblem
import com.rtbishop.look4sat.core.domain.repository.WavelogUploadOutcome
import kotlinx.coroutines.runBlocking
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class WavelogUploadRepositoryTest {
// region # gridCompatible (mirror of the server's adif_grid_check_location prefix rule)
@Test
fun grid_sameGridsPass() {
assertTrue(gridCompatible("OL62TI", "OL62TI"))
}
@Test
fun grid_longerAdifGridPasses() {
assertTrue(gridCompatible("OL62TI", "OL62"))
}
@Test
fun grid_longerStationGridPasses() {
assertTrue(gridCompatible("OL62", "OL62TI"))
}
@Test
fun grid_differentSquaresFail() {
assertFalse(gridCompatible("PM01", "OL62"))
}
@Test
fun grid_blankSidesDisableTheCheck() {
assertTrue(gridCompatible("", "OL62"))
assertTrue(gridCompatible("OL62", ""))
}
@Test
fun grid_stationMarginAcceptsAPrefixPart() {
assertTrue(gridCompatible("OL63AA", "OL62,OL63"))
assertFalse(gridCompatible("PM01AA", "OL62,OL63"))
}
@Test
fun grid_caseInsensitive() {
assertTrue(gridCompatible("ol62ti", "OL62"))
}
// endregion
// region # classifyUploadResponse
private val calls = mapOf(1L to "BG5JVM", 2L to "BH6RJD", 3L to "BI3SIR")
@Test
fun createdMarksEverything() {
val outcome = classifyUploadResponse(
201, """{"status":"created","adif_count":3,"adif_errors":0,"messages":[""]}""", calls
)
assertEquals(WavelogUploadOutcome.Imported(3, 0, 0, listOf(1L, 2L, 3L)), outcome)
}
@Test
fun duplicatesAreCountedAndStillMarked() {
val body = """{"status":"abort","messages":["Date/Time: 2026-10-04 12:34:00 Callsign: BG5JVM Band: 70CM Duplicate for BA7OPF<br>"]}"""
val outcome = classifyUploadResponse(400, body, calls)
assertEquals(WavelogUploadOutcome.Imported(2, 1, 0, listOf(1L, 2L, 3L)), outcome)
}
@Test
fun skippedRecordIsNotMarked() {
val body = """{"status":"abort","messages":["Differing locator <b>PM01</b> while importing QSO with <b>BH6RJD</b> for station locator <b>OL62TI</b>: SKIPPED<br>"]}"""
val outcome = classifyUploadResponse(400, body, calls)
assertEquals(WavelogUploadOutcome.Imported(2, 0, 1, listOf(1L, 3L)), outcome)
}
@Test
fun duplicateAndSkipTogether() {
val body = """{"status":"abort","messages":["Differing locator <b>PM01</b> while importing QSO with <b>BH6RJD</b> for station locator <b>OL62TI</b>: SKIPPED<br>Date/Time: 2026-10-04 12:34:00 Callsign: BG5JVM Band: 70CM Duplicate for BA7OPF<br>"]}"""
val outcome = classifyUploadResponse(400, body, calls)
assertEquals(WavelogUploadOutcome.Imported(1, 1, 1, listOf(1L, 3L)), outcome)
}
@Test
fun unmappedSkipMarksNothing() {
val body = """{"status":"abort","messages":["Differing locator <b>PM01</b> while importing QSO with <b>N0CALL</b> for station locator <b>OL62TI</b>: SKIPPED<br>"]}"""
val outcome = classifyUploadResponse(400, body, calls)
assertTrue(outcome is WavelogUploadOutcome.Rejected)
}
@Test
fun otherMessagesAreRejected() {
val body = """{"status":"abort","messages":["QSO on 2026-10-04: You tried to import a QSO without any given CALL. This QSO wasn't imported. It's invalid.<br>"]}"""
val outcome = classifyUploadResponse(400, body, calls)
assertTrue(outcome is WavelogUploadOutcome.Rejected)
}
@Test
fun authFailuresAreActionable() {
assertTrue(classifyUploadResponse(401, "", calls) is WavelogUploadOutcome.Failed)
val forbidden = classifyUploadResponse(403, """{"reason":"API key does not have write permissions"}""", calls)
assertTrue(forbidden is WavelogUploadOutcome.Failed)
assertTrue((forbidden as WavelogUploadOutcome.Failed).message.contains("write"))
}
@Test
fun htmlGarbageOrEmptyBodyMarksNothing() {
assertTrue(classifyUploadResponse(400, "<html>oops</html>", calls) is WavelogUploadOutcome.Rejected)
assertTrue(classifyUploadResponse(400, "", calls) is WavelogUploadOutcome.Failed)
}
// endregion
// region # parseStations + prepare
@Test
fun stationsParse() {
val body = """[{"station_id":"1","station_profile_name":"Shenzhen","station_gridsquare":"OL62TI","station_callsign":"BA7OPF","station_active":"1"}]"""
val stations = parseStations(body)
assertEquals(1, stations.size)
assertEquals("1", stations.single().id)
assertEquals("Shenzhen", stations.single().name)
assertEquals("BA7OPF", stations.single().callsign)
assertEquals("OL62TI", stations.single().grid)
assertTrue(stations.single().active)
}
@Test
fun prepareWithoutConfigIsBlocked() = runBlocking {
val repo = WavelogUploadRepository()
val preview = repo.prepare(listOf(sampleRecord(start = 1_700_000_000_000L)), WavelogUploadSettings())
assertEquals(WavelogProblem.NOT_CONFIGURED, preview.blockedBy)
assertEquals(0, preview.count)
}
@Test
fun prepareWithoutStationIsBlocked() = runBlocking {
val repo = WavelogUploadRepository()
val settings = WavelogUploadSettings(url = "http://example.org", apiKey = "k")
val preview = repo.prepare(listOf(sampleRecord(start = 1_700_000_000_000L)), settings)
assertEquals(WavelogProblem.NO_STATION, preview.blockedBy)
}
@Test
fun prepareSkipsGridMismatchAndDeduplicates() = runBlocking {
val repo = WavelogUploadRepository()
val settings = WavelogUploadSettings(
url = "http://example.org", apiKey = "k", stationId = "1",
stationName = "Shenzhen", stationCallsign = "BA7OPF", stationGrid = "OL62TI"
)
val records = listOf(
sampleRecord(start = 1_700_000_000_000L, myGrid = "PM01AA"),
sampleRecord(start = 1_700_000_120_000L, myGrid = "OL62TI"),
sampleRecord(start = 1_700_000_120_000L, myGrid = "OL62TI") // batch duplicate of the previous
)
val preview = repo.prepare(records, settings)
assertEquals(1, preview.count)
assertEquals(2, preview.skipped)
assertEquals(1, preview.reasons[WavelogProblem.GRID_MISMATCH])
assertEquals(1, preview.submittedIds.size)
assertEquals("Shenzhen · BA7OPF · OL62TI", preview.stationLabel)
}
// endregion
private fun sampleRecord(start: Long, myGrid: String = "OL62TI") = QsoRecord(
id = start,
startUtcMillis = start,
theirCallsign = "BG5JVM",
myCallsign = "BA7OPF",
myGrid = myGrid,
txFrequencyHz = 145_850_000L,
rxFrequencyHz = 436_795_000L,
band = "2M",
rxBand = "70CM",
mode = "FM",
satelliteName = "SO-50",
propagationMode = "SAT",
status = QsoStatus.COMPLETE
)
}
@@ -18,7 +18,7 @@ import java.util.TimeZone
object AdifCodec { object AdifCodec {
private val utc = TimeZone.getTimeZone("UTC") private val utc = TimeZone.getTimeZone("UTC")
fun encode(records: List<QsoRecord>): String = buildString { fun encode(records: List<QsoRecord>, includeStationCallsign: Boolean = true): String = buildString {
append(field("ADIF_VER", "3.1.7")) append(field("ADIF_VER", "3.1.7"))
append(field("PROGRAMID", "Look4Sat")) append(field("PROGRAMID", "Look4Sat"))
append("<EOH>\r\n") append("<EOH>\r\n")
@@ -32,7 +32,7 @@ object AdifCodec {
append(field("TIME_OFF", format(it, "HHmmss"))) append(field("TIME_OFF", format(it, "HHmmss")))
} }
append(field("CALL", record.theirCallsign)) append(field("CALL", record.theirCallsign))
append(field("STATION_CALLSIGN", record.myCallsign)) if (includeStationCallsign) append(field("STATION_CALLSIGN", record.myCallsign))
appendOptional("MODE", record.mode) appendOptional("MODE", record.mode)
appendOptional("SUBMODE", record.submode) appendOptional("SUBMODE", record.submode)
appendOptional("GRIDSQUARE", record.theirGrid) appendOptional("GRIDSQUARE", record.theirGrid)
@@ -23,6 +23,10 @@ interface IQsoRepository {
* logbook row can show which grids the QSO went out under. * logbook row can show which grids the QSO went out under.
*/ */
suspend fun markUploaded(ids: List<Long>, grids: List<String> = emptyList()) 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>)
suspend fun exportAdi(ids: Set<Long>? = null, includeIncomplete: Boolean = false): String suspend fun exportAdi(ids: Set<Long>? = null, includeIncomplete: Boolean = false): String
suspend fun importAdi(content: String): AdifImportResult suspend fun importAdi(content: String): AdifImportResult
suspend fun mergeConfirmed(records: List<QsoRecord>): AdifImportResult suspend fun mergeConfirmed(records: List<QsoRecord>): AdifImportResult
@@ -40,6 +40,8 @@ data class QsoRecord(
val propagationMode: String = if (satelliteName.isNotBlank()) "SAT" else "", val propagationMode: String = if (satelliteName.isNotBlank()) "SAT" else "",
val lotwConfirmed: Boolean = false, val lotwConfirmed: Boolean = false,
val lotwUploaded: Boolean = false, 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,
val lotwReceived: Boolean = false, val lotwReceived: Boolean = false,
val lotwQslDate: String = "", val lotwQslDate: String = "",
val vuccGrids: List<String> = emptyList(), val vuccGrids: List<String> = emptyList(),
@@ -0,0 +1,90 @@
/*
* 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.logbook
import com.rtbishop.look4sat.core.domain.repository.WavelogProblem
import com.rtbishop.look4sat.core.domain.repository.WavelogUploadOutcome
import com.rtbishop.look4sat.core.domain.repository.WavelogUploadPreview
/**
* The records a Wavelog upload may submit: locally recorded contacts only.
*
* Pure LoTW-imported confirmation rows never carry [QsoRecord.lotwUploaded] (the ADIF decoder
* does not set it), while a locally recorded row keeps its value through a confirmation merge —
* [withConfirmation] leaves `lotwUploaded` untouched. Confirmed local rows uploaded to LoTW
* from this app therefore stay eligible; imported history stays out of the upload.
*/
fun wavelogUploadCandidates(records: List<QsoRecord>): List<QsoRecord> = records.filter {
it.status == QsoStatus.COMPLETE && !it.wavelogUploaded && (!it.lotwConfirmed || it.lotwUploaded)
}
/** Operator-facing wording for why a record cannot enter a Wavelog upload. */
fun WavelogProblem.label(): String = when (this) {
WavelogProblem.NOT_CONFIGURED -> "Wavelog upload not configured"
WavelogProblem.NO_STATION -> "no station profile selected"
WavelogProblem.GRID_MISMATCH -> "logged grid outside the Wavelog station grid"
}
/** One-line summary of the records excluded before a Wavelog upload (empty when none). */
fun wavelogSkipSummary(preview: WavelogUploadPreview): String {
if (preview.skipped <= 0) return ""
val detail = preview.reasons.entries.sortedByDescending { it.value }
.joinToString(", ") { (problem, count) -> "$count× ${problem.label()}" }
return "${preview.skipped} QSO(s) skipped" + if (detail.isNotBlank()) " — $detail" else ""
}
/** One-line result wording for a finished Wavelog upload. */
fun wavelogResultMessage(result: WavelogUploadOutcome): String = when (result) {
is WavelogUploadOutcome.Imported -> buildString {
if (result.imported == 0 && result.duplicates > 0) {
append("All ${result.markIds.size} record(s) already in Wavelog")
} else {
append("Uploaded ${result.imported} QSO(s) to Wavelog")
if (result.duplicates > 0) append(" — ${result.duplicates} already there")
}
if (result.skipped > 0) append(" — ${result.skipped} skipped by Wavelog")
}
is WavelogUploadOutcome.Rejected -> result.message
is WavelogUploadOutcome.Failed -> result.message
WavelogUploadOutcome.Expired -> "Preview expired — tap upload again"
}
/**
* Message for the piggybacked Wavelog side when nothing can be submitted —
* null when there is nothing to say (fully caught up, no held-back records).
*/
fun wavelogIdleSegment(preview: WavelogUploadPreview): String? = when {
preview.blockedBy != null -> "Wavelog: ${preview.blockedBy.label()}"
preview.skipped > 0 -> "Wavelog: ${wavelogSkipSummary(preview)}"
else -> null
}
/**
* Message for a finished piggybacked Wavelog upload: the server outcome plus
* the records held back before the POST (e.g. grids outside the station profile).
*/
fun wavelogConfirmedSegment(outcome: WavelogUploadOutcome, preview: WavelogUploadPreview): String {
val base = wavelogResultMessage(outcome)
val head = if (outcome is WavelogUploadOutcome.Imported) base else "Wavelog: $base"
val skip = wavelogSkipSummary(preview)
return if (skip.isNotBlank()) "$head\n$skip" else head
}
/** Message when the piggybacked upload threw outright — records stay unmarked for retry. */
fun wavelogTransportFailureSegment(): String = "Wavelog: upload failed — it will retry with your next upload"
@@ -104,3 +104,40 @@ data class GridQso(
else -> band.trim().uppercase() else -> band.trim().uppercase()
} }
} }
/**
* Every MY-side grid of this QSO: [myGrids] (MY_GRIDSQUARE + MY_VUCC_GRIDS)
* with a single-grid fallback to [myGrid] for records synced before
* multi-grid support. Empty when the record carries no MY-side grid at all.
*/
fun GridQso.myStationGrids(): Set<String> =
myGrids.ifEmpty { myGrid?.let { setOf(it) }.orEmpty() }
/**
* Station-location (台址) grouping key: the QSO's MY grid set, sorted and
* comma-joined ("OL62", "OM60,PM01"). Records covering the same grid set
* belong to the same 台址 — they are VUCC-equivalent. Null when the record
* carries no MY-side grid. Backs both the map's operated-grid selector and
* its per-台址 grid-detail filtering, so the two scope by one identity.
*/
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.
*/
fun List<GridQso>.scopedToStation(stationId: String?): List<GridQso> =
if (stationId == null) this else filter { it.stationGridSetKey() == stationId }
/**
* For every grid in the store, the callsign of the earliest QSO — scoped to the
* operated-grid selector's choice first, so a specific 台址 labels each worked
* cell with ITS first contact; null ("All") keeps the global first call.
*/
fun Map<String, List<GridQso>>.firstCallsByGrid(stationId: String?): Map<String, String> =
mapNotNull { (grid, qsos) ->
qsos.scopedToStation(stationId).minByOrNull { it.epochMs }?.let { grid to it.call }
}.toMap()
@@ -30,9 +30,10 @@ data class SatReport(
/** State of one 2-hour slot */ /** State of one 2-hour slot */
data class SatSlot( data class SatSlot(
val statusColor: Long, // ARGB 状态色(-1 = 无报告) val statusColor: Long, // ARGB 状态色(灰=无报告;判定见 AmSatRepository.slotStatusOf)
val count: Int, // 报告数量(0 = 无) val count: Int, // 官网页口径:多数方计数(冲突槽/异常槽=总条数;0 = 无)
val reportIds: List<String> = emptyList() // 该槽报告 ID 列表 val reportIds: List<String> = emptyList(), // 该槽报告 ID 列表
val isConflicted: Boolean = false // 官网页"Conflicting reports":该槽无严格多数(弹窗据此标注)
) )
/** One satellite day (12 two-hour slots) */ /** One satellite day (12 two-hour slots) */
@@ -109,6 +109,19 @@ data class WavelogSettings(
val isConfigured: Boolean get() = url.isNotBlank() && token.isNotBlank() val isConfigured: Boolean get() = url.isNotBlank() && token.isNotBlank()
} }
/** Wavelog QSO-upload configuration (a separate block from the worked-grids sync). */
data class WavelogUploadSettings(
val url: String = "",
val apiKey: String = "",
val stationId: String = "",
val stationName: String = "",
val stationCallsign: String = "",
val stationGrid: String = ""
) {
val isConfigured: Boolean get() = url.isNotBlank() && apiKey.isNotBlank()
val isReady: Boolean get() = isConfigured && stationId.isNotBlank()
}
data class LoTWSettings( data class LoTWSettings(
val callsign: String = "", val callsign: String = "",
val password: String = "" val password: String = ""
@@ -37,30 +37,42 @@ data class OrbitalData(
val xno: Double = meanmo * TWO_PI / MIN_PER_DAY val xno: Double = meanmo * TWO_PI / MIN_PER_DAY
val orbitalPeriod: Double = MIN_PER_DAY / meanmo val orbitalPeriod: Double = MIN_PER_DAY / meanmo
val isDeepSpace: Boolean = orbitalPeriod >= 225.0 // NearEarth (period < 225 min) or DeepSpace (period >= 225 min) val isDeepSpace: Boolean = orbitalPeriod >= 225.0 // NearEarth (period < 225 min) or DeepSpace (period >= 225 min)
/** Absolute age of this element set, used to pick the freshest data among several sources. */
val epochDaynum: Double = epochToDaynum(epoch)
fun getObject(): OrbitalObject = if (isDeepSpace) DeepSpaceObject(this) else NearEarthObject(this) fun getObject(): OrbitalObject = if (isDeepSpace) DeepSpaceObject(this) else NearEarthObject(this)
/** Check if satellite has likely decayed by the given time. */ /** Check if satellite has likely decayed by the given time. */
fun hasDecayed(currentTimeMillis: Long): Boolean { fun hasDecayed(currentTimeMillis: Long): Boolean {
if (ndot == 0.0) return false if (ndot == 0.0) return false
val currentDaynum = (currentTimeMillis - 315446400000L) / 86400000.0 return CelestialComputer.hasDecayed(meanmo, ndot, epochDaynum, timeToDaynum(currentTimeMillis))
val epochDaynum = epochToDaynum(epoch)
return CelestialComputer.hasDecayed(meanmo, ndot, epochDaynum, currentDaynum)
} }
private fun epochToDaynum(epoch: Double): Double { companion object {
var year = kotlin.math.floor(epoch * 1E-3) /** Days since 31 Dec 1979, the reference point every daynum in the app is based on. */
val day = (epoch * 1E-3 - year) * 1000.0 fun timeToDaynum(timeMillis: Long): Double = (timeMillis - 315446400000L) / 86400000.0
year = if (year < 57) year + 2000 else year + 1900
// daynum = days since 31 Dec 1979, Julian date of 31Dec79 = 2444238.5
val jan1Jd = julianDateOfYear(year)
return jan1Jd + day - 2444238.5
}
private fun julianDateOfYear(theYear: Double): Double { /**
val aYear = theYear - 1 * Days since 31 Dec 1979 for a TLE style YYDDD.ffffffff epoch. Raw epochs are not
val a = kotlin.math.floor(aYear / 100).toLong() * comparable across decades (a 1999 epoch reads as 99xxx, a 2026 one as 26xxx), so
val b = 2 - a + a / 4 * freshness checks must always go through this conversion.
val i = kotlin.math.floor(365.25 * aYear).toLong() */
return i + (30.6001 * 14).toLong() + 1720994.5 + b fun epochToDaynum(epoch: Double): Double {
var year = kotlin.math.floor(epoch * 1E-3)
val day = (epoch * 1E-3 - year) * 1000.0
year = if (year < 57) year + 2000 else year + 1900
// daynum = days since 31 Dec 1979, Julian date of 31Dec79 = 2444238.5
val jan1Jd = julianDateOfYear(year)
return jan1Jd + day - 2444238.5
}
private fun julianDateOfYear(theYear: Double): Double {
val aYear = theYear - 1
val a = kotlin.math.floor(aYear / 100).toLong()
val b = 2 - a + a / 4
val i = kotlin.math.floor(365.25 * aYear).toLong()
return i + (30.6001 * 14).toLong() + 1720994.5 + b
}
} }
} }
@@ -34,6 +34,7 @@ interface IMainContainer {
val amSatRepo: IAmSatRepository val amSatRepo: IAmSatRepository
val updateRepo: IUpdateRepository val updateRepo: IUpdateRepository
val wavelogRepo: IWavelogRepository val wavelogRepo: IWavelogRepository
val wavelogUploadRepository: IWavelogUploadRepository
val lotwRepo: com.rtbishop.look4sat.core.domain.repository.ILoTWRepository val lotwRepo: com.rtbishop.look4sat.core.domain.repository.ILoTWRepository
val qsoRepository: IQsoRepository val qsoRepository: IQsoRepository
val lotwUploadRepository: ILoTWUploadRepository val lotwUploadRepository: ILoTWUploadRepository
@@ -24,6 +24,7 @@ import com.rtbishop.look4sat.core.domain.model.PassesSettings
import com.rtbishop.look4sat.core.domain.model.RCSettings import com.rtbishop.look4sat.core.domain.model.RCSettings
import com.rtbishop.look4sat.core.domain.model.RadioControlSettings import com.rtbishop.look4sat.core.domain.model.RadioControlSettings
import com.rtbishop.look4sat.core.domain.model.WavelogSettings 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.GeoPos
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
@@ -124,6 +125,11 @@ interface ISettingsRepo {
fun setMarkedGridStations(stations: Map<String, List<com.rtbishop.look4sat.core.domain.model.MarkedStation>>) fun setMarkedGridStations(stations: Map<String, List<com.rtbishop.look4sat.core.domain.model.MarkedStation>>)
//endregion //endregion
//region # Wavelog upload settings
val wavelogUploadSettings: StateFlow<WavelogUploadSettings>
fun updateWavelogUploadSettings(settings: WavelogUploadSettings)
//endregion
//region # LoTW confirmed-grids settings //region # LoTW confirmed-grids settings
val lotwSettings: StateFlow<com.rtbishop.look4sat.core.domain.model.LoTWSettings> val lotwSettings: StateFlow<com.rtbishop.look4sat.core.domain.model.LoTWSettings>
fun updateLoTWSettings(settings: com.rtbishop.look4sat.core.domain.model.LoTWSettings) fun updateLoTWSettings(settings: com.rtbishop.look4sat.core.domain.model.LoTWSettings)
@@ -0,0 +1,79 @@
/*
* 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.WavelogUploadSettings
/** One station profile as returned by the v1 station_info endpoint. */
data class WavelogStationInfo(
val id: String,
val name: String,
val callsign: String,
val grid: String,
val active: Boolean
)
/** Result of probing a Wavelog server (check_auth + station_info). */
data class WavelogProbe(val rights: String, val stations: List<WavelogStationInfo>)
/** Why a record (or the whole upload) cannot be submitted. */
enum class WavelogProblem { NOT_CONFIGURED, NO_STATION, GRID_MISMATCH }
/** Records skipped before the POST (with reasons), plus the records that will be submitted. */
data class WavelogUploadPreview(
val count: Int,
val skipped: Int,
val reasons: Map<WavelogProblem, Int>,
val stationLabel: String,
val firstUtc: String,
val lastUtc: String,
val contacts: List<String>,
val submittedIds: List<Long>,
val blockedBy: WavelogProblem? = null
)
sealed interface WavelogUploadOutcome {
/** [markIds] can be smaller than the submission when Wavelog reported skipped records. */
data class Imported(
val imported: Int,
val duplicates: Int,
val skipped: Int,
val markIds: List<Long>
) : WavelogUploadOutcome
data class Rejected(val message: String) : WavelogUploadOutcome
data class Failed(val message: String) : WavelogUploadOutcome
data object Expired : WavelogUploadOutcome
}
/**
* Uploads local QSO records to a self-hosted Wavelog via the v1 API
* (`POST {base}/index.php/api/qso` with a batch ADIF string).
*/
interface IWavelogUploadRepository {
/** check_auth + station_info; null on any transport/parse failure. */
suspend fun probe(url: String, apiKey: String): WavelogProbe?
/** Filters + encodes the batch and keeps a one-shot pending payload for [upload]. */
suspend fun prepare(records: List<QsoRecord>, settings: WavelogUploadSettings): WavelogUploadPreview
/** POSTs the prepared batch and classifies Wavelog's response. */
suspend fun upload(preview: WavelogUploadPreview, settings: WavelogUploadSettings): WavelogUploadOutcome
}
@@ -26,6 +26,10 @@ interface ILocalSource {
suspend fun getEntriesTotal(): Int suspend fun getEntriesTotal(): Int
suspend fun getEntriesList(): List<SatItem> suspend fun getEntriesList(): List<SatItem>
suspend fun getEntriesWithIds(ids: List<Int>): List<OrbitalObject> suspend fun getEntriesWithIds(ids: List<Int>): List<OrbitalObject>
suspend fun getEntriesEpochs(): Map<Int, Double>
suspend fun getEntriesNames(): Map<Int, String>
suspend fun renameEntries(names: Map<Int, String>)
suspend fun deleteEntriesWithIds(ids: List<Int>)
suspend fun insertEntries(entries: List<OrbitalData>) suspend fun insertEntries(entries: List<OrbitalData>)
suspend fun deleteEntries() suspend fun deleteEntries()
suspend fun getIdsWithModes(modes: List<String>): List<Int> suspend fun getIdsWithModes(modes: List<String>): List<Int>
@@ -18,41 +18,32 @@
package com.rtbishop.look4sat.core.domain.source package com.rtbishop.look4sat.core.domain.source
object Sources { object Sources {
// Default TLE sources, aligned with upstream's "most specific to most generic" order:
// the first source that knows a satellite gets to name it, with the full catalogue
// (CelesTrak active) as the fallback. Upstream dropped the old CelesTrak category
// groups (amateur/cubesat/weather/...) — a single `active` group covers them and keeps
// the request count (and CelesTrak's 2-hourly "already downloaded" 403s) down.
// The three BA7OPF additions keep their own spots: Kaggle LAPAN-A2 first and the two
// AutoTLE mirrors next to R4UAB (Chinese amateur catalogues, freshest for CN sats).
val satelliteDataUrls = mapOf( val satelliteDataUrls = mapOf(
"LAPAN-A2" to "https://www.kaggle.com/api/v1/datasets/download/muazamnugroho/lapan-a2-satellite-two-line-element-tle-dataset/LAPAN-A2_TLE_latest.txt", "LAPAN-A2" to "https://www.kaggle.com/api/v1/datasets/download/muazamnugroho/lapan-a2-satellite-two-line-element-tle-dataset/LAPAN-A2_TLE_latest.txt",
"ARISS" to "https://live.ariss.org/iss.txt",
"R4UAB" to "https://r4uab.ru/satonline.txt", "R4UAB" to "https://r4uab.ru/satonline.txt",
"BI4PYM AutoTLE (GitHub)" to "https://raw.githubusercontent.com/BI4PYM/AutoTLE/refs/heads/master/AutoTLE.txt", "BI4PYM AutoTLE (GitHub)" to "https://raw.githubusercontent.com/BI4PYM/AutoTLE/refs/heads/master/AutoTLE.txt",
"BI4PYM AutoTLE (Mirror)" to "https://autotle.bi4pym.cn/AutoTLE.txt", "BI4PYM AutoTLE (Mirror)" to "https://autotle.bi4pym.cn/AutoTLE.txt",
"Classified" to "https://www.mmccants.org/tles/classfd.zip",
"Amsat" to "https://amsat.org/tle/current/nasabare.txt",
"All" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=active&FORMAT=csv", "All" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=active&FORMAT=csv",
"SatNOGS" to "https://db.satnogs.org/api/tle/?format=3le", "SatNOGS" to "https://db.satnogs.org/api/tle/?format=3le",
"Amateur" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=amateur&FORMAT=csv",
"Brightest" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=visual&FORMAT=csv",
"Cubesat" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=cubesat&FORMAT=csv",
"Education" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=education&FORMAT=csv",
"Engineer" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=engineering&FORMAT=csv",
"Geostationary" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=geo&FORMAT=csv",
"Globalstar" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=globalstar&FORMAT=csv",
"GNSS" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=gnss&FORMAT=csv",
"Intelsat" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=intelsat&FORMAT=csv",
"Iridium" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=iridium-NEXT&FORMAT=csv",
"Military" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=military&FORMAT=csv",
"New" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=last-30-days&FORMAT=csv",
"OneWeb" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=oneweb&FORMAT=csv",
"Orbcomm" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=orbcomm&FORMAT=csv",
"Resource" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=resource&FORMAT=csv",
"CelesTrak SatNOGS" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=satnogs&FORMAT=csv",
"Science" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=science&FORMAT=csv",
"Spire" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=spire&FORMAT=csv",
"Starlink" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=starlink&FORMAT=csv",
"Swarm" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=swarm&FORMAT=csv",
"Weather" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=weather&FORMAT=csv",
"X-Comm" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=x-comm&FORMAT=csv",
"Amsat" to "https://amsat.org/tle/current/nasabare.txt",
"Classified" to "https://www.mmccants.org/tles/classfd.zip",
"McCants" to "https://www.mmccants.org/tles/inttles.zip",
"ARISS" to "https://live.ariss.org/iss.txt",
"Other" to "" // key for sats filter "Other" to "" // key for sats filter
) )
// Transceiver sources. Both publish the same SatNOGS-uuid records (measured 2026-10-04:
// 2760 shared uuids), and the sync keeps the FIRST source's copy for a shared uuid
// (distinctBy { uuid }). SatNOGS is kept first on purpose: its copies carry the
// up-to-date descriptions (e.g. AO-91 "no CTCSS any longer") and it is the only source
// with the newest satellites' transmitters (T-18 objects etc.), while R4UAB mainly adds
// long-dead entries plus a handful of live extras. Upstream lists R4UAB first; this fork
// intentionally differs.
val transceiversDataUrls = mapOf( val transceiversDataUrls = mapOf(
"SatNOGS" to "https://db.satnogs.org/api/transmitters/?format=json&status=active", "SatNOGS" to "https://db.satnogs.org/api/transmitters/?format=json&status=active",
"R4UAB" to "https://r4uab.ru/transmitters.json" "R4UAB" to "https://r4uab.ru/transmitters.json"
@@ -78,4 +69,12 @@ object Sources {
* per-type IDs, and are mutually exclusive with the regular TLE-source * per-type IDs, and are mutually exclusive with the regular TLE-source
* types in the picker. */ * types in the picker. */
val virtualTypeNames = listOf("AMSAT Live FM", "AMSAT Live Linear", "Live SSTV") val virtualTypeNames = listOf("AMSAT Live FM", "AMSAT Live Linear", "Live SSTV")
/** Key of the CelesTrak "active" group source in [satelliteDataUrls] — the whole
* catalogue. Selecting it means "all satellites", so when its persisted id list
* is empty the picker must fall back to no filtering instead of showing an empty
* list. The list can legitimately be empty: CelesTrak answers 403 ("GP data has
* not updated since your last successful download", groups update every 2 hours)
* when a group is re-requested, and the sync then persists nothing for that type. */
val allSourceType = "All"
} }
@@ -83,4 +83,20 @@ class AdifCodecTest {
assertTrue(record.lotwConfirmed) assertTrue(record.lotwConfirmed)
assertTrue(record.isSatellite) assertTrue(record.isSatellite)
} }
@Test
fun encodeOmitsStationCallsignForWavelog() {
val record = QsoRecord(
startUtcMillis = 1_700_000_000_000L,
theirCallsign = "N0CALL",
myCallsign = "BA7OPF",
txFrequencyHz = 145_850_000L,
band = "2M",
satelliteName = "SO-50"
)
val adif = AdifCodec.encode(listOf(record), includeStationCallsign = false)
assertTrue(!adif.contains("<STATION_CALLSIGN"))
assertTrue(adif.contains("<CALL:6>N0CALL"))
assertTrue(adif.contains("<SAT_NAME:5>SO-50"))
}
} }
@@ -0,0 +1,141 @@
/*
* 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.
*/
package com.rtbishop.look4sat.core.domain.logbook
import com.rtbishop.look4sat.core.domain.repository.WavelogProblem
import com.rtbishop.look4sat.core.domain.repository.WavelogUploadOutcome
import com.rtbishop.look4sat.core.domain.repository.WavelogUploadPreview
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class WavelogUploadMessagesTest {
private fun record(
complete: Boolean = true,
wavelogUploaded: Boolean = false,
lotwConfirmed: Boolean = false,
lotwUploaded: Boolean = false
) = QsoRecord(
startUtcMillis = 1_700_000_000_000L,
theirCallsign = "BG5JVM",
myCallsign = "BA7OPF",
status = if (complete) QsoStatus.COMPLETE else QsoStatus.DRAFT,
lotwConfirmed = lotwConfirmed,
lotwUploaded = lotwUploaded,
wavelogUploaded = wavelogUploaded
)
private fun preview(
count: Int = 0,
skipped: Int = 0,
reasons: Map<WavelogProblem, Int> = emptyMap(),
blockedBy: WavelogProblem? = null
) = WavelogUploadPreview(
count = count,
skipped = skipped,
reasons = reasons,
stationLabel = "OL62TI · BA7OPF",
firstUtc = "",
lastUtc = "",
contacts = emptyList(),
submittedIds = List(count) { it.toLong() },
blockedBy = blockedBy
)
@Test
fun freshLocalRecordIsEligible() {
assertTrue(wavelogUploadCandidates(listOf(record())).isNotEmpty())
}
@Test
fun locallyUploadedToLotwStaysEligible() {
assertTrue(wavelogUploadCandidates(listOf(record(lotwUploaded = true))).isNotEmpty())
assertTrue(
wavelogUploadCandidates(listOf(record(lotwConfirmed = true, lotwUploaded = true))).isNotEmpty()
)
}
@Test
fun importedConfirmationRowIsExcluded() {
assertTrue(wavelogUploadCandidates(listOf(record(lotwConfirmed = true))).isEmpty())
}
@Test
fun draftAndAlreadyUploadedRecordsAreExcluded() {
assertTrue(wavelogUploadCandidates(listOf(record(complete = false))).isEmpty())
assertTrue(wavelogUploadCandidates(listOf(record(wavelogUploaded = true))).isEmpty())
}
@Test
fun problemLabelsAreOperatorFacing() {
assertEquals("Wavelog upload not configured", WavelogProblem.NOT_CONFIGURED.label())
assertEquals("no station profile selected", WavelogProblem.NO_STATION.label())
assertEquals("logged grid outside the Wavelog station grid", WavelogProblem.GRID_MISMATCH.label())
}
// --- Piggyback segments (Wavelog rides the LoTW upload flow) ---
@Test
fun `idle segment stays silent when nothing was held back`() {
assertNull(wavelogIdleSegment(preview()))
assertNull(wavelogIdleSegment(preview(count = 0, skipped = 0)))
}
@Test
fun `idle segment explains a blocked profile`() {
assertEquals(
"Wavelog: no station profile selected",
wavelogIdleSegment(preview(blockedBy = WavelogProblem.NO_STATION))
)
}
@Test
fun `idle segment lists held-back records with reasons`() {
assertEquals(
"Wavelog: 3 QSO(s) skipped — 3× logged grid outside the Wavelog station grid",
wavelogIdleSegment(preview(skipped = 3, reasons = mapOf(WavelogProblem.GRID_MISMATCH to 3)))
)
}
@Test
fun `confirmed segment reports the outcome and pre-POST skips`() {
val outcome = WavelogUploadOutcome.Imported(imported = 2, duplicates = 0, skipped = 0, markIds = listOf(1, 2))
assertEquals("Uploaded 2 QSO(s) to Wavelog", wavelogConfirmedSegment(outcome, preview()))
assertEquals(
"Uploaded 2 QSO(s) to Wavelog\n1 QSO(s) skipped — 1× logged grid outside the Wavelog station grid",
wavelogConfirmedSegment(
outcome,
preview(skipped = 1, reasons = mapOf(WavelogProblem.GRID_MISMATCH to 1))
)
)
}
@Test
fun `confirmed segment keeps the all-duplicates wording unprefixed`() {
val outcome = WavelogUploadOutcome.Imported(imported = 0, duplicates = 2, skipped = 0, markIds = listOf(1, 2))
assertEquals("All 2 record(s) already in Wavelog", wavelogConfirmedSegment(outcome, preview()))
}
@Test
fun `confirmed segment prefixes failures with Wavelog`() {
assertEquals("Wavelog: boom", wavelogConfirmedSegment(WavelogUploadOutcome.Failed("boom"), preview()))
}
@Test
fun `transport failure segment asks for a retry on the next upload`() {
assertEquals(
"Wavelog: upload failed — it will retry with your next upload",
wavelogTransportFailureSegment()
)
}
}
@@ -0,0 +1,128 @@
/*
* 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.model
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
/**
* The map's VUCC view scopes QSO data to the operated-grid selector's choice
* (台址 = station location): [stationGridSetKey] identifies a record's 台址 and
* [scopedToStation] applies the choice — the grid-detail dialog relies on both.
*/
class GridQsoStationScopeTest {
private fun qso(
call: String = "BG7XYZ",
myGrid: String? = null,
myGrids: Set<String> = emptySet()
) = GridQso(
call = call, epochMs = 0L, satName = "SO-50", mode = "FM",
bandUp = "70CM", bandDown = "2M", myGrid = myGrid, myGrids = myGrids
)
@Test
fun `multi-grid record keys by its full sorted grid set`() {
assertEquals("OL62,PM01", qso(myGrids = setOf("PM01", "OL62")).stationGridSetKey())
}
@Test
fun `legacy single-grid record falls back to myGrid`() {
assertEquals("OL62", qso(myGrid = "OL62").stationGridSetKey())
}
@Test
fun `myGrids wins over myGrid when both are present`() {
val q = qso(myGrid = "OL62", myGrids = setOf("OM60", "PM01"))
assertEquals("OM60,PM01", q.stationGridSetKey())
}
@Test
fun `record without any MY grid carries no key`() {
assertNull(qso().stationGridSetKey())
}
@Test
fun `key does not depend on the grid set's iteration order`() {
assertEquals(
qso(myGrids = setOf("PM01", "OL62")).stationGridSetKey(),
qso(myGrids = setOf("OL62", "PM01")).stationGridSetKey()
)
}
@Test
fun `null scope keeps every record`() {
val a = qso("A", myGrid = "OL62")
val b = qso("B", myGrid = "PM01")
assertEquals(listOf(a, b), listOf(a, b).scopedToStation(null))
}
@Test
fun `specific scope keeps only the matching station's records`() {
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"))
}
@Test
fun `records without a MY grid belong to no station scope`() {
val orphan = qso("A")
val mine = qso("B", myGrid = "OL62")
assertEquals(listOf(mine), listOf(orphan, mine).scopedToStation("OL62"))
}
@Test
fun `scoping to a station absent from the list yields empty`() {
val list = listOf(qso("A", myGrid = "OL62"))
assertEquals(emptyList<GridQso>(), list.scopedToStation("PM01"))
}
// --- firstCallsByGrid ---
@Test
fun `first call per grid picks the earliest of the scoped records`() {
val store = mapOf(
"OL62" to listOf(
qso("OTHER", myGrid = "PM01").copy(epochMs = 10),
qso("MINE", myGrid = "OL62").copy(epochMs = 20)
),
"PM95" to listOf(qso("MINE2", myGrid = "OL62").copy(epochMs = 30))
)
assertEquals(mapOf("OL62" to "MINE", "PM95" to "MINE2"), store.firstCallsByGrid("OL62"))
}
@Test
fun `first call per grid with All uses every record`() {
val store = mapOf(
"OL62" to listOf(
qso("OTHER", myGrid = "PM01").copy(epochMs = 10),
qso("MINE", myGrid = "OL62").copy(epochMs = 20)
)
)
assertEquals(mapOf("OL62" to "OTHER"), store.firstCallsByGrid(null))
}
@Test
fun `grids without scoped records get no first call`() {
val store = mapOf("OL62" to listOf(qso("A", myGrid = "PM01")))
assertEquals(emptyMap<String, String>(), store.firstCallsByGrid("OL62"))
}
}
@@ -27,6 +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.RCSettings
import com.rtbishop.look4sat.core.domain.model.RadioControlSettings import com.rtbishop.look4sat.core.domain.model.RadioControlSettings
import com.rtbishop.look4sat.core.domain.model.WavelogSettings 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.GeoPos
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
@@ -248,6 +249,7 @@ class LoTWGridSyncTest {
RadioControlSettings(false, RadioControlSettings.MODEL_YAESU_FT817, "", "", "", "", 9600) RadioControlSettings(false, RadioControlSettings.MODEL_YAESU_FT817, "", "", "", "", 9600)
) )
override val wavelogSettings: StateFlow<WavelogSettings> = MutableStateFlow(WavelogSettings()) override val wavelogSettings: StateFlow<WavelogSettings> = MutableStateFlow(WavelogSettings())
override val wavelogUploadSettings: StateFlow<WavelogUploadSettings> = MutableStateFlow(WavelogUploadSettings())
override val lotwSettings: StateFlow<LoTWSettings> = MutableStateFlow(LoTWSettings()) override val lotwSettings: StateFlow<LoTWSettings> = MutableStateFlow(LoTWSettings())
private var workedGrids: Set<String> = emptySet() private var workedGrids: Set<String> = emptySet()
@@ -292,6 +294,7 @@ class LoTWGridSyncTest {
override fun getAmSatCallsign(): String = "" override fun getAmSatCallsign(): String = ""
override fun setAmSatCallsign(callsign: String) = TODO() override fun setAmSatCallsign(callsign: String) = TODO()
override fun updateWavelogSettings(settings: WavelogSettings) = TODO() override fun updateWavelogSettings(settings: WavelogSettings) = TODO()
override fun updateWavelogUploadSettings(settings: WavelogUploadSettings) = TODO()
override fun updateLoTWSettings(settings: LoTWSettings) = TODO() override fun updateLoTWSettings(settings: LoTWSettings) = TODO()
} }
} }
@@ -112,6 +112,8 @@
<string name="prefs_data_update_success">Actualización completada con éxito</string> <string name="prefs_data_update_success">Actualización completada con éxito</string>
<string name="prefs_data_sources_title">Custom data sources</string> <string name="prefs_data_sources_title">Custom data sources</string>
<string name="prefs_data_sources_sat_hint">Prioridad arriba: nombres de satélites</string>
<string name="prefs_data_sources_transceivers_hint">Prioridad arriba: datos de transceptores</string>
<string name="prefs_data_import_satellites_error">No satellites imported. Select a valid TLE/3LE (.txt) or OMM (.csv) file.</string> <string name="prefs_data_import_satellites_error">No satellites imported. Select a valid TLE/3LE (.txt) or OMM (.csv) file.</string>
<string name="prefs_data_import_transceivers_error">No transceivers imported. Select a valid SatNOGS (.json) file.</string> <string name="prefs_data_import_transceivers_error">No transceivers imported. Select a valid SatNOGS (.json) file.</string>
<string name="prefs_data_sources_url_title" translatable="false">URL</string> <string name="prefs_data_sources_url_title" translatable="false">URL</string>
@@ -111,6 +111,8 @@
<string name="prefs_data_update_success">Обновление прошло успешно</string> <string name="prefs_data_update_success">Обновление прошло успешно</string>
<string name="prefs_data_sources_title">Свои источники данных</string> <string name="prefs_data_sources_title">Свои источники данных</string>
<string name="prefs_data_sources_sat_hint">Приоритет сверху: названия спутников</string>
<string name="prefs_data_sources_transceivers_hint">Приоритет сверху: данные трансиверов</string>
<string name="prefs_data_import_satellites_error">No satellites imported. Select a valid TLE/3LE (.txt) or OMM (.csv) file.</string> <string name="prefs_data_import_satellites_error">No satellites imported. Select a valid TLE/3LE (.txt) or OMM (.csv) file.</string>
<string name="prefs_data_import_transceivers_error">No transceivers imported. Select a valid SatNOGS (.json) file.</string> <string name="prefs_data_import_transceivers_error">No transceivers imported. Select a valid SatNOGS (.json) file.</string>
<string name="prefs_data_sources_url_title" translatable="false">URL</string> <string name="prefs_data_sources_url_title" translatable="false">URL</string>
@@ -112,6 +112,8 @@
<string name="prefs_data_update_success">යාවත්කාලීනය සම්පූර්ණ කිරීම සාර්ථකයි</string> <string name="prefs_data_update_success">යාවත්කාලීනය සම්පූර්ණ කිරීම සාර්ථකයි</string>
<string name="prefs_data_sources_title">Custom data sources</string> <string name="prefs_data_sources_title">Custom data sources</string>
<string name="prefs_data_sources_sat_hint">ඉහළ ප්‍රමුඛතාව: චන්ද්‍රිකා නම්</string>
<string name="prefs_data_sources_transceivers_hint">ඉහළ ප්‍රමුඛතාව: සම්ප්‍රේෂක දත්ත</string>
<string name="prefs_data_import_satellites_error">No satellites imported. Select a valid TLE/3LE (.txt) or OMM (.csv) file.</string> <string name="prefs_data_import_satellites_error">No satellites imported. Select a valid TLE/3LE (.txt) or OMM (.csv) file.</string>
<string name="prefs_data_import_transceivers_error">No transceivers imported. Select a valid SatNOGS (.json) file.</string> <string name="prefs_data_import_transceivers_error">No transceivers imported. Select a valid SatNOGS (.json) file.</string>
<string name="prefs_data_sources_url_title" translatable="false">URL</string> <string name="prefs_data_sources_url_title" translatable="false">URL</string>
@@ -142,6 +142,8 @@
<string name="prefs_data_update_success">Güncelleme başarıyla tamamlandı</string> <string name="prefs_data_update_success">Güncelleme başarıyla tamamlandı</string>
<string name="prefs_data_sources_title">Özel veri kaynakları</string> <string name="prefs_data_sources_title">Özel veri kaynakları</string>
<string name="prefs_data_sources_sat_hint">Öncelik üstte: uydu adları</string>
<string name="prefs_data_sources_transceivers_hint">Öncelik üstte: transceiver verileri</string>
<string name="prefs_data_import_satellites_error">No satellites imported. Select a valid TLE/3LE (.txt) or OMM (.csv) file.</string> <string name="prefs_data_import_satellites_error">No satellites imported. Select a valid TLE/3LE (.txt) or OMM (.csv) file.</string>
<string name="prefs_data_import_transceivers_error">No transceivers imported. Select a valid SatNOGS (.json) file.</string> <string name="prefs_data_import_transceivers_error">No transceivers imported. Select a valid SatNOGS (.json) file.</string>
<string name="prefs_data_sources_satellites_label" translatable="false">Satellites data</string> <string name="prefs_data_sources_satellites_label" translatable="false">Satellites data</string>
@@ -112,6 +112,8 @@
<string name="prefs_data_update_success">Оновлення успішне</string> <string name="prefs_data_update_success">Оновлення успішне</string>
<string name="prefs_data_sources_title">Custom data sources</string> <string name="prefs_data_sources_title">Custom data sources</string>
<string name="prefs_data_sources_sat_hint">Пріоритет зверху: назви супутників</string>
<string name="prefs_data_sources_transceivers_hint">Пріоритет зверху: дані трансиверів</string>
<string name="prefs_data_import_satellites_error">No satellites imported. Select a valid TLE/3LE (.txt) or OMM (.csv) file.</string> <string name="prefs_data_import_satellites_error">No satellites imported. Select a valid TLE/3LE (.txt) or OMM (.csv) file.</string>
<string name="prefs_data_import_transceivers_error">No transceivers imported. Select a valid SatNOGS (.json) file.</string> <string name="prefs_data_import_transceivers_error">No transceivers imported. Select a valid SatNOGS (.json) file.</string>
<string name="prefs_data_sources_url_title" translatable="false">URL</string> <string name="prefs_data_sources_url_title" translatable="false">URL</string>
@@ -166,6 +166,16 @@
<string name="prefs_wavelog_hint">已同步网格数:%1$d。格式: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_sync">立即同步</string>
<string name="prefs_wavelog_syncing">同步中…</string> <string name="prefs_wavelog_syncing">同步中…</string>
<string name="prefs_wavelog_upload_title">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>
<string name="prefs_wavelog_upload_fetch">获取站点</string>
<string name="prefs_wavelog_upload_probing">检测中…</string>
<string name="prefs_wavelog_upload_station_none">未选择站点 — 点「获取站点」</string>
<string name="prefs_wavelog_upload_rights_rw">密钥:读写 — 可上传</string>
<string name="prefs_wavelog_upload_rights_r">密钥只读 — 请在 Wavelog → API 生成「读写」密钥</string>
<string name="prefs_wavelog_upload_hint">点「获取站点」选择上传使用的台站档案;密钥需要「读写」权限。</string>
<string name="prefs_lotw_title">LoTW 已确认网格</string> <string name="prefs_lotw_title">LoTW 已确认网格</string>
<string name="prefs_lotw_callsign">LoTW 呼号</string> <string name="prefs_lotw_callsign">LoTW 呼号</string>
<string name="prefs_lotw_password">LoTW 密码</string> <string name="prefs_lotw_password">LoTW 密码</string>
@@ -201,6 +211,7 @@
<string name="prefs_logbook_selected">已选 %1$d 条</string> <string name="prefs_logbook_selected">已选 %1$d 条</string>
<string name="prefs_logbook_select_hint">长按记录行可多选重传</string> <string name="prefs_logbook_select_hint">长按记录行可多选重传</string>
<string name="prefs_logbook_resubmit_note">已上传记录将按当前台址就地更新;已被奖项使用的旧网格可能被拒绝。</string> <string name="prefs_logbook_resubmit_note">已上传记录将按当前台址就地更新;已被奖项使用的旧网格可能被拒绝。</string>
<string name="prefs_logbook_wavelog_sync">同时同步 Wavelog:%1$d 条</string>
<string name="lotw_upload_grid_title">上传前核对</string> <string name="lotw_upload_grid_title">上传前核对</string>
<string name="lotw_upload_grid_fix">修改台址</string> <string name="lotw_upload_grid_fix">修改台址</string>
<string name="lotw_upload_grid_ignore">忽略</string> <string name="lotw_upload_grid_ignore">忽略</string>
@@ -241,6 +252,8 @@
<string name="prefs_data_import_transceivers_error">未导入收发器。请选择有效的 SatNOGS (.json) 文件。</string> <string name="prefs_data_import_transceivers_error">未导入收发器。请选择有效的 SatNOGS (.json) 文件。</string>
<string name="prefs_data_sources_title">自定义数据源</string> <string name="prefs_data_sources_title">自定义数据源</string>
<string name="prefs_data_sources_sat_hint">轨道数据优先取列表最新星历,此顺序仅影响卫星名称</string>
<string name="prefs_data_sources_transceivers_hint">靠前优先:收发器数据</string>
<string name="prefs_data_sources_restore">恢复默认数据源</string> <string name="prefs_data_sources_restore">恢复默认数据源</string>
<string name="prefs_data_sources_tle_switch">自定义TLE URL</string> <string name="prefs_data_sources_tle_switch">自定义TLE URL</string>
<string name="prefs_data_sources_transceivers_switch">自定义收发器URL</string> <string name="prefs_data_sources_transceivers_switch">自定义收发器URL</string>
@@ -195,6 +195,16 @@
<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_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_sync">Sync now</string>
<string name="prefs_wavelog_syncing">Syncing…</string> <string name="prefs_wavelog_syncing">Syncing…</string>
<string name="prefs_wavelog_upload_title">Wavelog log upload</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>
<string name="prefs_wavelog_upload_fetch">Fetch stations</string>
<string name="prefs_wavelog_upload_probing">Checking…</string>
<string name="prefs_wavelog_upload_station_none">No station selected — tap Fetch stations</string>
<string name="prefs_wavelog_upload_rights_rw">Key: read &amp; write — uploads enabled</string>
<string name="prefs_wavelog_upload_rights_r">Key is read-only — create a read &amp; write key in Wavelog → API</string>
<string name="prefs_wavelog_upload_hint">Tap Fetch stations to pick the station profile uploads go through. The key needs read &amp; write permission.</string>
<string name="prefs_lotw_title">LoTW confirmed grids</string> <string name="prefs_lotw_title">LoTW confirmed grids</string>
<string name="prefs_lotw_callsign">LoTW callsign</string> <string name="prefs_lotw_callsign">LoTW callsign</string>
<string name="prefs_lotw_password">LoTW password</string> <string name="prefs_lotw_password">LoTW password</string>
@@ -230,6 +240,7 @@
<string name="prefs_logbook_selected">%1$d selected</string> <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_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_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="lotw_upload_grid_title">Upload check</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_fix">Fix station location</string>
<string name="lotw_upload_grid_ignore">Ignore</string> <string name="lotw_upload_grid_ignore">Ignore</string>
@@ -269,6 +280,8 @@
<string name="prefs_data_import_transceivers_error">No transceivers imported. Select a valid SatNOGS (.json) file.</string> <string name="prefs_data_import_transceivers_error">No transceivers imported. Select a valid SatNOGS (.json) file.</string>
<string name="prefs_data_sources_title">Custom data sources</string> <string name="prefs_data_sources_title">Custom data sources</string>
<string name="prefs_data_sources_sat_hint">Orbital data takes the freshest elements; this order only affects satellite names</string>
<string name="prefs_data_sources_transceivers_hint">Top priority affects transceivers data</string>
<string name="prefs_data_sources_restore">Restore default sources</string> <string name="prefs_data_sources_restore">Restore default sources</string>
<string name="prefs_data_sources_tle_switch">Custom TLE URL</string> <string name="prefs_data_sources_tle_switch">Custom TLE URL</string>
<string name="prefs_data_sources_transceivers_switch">Custom transceivers URL</string> <string name="prefs_data_sources_transceivers_switch">Custom transceivers URL</string>
@@ -85,7 +85,11 @@ import androidx.lifecycle.viewmodel.compose.viewModel
import com.rtbishop.look4sat.core.domain.model.AwardCalculator import com.rtbishop.look4sat.core.domain.model.AwardCalculator
import com.rtbishop.look4sat.core.domain.model.AwardProgress import com.rtbishop.look4sat.core.domain.model.AwardProgress
import com.rtbishop.look4sat.core.domain.model.AwardType import com.rtbishop.look4sat.core.domain.model.AwardType
import com.rtbishop.look4sat.core.domain.model.firstCallsByGrid
import com.rtbishop.look4sat.core.domain.model.MapSource import com.rtbishop.look4sat.core.domain.model.MapSource
import com.rtbishop.look4sat.core.domain.model.myStationGrids
import com.rtbishop.look4sat.core.domain.model.scopedToStation
import com.rtbishop.look4sat.core.domain.model.stationGridSetKey
import com.rtbishop.look4sat.core.domain.predict.GeoPos import com.rtbishop.look4sat.core.domain.predict.GeoPos
import com.rtbishop.look4sat.core.domain.predict.OrbitalObject import com.rtbishop.look4sat.core.domain.predict.OrbitalObject
import com.rtbishop.look4sat.core.domain.predict.OrbitalPos import com.rtbishop.look4sat.core.domain.predict.OrbitalPos
@@ -303,13 +307,6 @@ private fun MapScreen(
// Selected award filter. Lives in an Activity-scoped ViewModel so it // Selected award filter. Lives in an Activity-scoped ViewModel so it
// survives page switches; defaults to VUCC only once per process (cold start). // survives page switches; defaults to VUCC only once per process (cold start).
var selectedAward by mapFilterViewModel.selectedAward var selectedAward by mapFilterViewModel.selectedAward
// First callsign worked in each grid (earliest QSO by time), used by the
// "首通呼号" label mode — derived from the same QSO store as the worked fills.
val firstCallsByGrid: Map<String, String> = remember(uiState.workedGridQsos) {
uiState.workedGridQsos.mapNotNull { (grid, qsos) ->
qsos.minByOrNull { it.epochMs }?.let { grid to it.call }
}.toMap()
}
// 台址名单: QSOs grouped by the 台址's GRID SET. A station location (台址) // 台址名单: QSOs grouped by the 台址's GRID SET. A station location (台址)
// can span several grids (MY_GRIDSQUARE + MY_VUCC_GRIDS), and QSOs whose // can span several grids (MY_GRIDSQUARE + MY_VUCC_GRIDS), and QSOs whose
// location covers the same grid set belong to the same 台址; groups with an // location covers the same grid set belong to the same 台址; groups with an
@@ -321,10 +318,8 @@ private fun MapScreen(
val byGridSet = LinkedHashMap<String, Pair<Set<String>, MutableSet<String>>>() val byGridSet = LinkedHashMap<String, Pair<Set<String>, MutableSet<String>>>()
for ((grid, qsos) in uiState.workedGridQsos) { for ((grid, qsos) in uiState.workedGridQsos) {
for (q in qsos) { for (q in qsos) {
val gs = q.myGrids.ifEmpty { q.myGrid?.let { setOf(it) }.orEmpty() } val key = q.stationGridSetKey() ?: continue
if (gs.isEmpty()) continue byGridSet.getOrPut(key) { q.myStationGrids() to mutableSetOf() }.second.add(grid)
val key = gs.sorted().joinToString(",")
byGridSet.getOrPut(key) { gs to mutableSetOf() }.second.add(grid)
} }
} }
byGridSet.map { (key, v) -> StationGroup(id = key, grids = v.first, worked = v.second) } byGridSet.map { (key, v) -> StationGroup(id = key, grids = v.first, worked = v.second) }
@@ -352,6 +347,13 @@ private fun MapScreen(
if (selectedStationId == null) uiState.workedGrids if (selectedStationId == null) uiState.workedGrids
else stationGroups.firstOrNull { it.id == selectedStationId }?.worked ?: emptySet() else stationGroups.firstOrNull { it.id == selectedStationId }?.worked ?: emptySet()
} }
// First callsign worked in each grid (earliest QSO by time), used by the
// "首通呼号" label mode. Scoped to the selected 台址 like the fills: with a
// specific operated grid chosen, the label must show ITS first contact, not
// another 台址's (user decision 2026-10-05); "All" = global first call.
val firstCallsByGrid: Map<String, String> = remember(uiState.workedGridQsos, selectedStationId) {
uiState.workedGridQsos.firstCallsByGrid(selectedStationId)
}
val isGridMode = uiState.isGridMode val isGridMode = uiState.isGridMode
// True when this composition restored a saved viewport. Only grid mode // True when this composition restored a saved viewport. Only grid mode
// restores the viewport the user left (satellite mode keeps its original // restores the viewport the user left (satellite mode keeps its original
@@ -539,7 +541,11 @@ private fun MapScreen(
selectedGrid?.let { grid -> selectedGrid?.let { grid ->
WorkedGridQsoDialog( WorkedGridQsoDialog(
grid = grid, grid = grid,
qsos = uiState.workedGridQsos[grid].orEmpty().sortedBy { it.epochMs }, // Scope to the selected 台址 (null = All): a specific station location
// must not show other locations' QSOs (bug report 2026-10-05).
qsos = uiState.workedGridQsos[grid].orEmpty()
.scopedToStation(selectedStationId)
.sortedBy { it.epochMs },
marked = uiState.markedGrids[grid].orEmpty(), marked = uiState.markedGrids[grid].orEmpty(),
isUtc = uiState.isUtc, isUtc = uiState.isUtc,
matchCalculating = matchCalculating, matchCalculating = matchCalculating,
@@ -730,11 +736,12 @@ private fun MarkedStationRow(
) )
// "标记" column: pin icon (only when the grid has multiple marks) sits // "标记" column: pin icon (only when the grid has multiple marks) sits
// left of the label; pinned (first) row's icon is grey, others pale // left of the label; pinned (first) row's icon is grey, others pale
// yellow — tapping moves that mark to the top. // yellow — tapping moves that mark to the top. Wrap-content (no weight),
// same as the count column of the QSO rows, so the callsign column next
// to it takes the leftover width while these glyphs stay row-centred.
Row( Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center, horizontalArrangement = Arrangement.Center
modifier = Modifier.weight(1f)
) { ) {
if (showPin) { if (showPin) {
Icon( Icon(
@@ -807,8 +814,13 @@ private fun WorkedGridCallRow(
else stringResource(R.string.grid_qso_count_many, callQsos.size), else stringResource(R.string.grid_qso_count_many, callQsos.size),
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = androidx.compose.ui.text.style.TextAlign.Center, textAlign = androidx.compose.ui.text.style.TextAlign.Center
modifier = Modifier.weight(1f) // No weight: the count column wraps its content, so the callsign
// column (weight 1f, opposite the date column) absorbs the slack
// that used to sit left of the centred count text. The count text
// keeps its exact on-screen position: with equal weights on the
// outer columns the wrap-content middle column starts where the
// centred text used to start, i.e. its centre stays at row centre.
) )
Row( Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
@@ -28,6 +28,7 @@ import com.rtbishop.look4sat.core.domain.model.RCSettings
import com.rtbishop.look4sat.core.domain.model.RadioControlSettings import com.rtbishop.look4sat.core.domain.model.RadioControlSettings
import com.rtbishop.look4sat.core.domain.model.SatRadio import com.rtbishop.look4sat.core.domain.model.SatRadio
import com.rtbishop.look4sat.core.domain.model.WavelogSettings 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.GeoPos
import com.rtbishop.look4sat.core.domain.predict.OrbitalObject import com.rtbishop.look4sat.core.domain.predict.OrbitalObject
import com.rtbishop.look4sat.core.domain.predict.OrbitalPass import com.rtbishop.look4sat.core.domain.predict.OrbitalPass
@@ -107,6 +108,8 @@ class FakeSettingsRepo(
RadioControlSettings(false, RadioControlSettings.MODEL_YAESU_FT817, "", "", "", "", 9600) RadioControlSettings(false, RadioControlSettings.MODEL_YAESU_FT817, "", "", "", "", 9600)
) )
override val wavelogSettings: StateFlow<WavelogSettings> = MutableStateFlow(WavelogSettings()) 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()) override val lotwSettings: StateFlow<LoTWSettings> = MutableStateFlow(LoTWSettings())
override fun setSelectedIds(ids: List<Int>) = TODO() override fun setSelectedIds(ids: List<Int>) = TODO()
@@ -260,6 +260,7 @@ fun LogPage(
UploadPreviewDialog( UploadPreviewDialog(
preview = preview, preview = preview,
busy = logUiState.busy, busy = logUiState.busy,
wavelogCount = logUiState.wavelogPending?.count ?: 0,
onConfirm = logViewModel::confirmUpload, onConfirm = logViewModel::confirmUpload,
onDismiss = logViewModel::dismissPreview onDismiss = logViewModel::dismissPreview
) )
@@ -406,6 +407,7 @@ private fun PostDialog(
private fun UploadPreviewDialog( private fun UploadPreviewDialog(
preview: com.rtbishop.look4sat.core.domain.repository.LoTWUploadPreview, preview: com.rtbishop.look4sat.core.domain.repository.LoTWUploadPreview,
busy: Boolean, busy: Boolean,
wavelogCount: Int = 0,
onConfirm: () -> Unit, onConfirm: () -> Unit,
onDismiss: () -> Unit onDismiss: () -> Unit
) { ) {
@@ -454,6 +456,13 @@ private fun UploadPreviewDialog(
} }
Text(parts.joinToString(" · "), fontSize = 12.sp, color = MaterialTheme.colorScheme.error) 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) Text(preview.contacts.joinToString("\n") { it }, fontSize = 12.sp, maxLines = 8)
} }
}, },
@@ -21,13 +21,21 @@ import com.rtbishop.look4sat.core.domain.logbook.frequencyBand
import com.rtbishop.look4sat.core.domain.logbook.officialSatelliteName import com.rtbishop.look4sat.core.domain.logbook.officialSatelliteName
import com.rtbishop.look4sat.core.domain.logbook.satelliteIdentity import com.rtbishop.look4sat.core.domain.logbook.satelliteIdentity
import com.rtbishop.look4sat.core.domain.logbook.unavailableUploadSummary import com.rtbishop.look4sat.core.domain.logbook.unavailableUploadSummary
import com.rtbishop.look4sat.core.domain.logbook.wavelogConfirmedSegment
import com.rtbishop.look4sat.core.domain.logbook.wavelogIdleSegment
import com.rtbishop.look4sat.core.domain.logbook.wavelogTransportFailureSegment
import com.rtbishop.look4sat.core.domain.logbook.wavelogUploadCandidates
import com.rtbishop.look4sat.core.domain.model.WavelogUploadSettings
import com.rtbishop.look4sat.core.domain.repository.ILoTWUploadRepository import com.rtbishop.look4sat.core.domain.repository.ILoTWUploadRepository
import com.rtbishop.look4sat.core.domain.repository.IMainContainer import com.rtbishop.look4sat.core.domain.repository.IMainContainer
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
import com.rtbishop.look4sat.core.domain.repository.IWavelogUploadRepository
import com.rtbishop.look4sat.core.domain.repository.LoTWOperationException import com.rtbishop.look4sat.core.domain.repository.LoTWOperationException
import com.rtbishop.look4sat.core.domain.repository.LoTWPositionWarning import com.rtbishop.look4sat.core.domain.repository.LoTWPositionWarning
import com.rtbishop.look4sat.core.domain.repository.LoTWUploadPreview import com.rtbishop.look4sat.core.domain.repository.LoTWUploadPreview
import com.rtbishop.look4sat.core.domain.repository.LoTWUploadResult import com.rtbishop.look4sat.core.domain.repository.LoTWUploadResult
import com.rtbishop.look4sat.core.domain.repository.WavelogUploadOutcome
import com.rtbishop.look4sat.core.domain.repository.WavelogUploadPreview
import com.rtbishop.look4sat.core.domain.repository.positionWarning import com.rtbishop.look4sat.core.domain.repository.positionWarning
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
@@ -78,6 +86,9 @@ data class LogUiState(
val message: String = "", val message: String = "",
val postText: String? = null, val postText: String? = null,
val busy: Boolean = false, 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,
/** Out-of-window contact waiting for the operator's confirmation (null when closed). */ /** Out-of-window contact waiting for the operator's confirmation (null when closed). */
val outOfWindowLog: OutOfWindowLog? = null val outOfWindowLog: OutOfWindowLog? = null
) )
@@ -85,6 +96,7 @@ data class LogUiState(
class LogViewModel( class LogViewModel(
private val qsoRepository: IQsoRepository, private val qsoRepository: IQsoRepository,
private val lotwUploadRepository: ILoTWUploadRepository, private val lotwUploadRepository: ILoTWUploadRepository,
private val wavelogUploadRepository: IWavelogUploadRepository,
private val settingsRepo: ISettingsRepo private val settingsRepo: ISettingsRepo
) : ViewModel() { ) : ViewModel() {
@@ -260,6 +272,16 @@ class LogViewModel(
// LoTW-imported confirmations are the feedback side. // LoTW-imported confirmations are the feedback side.
val pending = all.filter { !it.lotwConfirmed && !it.lotwUploaded && it.status == QsoStatus.COMPLETE } val pending = all.filter { !it.lotwConfirmed && !it.lotwUploaded && it.status == QsoStatus.COMPLETE }
val audit = lotwUploadRepository.audit(pending) 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) { if (audit.pending == 0) {
val msg = when { val msg = when {
audit.unavailable > 0 -> unavailableUploadSummary( audit.unavailable > 0 -> unavailableUploadSummary(
@@ -269,7 +291,14 @@ class LogViewModel(
audit.unknown > 0 -> "${audit.unknown} QSO(s) had an unknown upload result — not retried automatically" audit.unknown > 0 -> "${audit.unknown} QSO(s) had an unknown upload result — not retried automatically"
else -> "No pending QSOs to upload" else -> "No pending QSOs to upload"
} }
_uiState.update { it.copy(busy = false, message = msg) } // 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"))
}
return@launch return@launch
} }
val preview = lotwUploadRepository.prepare(pending, false) val preview = lotwUploadRepository.prepare(pending, false)
@@ -280,6 +309,7 @@ class LogViewModel(
it.copy( it.copy(
busy = false, busy = false,
preview = preview, preview = preview,
wavelogPending = wavelogPreview,
uploadPositionWarning = positionWarning(settingsRepo.getCurrentGrid(), preview.grids) uploadPositionWarning = positionWarning(settingsRepo.getCurrentGrid(), preview.grids)
) )
} }
@@ -308,11 +338,40 @@ class LogViewModel(
LoTWUploadResult.Unknown -> "Unknown result — will not auto-retry" LoTWUploadResult.Unknown -> "Unknown result — will not auto-retry"
LoTWUploadResult.ExpiredPreview -> "Preview expired — tap upload again" LoTWUploadResult.ExpiredPreview -> "Preview expired — tap upload again"
} }
_uiState.update { it.copy(busy = false, preview = null, message = msg) } // 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")
)
}
} }
} }
fun dismissPreview() = _uiState.update { it.copy(preview = null) } fun dismissPreview() = _uiState.update { it.copy(preview = null, wavelogPending = null) }
/**
* Uploads one prepared Wavelog batch (piggyback) and marks what the server
* accepted. Returns the operator-facing message segment for this upload.
*/
private suspend fun uploadWavelogNow(
preview: WavelogUploadPreview,
settings: WavelogUploadSettings
): String = try {
val outcome = wavelogUploadRepository.upload(preview, settings)
if (outcome is WavelogUploadOutcome.Imported && outcome.markIds.isNotEmpty()) {
qsoRepository.markWavelogUploaded(outcome.markIds)
}
wavelogConfirmedSegment(outcome, preview)
} catch (_: Exception) {
wavelogTransportFailureSegment()
}
/** Operator chose "ignore" on the position check: keep the prepared preview. */ /** Operator chose "ignore" on the position check: keep the prepared preview. */
fun ignorePositionWarning() = _uiState.update { it.copy(uploadPositionWarning = null) } fun ignorePositionWarning() = _uiState.update { it.copy(uploadPositionWarning = null) }
@@ -321,7 +380,7 @@ class LogViewModel(
fun abandonForGridFix() { fun abandonForGridFix() {
lotwUploadRepository.discardPreview() lotwUploadRepository.discardPreview()
lastUploadedIds = emptyList() lastUploadedIds = emptyList()
_uiState.update { it.copy(uploadPositionWarning = null, preview = null) } _uiState.update { it.copy(uploadPositionWarning = null, preview = null, wavelogPending = null) }
} }
/** Recompute the log-page roaming hint from the freshest station/position data. */ /** Recompute the log-page roaming hint from the freshest station/position data. */
@@ -347,7 +406,10 @@ class LogViewModel(
companion object { companion object {
fun factory(container: IMainContainer) = viewModelFactory { fun factory(container: IMainContainer) = viewModelFactory {
initializer { initializer {
LogViewModel(container.qsoRepository, container.lotwUploadRepository, container.settingsRepo) LogViewModel(
container.qsoRepository, container.lotwUploadRepository,
container.wavelogUploadRepository, container.settingsRepo
)
} }
} }
} }
@@ -104,7 +104,9 @@ fun LogbookDialog(
onStartSelection: (Long) -> Unit = {}, onStartSelection: (Long) -> Unit = {},
onToggleSelection: (Long) -> Unit = {}, onToggleSelection: (Long) -> Unit = {},
onExitSelection: () -> Unit = {}, onExitSelection: () -> Unit = {},
onResubmitSelected: () -> Unit = {} onResubmitSelected: () -> Unit = {},
/** Submit count of the Wavelog batch riding this upload (0 hides the note). */
wavelogCount: Int = 0
) { ) {
val swipeController = rememberSwipeController() val swipeController = rememberSwipeController()
// Entering selection mode closes any row left swiped open — reveals are off there. // Entering selection mode closes any row left swiped open — reveals are off there.
@@ -181,6 +183,7 @@ fun LogbookDialog(
LogbookUploadPreviewDialog( LogbookUploadPreviewDialog(
preview = preview, preview = preview,
busy = uploadBusy, busy = uploadBusy,
wavelogCount = wavelogCount,
onConfirm = onConfirmUpload, onConfirm = onConfirmUpload,
onDismiss = onDismissPreview onDismiss = onDismissPreview
) )
@@ -215,6 +218,7 @@ fun LogbookDialog(
private fun LogbookUploadPreviewDialog( private fun LogbookUploadPreviewDialog(
preview: com.rtbishop.look4sat.core.domain.repository.LoTWUploadPreview, preview: com.rtbishop.look4sat.core.domain.repository.LoTWUploadPreview,
busy: Boolean, busy: Boolean,
wavelogCount: Int,
onConfirm: () -> Unit, onConfirm: () -> Unit,
onDismiss: () -> Unit onDismiss: () -> Unit
) { ) {
@@ -255,6 +259,13 @@ private fun LogbookUploadPreviewDialog(
color = MaterialTheme.colorScheme.primary 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) Text(preview.contacts.joinToString("\n") { it }, fontSize = 12.sp, maxLines = 8)
} }
}, },
@@ -225,6 +225,117 @@ fun WavelogDialog(
} }
} }
@Composable
fun WavelogUploadDialog(
initialSettings: com.rtbishop.look4sat.core.domain.model.WavelogUploadSettings,
stations: List<com.rtbishop.look4sat.core.domain.repository.WavelogStationInfo>,
rights: String,
probeBusy: Boolean,
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
) {
val url = rememberSaveable { mutableStateOf(initialSettings.url) }
val apiKey = rememberSaveable { mutableStateOf(initialSettings.apiKey) }
SharedDialog(
title = stringResource(R.string.prefs_wavelog_upload_title),
onCancel = dismiss,
onAccept = {
onSave(initialSettings.copy(url = url.value, apiKey = apiKey.value))
dismiss()
}
) {
OutlinedTextField(
value = url.value,
onValueChange = { url.value = it },
label = { Text(text = stringResource(id = R.string.prefs_wavelog_url)) },
placeholder = { Text(text = "http://100.x.x.x/wavelog") },
singleLine = true,
modifier = Modifier.fillMaxWidth().padding(horizontal = LocalSpacing.current.large),
)
OutlinedTextField(
value = apiKey.value,
onValueChange = { apiKey.value = it },
label = { Text(text = stringResource(id = R.string.prefs_wavelog_upload_apikey)) },
placeholder = { Text(text = "wl…") },
singleLine = true,
modifier = Modifier.fillMaxWidth().padding(horizontal = LocalSpacing.current.large),
)
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth().padding(horizontal = LocalSpacing.current.large)
) {
TextButton(onClick = { onFetchStations(url.value, apiKey.value) }, enabled = !probeBusy) {
Text(
text = if (probeBusy) stringResource(R.string.prefs_wavelog_upload_probing)
else stringResource(R.string.prefs_wavelog_upload_fetch)
)
}
}
if (rights.isNotBlank()) {
Text(
text = when (rights) {
"rw" -> stringResource(R.string.prefs_wavelog_upload_rights_rw)
else -> stringResource(R.string.prefs_wavelog_upload_rights_r)
},
color = if (rights == "rw") MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(horizontal = LocalSpacing.current.large)
)
}
if (stations.isEmpty() && initialSettings.stationId.isBlank()) {
Text(
text = stringResource(R.string.prefs_wavelog_upload_station_none),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
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)
)
}
}
if (initialSettings.stationId.isNotBlank()) {
Text(
text = stringResource(
R.string.prefs_wavelog_upload_configured,
listOf(initialSettings.stationName, initialSettings.stationCallsign, initialSettings.stationGrid)
.filter { it.isNotBlank() }.joinToString(" · ")
),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.primary,
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)
)
}
Text(
text = stringResource(R.string.prefs_wavelog_upload_hint),
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(horizontal = LocalSpacing.current.large)
)
Spacer(modifier = Modifier.height(0.dp))
}
}
@Composable @Composable
fun LoTWDialog( fun LoTWDialog(
initialSettings: com.rtbishop.look4sat.core.domain.model.LoTWSettings, initialSettings: com.rtbishop.look4sat.core.domain.model.LoTWSettings,
@@ -469,6 +580,7 @@ fun DataSourcesDialog(
sourceSection( sourceSection(
sectionKey = "sat", sectionKey = "sat",
labelResId = R.string.prefs_data_sources_satellites_label, labelResId = R.string.prefs_data_sources_satellites_label,
hintResId = R.string.prefs_data_sources_sat_hint,
urls = satUrls, urls = satUrls,
listState = listState, listState = listState,
draggedId = satDraggedId, draggedId = satDraggedId,
@@ -483,6 +595,7 @@ fun DataSourcesDialog(
sourceSection( sourceSection(
sectionKey = "tx", sectionKey = "tx",
labelResId = R.string.prefs_data_sources_transceivers_label, labelResId = R.string.prefs_data_sources_transceivers_label,
hintResId = R.string.prefs_data_sources_transceivers_hint,
urls = txUrls, urls = txUrls,
listState = listState, listState = listState,
draggedId = txDraggedId, draggedId = txDraggedId,
@@ -501,6 +614,7 @@ fun DataSourcesDialog(
private fun LazyListScope.sourceSection( private fun LazyListScope.sourceSection(
sectionKey: String, sectionKey: String,
labelResId: Int, labelResId: Int,
hintResId: Int,
urls: List<Pair<Long, String>>, urls: List<Pair<Long, String>>,
listState: LazyListState, listState: LazyListState,
draggedId: MutableState<Long>, draggedId: MutableState<Long>,
@@ -523,6 +637,15 @@ private fun LazyListScope.sourceSection(
) )
IconCard(action = onAdd, resId = R.drawable.ic_add) IconCard(action = onAdd, resId = R.drawable.ic_add)
} }
// States what the order below actually decides: for satellites the elements come from
// whichever enabled source has the freshest epoch, so the order only picks the name.
Text(
text = stringResource(hintResId),
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier
.fillMaxWidth()
.padding(top = 2.dp, bottom = 8.dp)
)
} }
itemsIndexed(urls, key = { _, entry -> "$sectionKey-${entry.first}" }) { index, (id, url) -> itemsIndexed(urls, key = { _, entry -> "$sectionKey-${entry.first}" }) { index, (id, url) ->
val enabledTint = MaterialTheme.colorScheme.onSurfaceVariant val enabledTint = MaterialTheme.colorScheme.onSurfaceVariant
@@ -318,7 +318,8 @@ private fun SettingsScreen(
onStartSelection = { onAction(SettingsAction.StartLogbookSelection(it)) }, onStartSelection = { onAction(SettingsAction.StartLogbookSelection(it)) },
onToggleSelection = { onAction(SettingsAction.ToggleLogbookSelection(it)) }, onToggleSelection = { onAction(SettingsAction.ToggleLogbookSelection(it)) },
onExitSelection = { onAction(SettingsAction.ExitLogbookSelection) }, onExitSelection = { onAction(SettingsAction.ExitLogbookSelection) },
onResubmitSelected = { onAction(SettingsAction.ResubmitSelectedLogbook) } onResubmitSelected = { onAction(SettingsAction.ResubmitSelectedLogbook) },
wavelogCount = uiState.wavelogPending?.count ?: 0
) )
} }
if (dialogs.lotwUpload) { if (dialogs.lotwUpload) {
@@ -340,6 +341,20 @@ private fun SettingsScreen(
) )
} }
if (dialogs.wavelogUpload) {
WavelogUploadDialog(
initialSettings = uiState.wavelogUploadSettings,
stations = uiState.wavelogUploadStations,
rights = uiState.wavelogUploadRights,
probeBusy = uiState.wavelogUploadProbeBusy,
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)) }
)
}
// URLs for top bar // URLs for top bar
val uriHandler = LocalUriHandler.current val uriHandler = LocalUriHandler.current
val upstreamTitle = stringResource(R.string.prefs_upstream_title) val upstreamTitle = stringResource(R.string.prefs_upstream_title)
@@ -461,6 +476,12 @@ private fun SettingsScreen(
showLoTWDialog = { dialogs.lotw = true } showLoTWDialog = { dialogs.lotw = true }
) )
} }
item {
WavelogUploadCard(
settings = uiState.wavelogUploadSettings,
showDialog = { dialogs.wavelogUpload = true }
)
}
item { item {
OtherCard( OtherCard(
settings = uiState.otherSettings, settings = uiState.otherSettings,
@@ -883,6 +904,41 @@ private fun LoTWCard(
} }
} }
@Composable
private fun WavelogUploadCard(
settings: com.rtbishop.look4sat.core.domain.model.WavelogUploadSettings,
showDialog: () -> Unit
) {
ElevatedCard(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)) {
Text(
text = stringResource(id = R.string.prefs_wavelog_upload_title),
color = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.height(2.dp))
Text(
text = if (settings.isReady) {
stringResource(
R.string.prefs_wavelog_upload_configured,
listOf(settings.stationName, settings.stationCallsign, settings.stationGrid)
.filter { it.isNotBlank() }.joinToString(" · ")
)
} else {
stringResource(R.string.prefs_wavelog_upload_not_configured)
},
style = MaterialTheme.typography.bodySmall,
maxLines = 2
)
Spacer(modifier = Modifier.height(4.dp))
CardButton(
onClick = showDialog,
text = stringResource(id = R.string.prefs_wavelog_configure),
modifier = Modifier.fillMaxWidth()
)
}
}
}
@Composable @Composable
private fun formatUpdateTime(updateTime: Long): String { private fun formatUpdateTime(updateTime: Long): String {
val timePattern = stringResource(id = R.string.prefs_updated_time) val timePattern = stringResource(id = R.string.prefs_updated_time)
@@ -1029,6 +1085,7 @@ private class DialogVisibility {
var compassCalibration by mutableStateOf(false) var compassCalibration by mutableStateOf(false)
var logbook by mutableStateOf(false) var logbook by mutableStateOf(false)
var lotwUpload by mutableStateOf(false) var lotwUpload by mutableStateOf(false)
var wavelogUpload by mutableStateOf(false)
} }
@Composable @Composable
@@ -1036,7 +1093,7 @@ private fun rememberDialogVisibility(): DialogVisibility {
return rememberSaveable(saver = run { return rememberSaveable(saver = run {
androidx.compose.runtime.saveable.Saver( androidx.compose.runtime.saveable.Saver(
save = { save = {
listOf(it.position, it.locator, it.dataSources, it.network, it.bluetooth, it.radioControl, it.wavelog, it.lotw, it.compassCalibration, it.logbook, it.lotwUpload) listOf(it.position, it.locator, it.dataSources, it.network, it.bluetooth, it.radioControl, it.wavelog, it.lotw, it.compassCalibration, it.logbook, it.lotwUpload, it.wavelogUpload)
}, },
restore = { restore = {
DialogVisibility().apply { DialogVisibility().apply {
@@ -1046,6 +1103,7 @@ private fun rememberDialogVisibility(): DialogVisibility {
compassCalibration = it.getOrElse(8) { false } compassCalibration = it.getOrElse(8) { false }
logbook = it.getOrElse(9) { false } logbook = it.getOrElse(9) { false }
lotwUpload = it.getOrElse(10) { false } lotwUpload = it.getOrElse(10) { false }
wavelogUpload = it.getOrElse(11) { false }
} }
} }
) )
@@ -23,6 +23,7 @@ import com.rtbishop.look4sat.core.domain.model.OtherSettings
import com.rtbishop.look4sat.core.domain.model.RCSettings import com.rtbishop.look4sat.core.domain.model.RCSettings
import com.rtbishop.look4sat.core.domain.model.RadioControlSettings import com.rtbishop.look4sat.core.domain.model.RadioControlSettings
import com.rtbishop.look4sat.core.domain.model.WavelogSettings 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.GeoPos
import com.rtbishop.look4sat.core.domain.repository.CompassAccuracy import com.rtbishop.look4sat.core.domain.repository.CompassAccuracy
import com.rtbishop.look4sat.core.domain.repository.LoTWSyncMode import com.rtbishop.look4sat.core.domain.repository.LoTWSyncMode
@@ -61,6 +62,16 @@ data class SettingsState(
val workedGridsCount: Int = 0, val workedGridsCount: Int = 0,
val wavelogSyncing: Boolean = false, val wavelogSyncing: Boolean = false,
val wavelogMessage: String? = null, val wavelogMessage: String? = null,
/** Wavelog upload: its own config block + probe/upload transient state. */
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. */
val wavelogUploadMessage: String? = null,
val lotwSettings: com.rtbishop.look4sat.core.domain.model.LoTWSettings = com.rtbishop.look4sat.core.domain.model.LoTWSettings(), val lotwSettings: com.rtbishop.look4sat.core.domain.model.LoTWSettings = com.rtbishop.look4sat.core.domain.model.LoTWSettings(),
val lotwSyncing: Boolean = false, val lotwSyncing: Boolean = false,
val lotwSyncMode: LoTWSyncMode? = null, val lotwSyncMode: LoTWSyncMode? = null,
@@ -150,6 +161,11 @@ sealed interface SettingsAction {
data class UpdateWavelog(val settings: WavelogSettings) : SettingsAction data class UpdateWavelog(val settings: WavelogSettings) : SettingsAction
data class SyncWorkedGrids(val settings: WavelogSettings) : SettingsAction data class SyncWorkedGrids(val settings: WavelogSettings) : SettingsAction
// Wavelog upload (separate config block + upload flow)
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
// LoTW confirmed grids // LoTW confirmed grids
data class UpdateLoTW(val settings: com.rtbishop.look4sat.core.domain.model.LoTWSettings) : SettingsAction data class UpdateLoTW(val settings: com.rtbishop.look4sat.core.domain.model.LoTWSettings) : SettingsAction
data class SyncLoTWGrids( data class SyncLoTWGrids(
@@ -24,6 +24,7 @@ import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory import androidx.lifecycle.viewmodel.viewModelFactory
import com.rtbishop.look4sat.core.domain.repository.IDatabaseRepo import com.rtbishop.look4sat.core.domain.repository.IDatabaseRepo
import com.rtbishop.look4sat.core.domain.model.WavelogSettings 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.IMainContainer
import com.rtbishop.look4sat.core.domain.repository.ISensorsRepo import com.rtbishop.look4sat.core.domain.repository.ISensorsRepo
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
@@ -36,11 +37,19 @@ import com.rtbishop.look4sat.core.domain.repository.lotwCursorEpochMs
import com.rtbishop.look4sat.core.domain.repository.resolveLoTWSyncMode import com.rtbishop.look4sat.core.domain.repository.resolveLoTWSyncMode
import com.rtbishop.look4sat.core.domain.repository.positionWarning import com.rtbishop.look4sat.core.domain.repository.positionWarning
import com.rtbishop.look4sat.core.domain.repository.IWavelogRepository import com.rtbishop.look4sat.core.domain.repository.IWavelogRepository
import com.rtbishop.look4sat.core.domain.repository.IWavelogUploadRepository
import com.rtbishop.look4sat.core.domain.repository.WavelogStationInfo
import com.rtbishop.look4sat.core.domain.repository.WavelogUploadOutcome
import com.rtbishop.look4sat.core.domain.repository.WavelogUploadPreview
import com.rtbishop.look4sat.core.domain.usecase.IShowToast import com.rtbishop.look4sat.core.domain.usecase.IShowToast
import com.rtbishop.look4sat.core.domain.utility.VersionComparator import com.rtbishop.look4sat.core.domain.utility.VersionComparator
import com.rtbishop.look4sat.core.domain.logbook.resubmitCandidates import com.rtbishop.look4sat.core.domain.logbook.resubmitCandidates
import com.rtbishop.look4sat.core.domain.logbook.toConfirmedRecord import com.rtbishop.look4sat.core.domain.logbook.toConfirmedRecord
import com.rtbishop.look4sat.core.domain.logbook.unavailableUploadSummary import com.rtbishop.look4sat.core.domain.logbook.unavailableUploadSummary
import com.rtbishop.look4sat.core.domain.logbook.wavelogConfirmedSegment
import com.rtbishop.look4sat.core.domain.logbook.wavelogIdleSegment
import com.rtbishop.look4sat.core.domain.logbook.wavelogTransportFailureSegment
import com.rtbishop.look4sat.core.domain.logbook.wavelogUploadCandidates
import com.rtbishop.look4sat.core.presentation.R import com.rtbishop.look4sat.core.presentation.R
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
@@ -54,6 +63,7 @@ class SettingsViewModel(
private val settingsRepo: ISettingsRepo, private val settingsRepo: ISettingsRepo,
private val updateRepo: IUpdateRepository, private val updateRepo: IUpdateRepository,
private val wavelogRepo: IWavelogRepository, private val wavelogRepo: IWavelogRepository,
private val wavelogUploadRepository: IWavelogUploadRepository,
private val lotwRepo: com.rtbishop.look4sat.core.domain.repository.ILoTWRepository, private val lotwRepo: com.rtbishop.look4sat.core.domain.repository.ILoTWRepository,
private val qsoRepository: com.rtbishop.look4sat.core.domain.logbook.IQsoRepository, private val qsoRepository: com.rtbishop.look4sat.core.domain.logbook.IQsoRepository,
private val lotwUploadRepository: com.rtbishop.look4sat.core.domain.repository.ILoTWUploadRepository, private val lotwUploadRepository: com.rtbishop.look4sat.core.domain.repository.ILoTWUploadRepository,
@@ -79,6 +89,7 @@ class SettingsViewModel(
dataSourcesSettings = settingsRepo.dataSourcesSettings.value, dataSourcesSettings = settingsRepo.dataSourcesSettings.value,
dataSourcesStatus = settingsRepo.dataSourcesStatus.value, dataSourcesStatus = settingsRepo.dataSourcesStatus.value,
wavelogSettings = settingsRepo.wavelogSettings.value, wavelogSettings = settingsRepo.wavelogSettings.value,
wavelogUploadSettings = settingsRepo.wavelogUploadSettings.value,
workedGridsCount = settingsRepo.getWorkedGrids().size, workedGridsCount = settingsRepo.getWorkedGrids().size,
compassAccuracy = sensorsRepo.compassAccuracy.value, compassAccuracy = sensorsRepo.compassAccuracy.value,
compassHeadingDegrees = correctedCompassHeading(), compassHeadingDegrees = correctedCompassHeading(),
@@ -156,6 +167,11 @@ class SettingsViewModel(
_uiState.update { it.copy(wavelogSettings = settings) } _uiState.update { it.copy(wavelogSettings = settings) }
} }
} }
viewModelScope.launch {
settingsRepo.wavelogUploadSettings.collect { settings ->
_uiState.update { it.copy(wavelogUploadSettings = settings) }
}
}
viewModelScope.launch { viewModelScope.launch {
settingsRepo.lotwSettings.collect { settings -> settingsRepo.lotwSettings.collect { settings ->
_uiState.update { it.copy(lotwSettings = settings) } _uiState.update { it.copy(lotwSettings = settings) }
@@ -225,6 +241,10 @@ class SettingsViewModel(
// Wavelog worked grids // Wavelog worked grids
is SettingsAction.UpdateWavelog -> settingsRepo.updateWavelogSettings(action.settings) is SettingsAction.UpdateWavelog -> settingsRepo.updateWavelogSettings(action.settings)
is SettingsAction.SyncWorkedGrids -> syncWorkedGrids(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)
is SettingsAction.FetchWavelogUploadStations -> fetchWavelogUploadStations(action.url, action.apiKey)
is SettingsAction.SelectWavelogUploadStation -> selectWavelogUploadStation(action.station)
// LoTW confirmed grids // LoTW confirmed grids
is SettingsAction.UpdateLoTW -> settingsRepo.updateLoTWSettings(action.settings) is SettingsAction.UpdateLoTW -> settingsRepo.updateLoTWSettings(action.settings)
is SettingsAction.SyncLoTWGrids -> syncLoTWGrids(action.settings, action.mode) is SettingsAction.SyncLoTWGrids -> syncLoTWGrids(action.settings, action.mode)
@@ -280,6 +300,78 @@ class SettingsViewModel(
} }
} }
// Wavelog upload (separate config block + upload flow)
private fun fetchWavelogUploadStations(url: String, apiKey: String) {
if (_uiState.value.wavelogUploadProbeBusy) return
_uiState.update { it.copy(wavelogUploadProbeBusy = true, wavelogUploadMessage = null) }
viewModelScope.launch {
val probe = wavelogUploadRepository.probe(url.trim(), apiKey.trim())
if (probe == null) {
_uiState.update {
it.copy(
wavelogUploadProbeBusy = false,
wavelogUploadMessage = "Wavelog probe failed — check URL/key/network"
)
}
return@launch
}
_uiState.update {
it.copy(
wavelogUploadProbeBusy = false,
wavelogUploadStations = probe.stations,
wavelogUploadRights = probe.rights
)
}
// Single-station setups select themselves; a saved station that the server
// still reports stays selected.
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)
}
}
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
)
)
}
/** 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()
}
/**
* Uploads one prepared Wavelog batch (piggyback) and marks what the server
* accepted. Returns the operator-facing message segment for this upload.
*/
private suspend fun uploadWavelogNow(
preview: WavelogUploadPreview,
settings: WavelogUploadSettings
): String = try {
val outcome = wavelogUploadRepository.upload(preview, settings)
if (outcome is WavelogUploadOutcome.Imported && outcome.markIds.isNotEmpty()) {
qsoRepository.markWavelogUploaded(outcome.markIds)
}
wavelogConfirmedSegment(outcome, preview)
} catch (_: Exception) {
wavelogTransportFailureSegment()
}
private fun syncLoTWGrids( private fun syncLoTWGrids(
settings: com.rtbishop.look4sat.core.domain.model.LoTWSettings, settings: com.rtbishop.look4sat.core.domain.model.LoTWSettings,
mode: LoTWSyncMode mode: LoTWSyncMode
@@ -389,6 +481,9 @@ class SettingsViewModel(
// LoTW-imported confirmations are the feedback side. // 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 pending = all.filter { !it.lotwConfirmed && !it.lotwUploaded && it.status == com.rtbishop.look4sat.core.domain.logbook.QsoStatus.COMPLETE }
val audit = lotwUploadRepository.audit(pending) 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) { if (audit.pending == 0) {
val msg = when { val msg = when {
audit.unavailable > 0 -> com.rtbishop.look4sat.core.domain.logbook.unavailableUploadSummary( audit.unavailable > 0 -> com.rtbishop.look4sat.core.domain.logbook.unavailableUploadSummary(
@@ -398,7 +493,17 @@ class SettingsViewModel(
audit.unknown > 0 -> "${audit.unknown} QSO(s) had an unknown upload result — not retried automatically" audit.unknown > 0 -> "${audit.unknown} QSO(s) had an unknown upload result — not retried automatically"
else -> "No pending QSOs to upload" else -> "No pending QSOs to upload"
} }
_uiState.update { it.copy(logbookUploadBusy = false, logbookUploadMessage = msg) } // 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")
)
}
return@launch return@launch
} }
val preview = lotwUploadRepository.prepare(pending, false) val preview = lotwUploadRepository.prepare(pending, false)
@@ -409,6 +514,7 @@ class SettingsViewModel(
it.copy( it.copy(
logbookUploadBusy = false, logbookUploadBusy = false,
logbookPreview = preview, logbookPreview = preview,
wavelogPending = wavelogPreview,
logbookPositionWarning = positionWarning(settingsRepo.getCurrentGrid(), preview.grids) logbookPositionWarning = positionWarning(settingsRepo.getCurrentGrid(), preview.grids)
) )
} }
@@ -436,11 +542,19 @@ class SettingsViewModel(
com.rtbishop.look4sat.core.domain.repository.LoTWUploadResult.Unknown -> "Unknown result — will not auto-retry" 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" 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 { _uiState.update {
it.copy( it.copy(
logbookUploadBusy = false, logbookUploadBusy = false,
logbookPreview = null, logbookPreview = null,
logbookUploadMessage = msg, wavelogPending = null,
logbookUploadMessage = listOfNotNull(msg, wavelogMsg).joinToString("\n"),
// An accepted resubmit is done — drop the checks; a failed one keeps // An accepted resubmit is done — drop the checks; a failed one keeps
// them so retrying stays one tap away. // them so retrying stays one tap away.
logbookSelectionMode = if (accepted) false else it.logbookSelectionMode, logbookSelectionMode = if (accepted) false else it.logbookSelectionMode,
@@ -450,7 +564,7 @@ class SettingsViewModel(
} }
} }
private fun dismissLogbookPreview() = _uiState.update { it.copy(logbookPreview = null) } private fun dismissLogbookPreview() = _uiState.update { it.copy(logbookPreview = null, wavelogPending = null) }
/** Operator chose "ignore" on the position check: keep the prepared preview. */ /** Operator chose "ignore" on the position check: keep the prepared preview. */
private fun ignoreLogbookPositionWarning() = _uiState.update { it.copy(logbookPositionWarning = null) } private fun ignoreLogbookPositionWarning() = _uiState.update { it.copy(logbookPositionWarning = null) }
@@ -459,7 +573,7 @@ class SettingsViewModel(
private fun abandonLogbookForGridFix() { private fun abandonLogbookForGridFix() {
lotwUploadRepository.discardPreview() lotwUploadRepository.discardPreview()
lastLogbookUploadIds = emptyList() lastLogbookUploadIds = emptyList()
_uiState.update { it.copy(logbookPositionWarning = null, logbookPreview = null) } _uiState.update { it.copy(logbookPositionWarning = null, logbookPreview = null, wavelogPending = null) }
} }
/** Long-press entry: selection mode with the pressed record checked. */ /** Long-press entry: selection mode with the pressed record checked. */
@@ -486,7 +600,8 @@ class SettingsViewModel(
viewModelScope.launch { viewModelScope.launch {
_uiState.update { it.copy(logbookUploadBusy = true, logbookUploadMessage = "") } _uiState.update { it.copy(logbookUploadBusy = true, logbookUploadMessage = "") }
try { try {
val selected = resubmitCandidates(qsoRepository.records.first(), _uiState.value.logbookSelectedIds) val all = qsoRepository.records.first()
val selected = resubmitCandidates(all, _uiState.value.logbookSelectedIds)
if (selected.isEmpty()) { if (selected.isEmpty()) {
_uiState.update { it.copy(logbookUploadBusy = false, logbookUploadMessage = "No complete QSOs in the selection") } _uiState.update { it.copy(logbookUploadBusy = false, logbookUploadMessage = "No complete QSOs in the selection") }
return@launch return@launch
@@ -502,10 +617,13 @@ class SettingsViewModel(
return@launch return@launch
} }
lastLogbookUploadIds = preview.submittedIds lastLogbookUploadIds = preview.submittedIds
// Wavelog piggyback: the resubmit confirmation syncs the backlog too.
val wavelogPending = prepareWavelogPiggyback(all)
_uiState.update { _uiState.update {
it.copy( it.copy(
logbookUploadBusy = false, logbookUploadBusy = false,
logbookPreview = preview, logbookPreview = preview,
wavelogPending = wavelogPending,
logbookPositionWarning = positionWarning(settingsRepo.getCurrentGrid(), preview.grids) logbookPositionWarning = positionWarning(settingsRepo.getCurrentGrid(), preview.grids)
) )
} }
@@ -704,6 +822,7 @@ class SettingsViewModel(
settingsRepo = container.settingsRepo, settingsRepo = container.settingsRepo,
updateRepo = container.updateRepo, updateRepo = container.updateRepo,
wavelogRepo = container.wavelogRepo, wavelogRepo = container.wavelogRepo,
wavelogUploadRepository = container.wavelogUploadRepository,
lotwRepo = container.lotwRepo, lotwRepo = container.lotwRepo,
qsoRepository = container.qsoRepository, qsoRepository = container.qsoRepository,
lotwUploadRepository = container.lotwUploadRepository, lotwUploadRepository = container.lotwUploadRepository,
@@ -400,6 +400,7 @@ private fun ReportDialog(
onDismiss: () -> Unit onDismiss: () -> Unit
) { ) {
val dayReports = day.slots.flatMap { it.reportIds }.mapNotNull { reports[it] } val dayReports = day.slots.flatMap { it.reportIds }.mapNotNull { reports[it] }
val conflictedIds = day.slots.filter { it.isConflicted }.flatMap { it.reportIds }.toSet()
InfoDialog( InfoDialog(
title = "$statusName · ${day.dateLabel}", title = "$statusName · ${day.dateLabel}",
onDismiss = onDismiss, onDismiss = onDismiss,
@@ -448,6 +449,10 @@ private fun ReportDialog(
) )
Spacer(modifier = Modifier.width(6.dp)) Spacer(modifier = Modifier.width(6.dp))
Text(text = report.statusText, fontSize = 14.sp, fontWeight = FontWeight.Bold) Text(text = report.statusText, fontSize = 14.sp, fontWeight = FontWeight.Bold)
if (report.id in conflictedIds) {
Spacer(modifier = Modifier.width(8.dp))
ConflictBadge()
}
} }
Text( Text(
text = "${report.call} ${report.dateUtc} ${report.timeUtc}" + 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 @Composable
private fun AmSatUploadPanel( private fun AmSatUploadPanel(
statusName: String, statusName: String,
+2 -2
View File
@@ -1,8 +1,8 @@
[versions] [versions]
#noinspection UnusedVersionCatalogEntry #noinspection UnusedVersionCatalogEntry
appVersionCode = "555" appVersionCode = "567"
#noinspection UnusedVersionCatalogEntry #noinspection UnusedVersionCatalogEntry
appVersionName = "4.4.8-ba7opf.3" appVersionName = "4.4.8-ba7opf.9.2"
#noinspection UnusedVersionCatalogEntry #noinspection UnusedVersionCatalogEntry
compileSdk = "37" compileSdk = "37"
#noinspection UnusedVersionCatalogEntry #noinspection UnusedVersionCatalogEntry