Compare commits

...
36 Commits
Author SHA1 Message Date
atsunatsu 7a7d742b54 build: release version 4.4.8-ba7opf.10.1 (574) 2026-10-06 03:19:28 +08:00
atsunatsu 146d47455f feat(map): 海上群岛按整体包络命中(点岛间海面即弹实体详情)
- 新增第三类命中目标:≥2 分离环且单岛跨度 ≤2.5° 的散布群岛,整体包络即命中区
  (±40px 触点余量),点岛链之间的海面归该实体
- 优先级:小实体 80px 目标 > 陆地多边形点内 > 群岛包络;主大陆+零星岛(日/英/印尼)
  不进群岛模式,行为不变
- 包络按单一展开经度框计算,反经线群岛(斐济式)不横扫
- 测试 6→10:包络命中、包络内小实体优先、主大陆排除、反经线包络连续
2026-10-06 03:18:57 +08:00
atsunatsu a7d99862d9 build: release version 4.4.8-ba7opf.10 (573)
gradle 内存调优:-Xmx3g/2 workers/parallel=false(WSL 8GB VM 下 lintVital OOM 修复)
2026-10-06 02:19:45 +08:00
atsunatsu a24acc2bfd feat(map): 奖状区域点击弹通联详情 + 过小区域圆点渲染/放大命中 + DXCC 资产补齐 88 区
- DXCC/WAPC/WAJA/WAS/WAZ 点区域弹只读通联详情(复用 VUCC 样式,隐藏匹配/标记站)
- 弹窗标题与 VUCC 统一(等宽字体、同 48dp 头部行高=同 6dp 顶距,回归测试钉死)
- WAZ 标题 CQ ZONE <n>,DXCC 副行带 DXCC <code> 前缀
- 投影过小区块渲染圆点(已通=绿)+ 标签常显;≤80px 区域点击命中放大,小实体优先
- dxcc.json 231→278 区:B 批岛屿 + 47 实体(威克/中途岛/扬马延/布韦/斯瓦尔巴/
  圣赫勒拿/毛里求斯/塞舌尔/佛得角/科摩罗/留尼汪/科索沃/斯普拉特利/黄岩岛等)
- THIRD_PARTY_NOTICES: Natural Earth 来源补 10m map units/rivers
2026-10-06 02:19:32 +08:00
atsunatsu ad970eec77 fix(radar): 计算器 offset 改为跟随驱动计算器的转发器读写
per-satellite offset 原先只在 SelectTransmitter 里载入、并按 selectedUuid
的 catnum 保存:重开雷达页读不回(看起来"没保存"),选中被 toggle 掉后
catnum 为 null 静默丢写;且计算器页 chip 仅在 dedupe 后多于一条时渲染,
单转发器卫星(JO-97/RS-44/FO-29/AO-73/QO-100/TO-108/FO-82/QB50P1/
UKUBE-1)根本没有 chip 可点,永远触发不了载入——上游 rt-bishop 在 PR #237
评审时预言过这个加载问题。

改为 offset 跟随"驱动计算器的转发器":该转发器变化(打开/换星)即载入
已存值,按其 catnum 保存,与收发机选中状态解耦;同时删掉 loadPassData 里
的 workaround 载入与 SelectTransmitter 里的 offset 读写,单一来源、与上游
PR #265 同形便于后续合并。存储格式(satelliteOffsets 单 JSON)不变,
已存的值补丁后直接生效。

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

- 雷达窗口外录入:不在过境窗口内按 Log 时按当前过境中点落点(弹窗确认,可事后编辑改时间)
- 网格显示链:上传盖章(markUploaded 记录台址网格集)+ 日志本行尾 / 雷达页「自己的格子」+
  QSL 槽对方网格(绿 #4CD964)+ 缩写规则统一(gridsLabel 上移 core:presentation);
  QsoDatabase v2→v3 增列 theirVuccGrids
- 全量同步「双倍」三层修复:键无关身份±60s 镜像折叠、matchableMode 归一(MFSK→FT4)、
  双确认对按 contentScore 折叠
- 上传前核对:批记录网格与台址完全不交集 → 弹窗(修改台址带预填跳转 / 忽略),预览被闸住
- 重传选中:日志本长按多选(含已上传/已确认)→ prepare(resubmit=true) → 成功后网格盖章更新;
  退出日志本重置选择(需重新长按)
- 报错拆分:QSO_FUTURE(记录时间在未来)与 QSO_DATE(超出证书范围)分开提示
- 全 App 弹窗样式统一(深色圆角 sheet)

测试:domain 172 / data 116 全绿。
2026-10-02 12:01:57 +08:00
102 changed files with 6195 additions and 491 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.
+24
View File
@@ -44,3 +44,27 @@ SOFTWARE.
- License: Apache-2.0 - License: Apache-2.0
- Used for: `core/presentation/src/main/res/drawable/ic_grid_finder.xml` - Used for: `core/presentation/src/main/res/drawable/ic_grid_finder.xml`
(crosshairs-gps icon). Attribution is also noted in the icon file header. (crosshairs-gps icon). Attribution is also noted in the icon file header.
## OpenStreetMap (award boundary geometry)
- Source: https://www.openstreetmap.org — relations `Ural Mountains` (21368163),
`England` (58447), `Scotland` (58446), `Wales` (58437),
`Northern Ireland` (156393)
- License: Open Database License (ODbL) 1.0 —
https://opendatacommons.org/licenses/odbl/1-0/
- Used for: the Europe/Asia split line of European/Asiatic Russia and the
England/Scotland/Wales/Northern Ireland partitions in
`feature/map/src/main/assets/awards/dxcc.json` (derived geometry).
Map tiles displayed in the app are © OpenStreetMap contributors and carry
their own in-app attribution.
## Natural Earth
- Source: https://www.naturalearthdata.com (1:110m countries, 1:10m admin-0
map units, 1:10m rivers)
- License: Public domain — https://www.naturalearthdata.com/about/terms-of-use/
- Used for: the country/region polygons of the award boundary assets under
`feature/map/src/main/assets/awards/` (including the island / microstate
DXCC entities — Singapore, Malta, Canary Is., ... — and the Balearic/Canary
rings split out of Spain's 1:10m multipolygon), and the Ural river centerline
used for the European/Asiatic Russia split.
@@ -105,6 +105,9 @@ class MainApplication : Application(), IContainerProvider {
*/ */
private suspend fun checkLoTWAutoSync(timeNow: Long = System.currentTimeMillis()) { private suspend fun checkLoTWAutoSync(timeNow: Long = System.currentTimeMillis()) {
val settingsRepo = container.settingsRepo val settingsRepo = container.settingsRepo
// One-of-two rule: while Wavelog is configured the app uploads/syncs through
// Wavelog only — the automatic LoTW sync stays off.
if (settingsRepo.wavelogUploadSettings.value.isReady) return
val lotwSettings = settingsRepo.lotwSettings.value val lotwSettings = settingsRepo.lotwSettings.value
if (!shouldAutoSyncLoTW( if (!shouldAutoSyncLoTW(
isConfigured = lotwSettings.isConfigured, isConfigured = lotwSettings.isConfigured,
@@ -147,7 +147,7 @@ fun NavRoot(deeplink: String? = null) {
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background color = MaterialTheme.colorScheme.background
) { ) {
RadarDestination(navigateUp = navigateBack) RadarDestination(navigateUp = navigateBack, onOpenLoTWStation = navigateToLoTWStation)
} }
} }
entry<GridFinderDestination> { entry<GridFinderDestination> {
@@ -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, wavelogStation = CASE WHEN :stationId = '' THEN wavelogStation ELSE :stationId END WHERE id IN (:ids)")
suspend fun markWavelogUploaded(ids: List<Long>, stationId: String)
} }
@@ -20,7 +20,7 @@ import com.rtbishop.look4sat.core.data.database.entity.QsoEntity
* upload receipts). Kept apart from [Look4SatDb] so existing installations * 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 = 2, exportSchema = false) @Database(entities = [QsoEntity::class], version = 5, exportSchema = false)
abstract class QsoDatabase : RoomDatabase() { abstract class QsoDatabase : RoomDatabase() {
abstract fun qsoDao(): QsoDao abstract fun qsoDao(): QsoDao
} }
@@ -31,3 +31,24 @@ val MIGRATION_QSO_1_2 = object : Migration(1, 2) {
db.execSQL("ALTER TABLE qso_records ADD COLUMN lotwUploaded INTEGER NOT NULL DEFAULT 0") db.execSQL("ALTER TABLE qso_records ADD COLUMN lotwUploaded INTEGER NOT NULL DEFAULT 0")
} }
} }
/** v2 → v3: keep the OPPOSITE station's grid set (multi-grid confirmations as LoTW reports them). */
val MIGRATION_QSO_2_3 = object : Migration(2, 3) {
override fun migrate(db: SupportSQLiteDatabase) {
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")
}
}
/** v4 → v5: remember which Wavelog station profile (台址) a QSO belongs to. */
val MIGRATION_QSO_4_5 = object : Migration(4, 5) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE qso_records ADD COLUMN wavelogStation TEXT NOT NULL DEFAULT ''")
}
}
@@ -44,9 +44,12 @@ 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 = "''") val wavelogStation: String = "",
@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 = "",
@ColumnInfo(defaultValue = "''") val theirVuccGrids: String = "",
val dxcc: Int? = null, val dxcc: Int? = null,
@ColumnInfo(defaultValue = "''") val country: String = "", @ColumnInfo(defaultValue = "''") val country: String = "",
val cqZone: Int? = null, val cqZone: Int? = null,
@@ -30,6 +30,9 @@ import com.rtbishop.look4sat.core.data.database.Look4SatDb
import com.rtbishop.look4sat.core.data.database.MIGRATION_1_2 import com.rtbishop.look4sat.core.data.database.MIGRATION_1_2
import com.rtbishop.look4sat.core.data.database.MIGRATION_2_3 import com.rtbishop.look4sat.core.data.database.MIGRATION_2_3
import com.rtbishop.look4sat.core.data.database.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_3_4
import com.rtbishop.look4sat.core.data.database.MIGRATION_QSO_4_5
import com.rtbishop.look4sat.core.data.database.QsoDatabase import com.rtbishop.look4sat.core.data.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
@@ -47,7 +50,9 @@ import com.rtbishop.look4sat.core.data.repository.SettingsRepo
import com.rtbishop.look4sat.core.data.repository.UpdateRepository import com.rtbishop.look4sat.core.data.repository.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.WavelogUploadRepository
import com.rtbishop.look4sat.core.data.repository.WavelogSyncRepository
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
@@ -68,7 +73,9 @@ import com.rtbishop.look4sat.core.domain.repository.ISatelliteRepo
import com.rtbishop.look4sat.core.domain.repository.ISelectionRepo import com.rtbishop.look4sat.core.domain.repository.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.IWavelogUploadRepository
import com.rtbishop.look4sat.core.domain.repository.IWavelogSyncRepository
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
@@ -99,11 +106,14 @@ class MainContainer(private val context: Context) : IMainContainer {
override val databaseRepo = provideDatabaseRepo() override val databaseRepo = provideDatabaseRepo()
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 wavelogUploadRepository: IWavelogUploadRepository by lazy { WavelogUploadRepository() }
override val wavelogSyncRepository: IWavelogSyncRepository by lazy { WavelogSyncRepository() }
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) .addMigrations(MIGRATION_QSO_1_2, MIGRATION_QSO_2_3, MIGRATION_QSO_3_4)
.addMigrations(MIGRATION_QSO_4_5)
.build() .build()
QsoRepository(database.qsoDao(), Dispatchers.IO) QsoRepository(database.qsoDao(), Dispatchers.IO)
} }
@@ -39,7 +39,14 @@ internal class LoTWSigner(private val config: LoTWConfig) {
!call.any(Char::isLetter) || !call.any(Char::isDigit)) fail(LoTWProblem.INVALID_CONTACT, call) !call.any(Char::isLetter) || !call.any(Char::isDigit)) fail(LoTWProblem.INVALID_CONTACT, call)
if (!record.myCallsign.trim().equals(key.info.callsign, true)) fail(LoTWProblem.CALLSIGN_MISMATCH, call) if (!record.myCallsign.trim().equals(key.info.callsign, true)) fail(LoTWProblem.CALLSIGN_MISMATCH, call)
val date = utc(record.startUtcMillis, "yyyy-MM-dd") val date = utc(record.startUtcMillis, "yyyy-MM-dd")
if (date < key.info.firstQsoDate || (key.info.lastQsoDate.isNotBlank() && date > key.info.lastQsoDate) || record.startUtcMillis > now) { // A record dated after "now" gets its own message: it happens when the operator
// logged against a pass that had not started yet (out-of-window flow), and "fix the
// time" is the actionable advice. Reporting it as "outside the certificate" sent one
// operator hunting the certificate while the culprit was the record's future time.
if (record.startUtcMillis > now) {
fail(LoTWProblem.QSO_FUTURE, "$call @ ${utc(record.startUtcMillis, "MM-dd HH:mm'Z'")}")
}
if (date < key.info.firstQsoDate || (key.info.lastQsoDate.isNotBlank() && date > key.info.lastQsoDate)) {
fail(LoTWProblem.QSO_DATE, call) fail(LoTWProblem.QSO_DATE, call)
} }
fun mhz(hz: Long?): String = hz?.let { BigDecimal.valueOf(it, 6).stripTrailingZeros().toPlainString() }.orEmpty() fun mhz(hz: Long?): String = hz?.let { BigDecimal.valueOf(it, 6).stripTrailingZeros().toPlainString() }.orEmpty()
@@ -114,6 +114,19 @@ class LoTWUploadRepository internal constructor(
} }
} }
override suspend fun previewCertificate(data: ByteArray, password: CharArray): LoTWCertificate = withContext(Dispatchers.IO) {
mutex.withLock {
try {
if (data.isEmpty() || data.size > MAX_CERTIFICATE_BYTES) fail(LoTWProblem.CERTIFICATE_INVALID)
LoTWKeyMaterial.read(data, password, now()).info
} finally {
// Never keep the operator's password; the p12 bytes stay the caller's
// own copy (the dialog re-reads the file for every preview attempt).
password.fill('\u0000')
}
}
}
override suspend fun saveCertificatePassword(password: CharArray): LoTWCertificate = withContext(Dispatchers.IO) { override suspend fun saveCertificatePassword(password: CharArray): LoTWCertificate = withContext(Dispatchers.IO) {
mutex.withLock { mutex.withLock {
val stored = storage.read("certificate") ?: fail(LoTWProblem.CERTIFICATE_MISSING) val stored = storage.read("certificate") ?: fail(LoTWProblem.CERTIFICATE_MISSING)
@@ -279,6 +292,13 @@ class LoTWUploadRepository internal constructor(
else -> contact else -> contact
} }
} }
// The station-location grids this batch is signed with: the primary grid plus the
// MY_VUCC_GRIDS set (1 inside a grid, 2 on a line, 4 on a corner — see [LoTWConfig]).
val locationGrids = buildList {
signing.location["GRIDSQUARE"]?.takeIf(String::isNotBlank)?.let(::add)
signing.location["MY_VUCC_GRIDS"]?.split(',')
?.map(String::trim)?.filter(String::isNotBlank)?.let(::addAll)
}.distinct()
val preview = LoTWUploadPreview( val preview = LoTWUploadPreview(
UUID.randomUUID().toString(), signing.key.info.callsign, signing.key.info.dxcc, signing.location.getValue("GRIDSQUARE"), UUID.randomUUID().toString(), signing.key.info.callsign, signing.key.info.dxcc, signing.location.getValue("GRIDSQUARE"),
contacts.size, skipped, contacts.size, skipped,
@@ -289,7 +309,9 @@ class LoTWUploadRepository internal constructor(
unavailable, unavailable,
reasons.toMap(), reasons.toMap(),
duplicates, duplicates,
contacts.map { it.record.id } contacts.map { it.record.id },
locationGrids,
resubmit
) )
if (contacts.isNotEmpty()) pending = Pending( if (contacts.isNotEmpty()) pending = Pending(
preview, preview,
@@ -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)
@@ -285,7 +285,9 @@ class LoTWRepository : ILoTWRepository {
myGrid = myGrids.firstOrNull(), myGrid = myGrids.firstOrNull(),
myGrids = myGrids, myGrids = myGrids,
myCallsign = myCallsign, myCallsign = myCallsign,
stationKey = stationKey stationKey = stationKey,
// Copy, never the live buffer — resetRecord() clears it.
theirGrids = gridsInRecord.distinct().toList()
) )
for (grid in gridsInRecord) { for (grid in gridsInRecord) {
result.getOrPut(grid) { mutableListOf() }.add(qso) result.getOrPut(grid) { mutableListOf() }.add(qso)
@@ -16,8 +16,11 @@ import com.rtbishop.look4sat.core.domain.logbook.AdifImportResult
import com.rtbishop.look4sat.core.domain.logbook.IQsoRepository import com.rtbishop.look4sat.core.domain.logbook.IQsoRepository
import com.rtbishop.look4sat.core.domain.logbook.QsoRecord import com.rtbishop.look4sat.core.domain.logbook.QsoRecord
import com.rtbishop.look4sat.core.domain.logbook.QsoStatus import com.rtbishop.look4sat.core.domain.logbook.QsoStatus
import com.rtbishop.look4sat.core.domain.logbook.bandsMatchOrMirror
import com.rtbishop.look4sat.core.domain.logbook.contentScore
import com.rtbishop.look4sat.core.domain.logbook.displayMode import com.rtbishop.look4sat.core.domain.logbook.displayMode
import com.rtbishop.look4sat.core.domain.logbook.sameConfirmedContact import com.rtbishop.look4sat.core.domain.logbook.sameConfirmedContact
import com.rtbishop.look4sat.core.domain.logbook.sameContactIdentity
import com.rtbishop.look4sat.core.domain.logbook.satelliteIdentity import com.rtbishop.look4sat.core.domain.logbook.satelliteIdentity
import com.rtbishop.look4sat.core.domain.logbook.splitConfirmationPairs import com.rtbishop.look4sat.core.domain.logbook.splitConfirmationPairs
import com.rtbishop.look4sat.core.domain.logbook.withConfirmation import com.rtbishop.look4sat.core.domain.logbook.withConfirmation
@@ -48,20 +51,23 @@ 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,
lotwQslDate = "", vuccGrids = emptyList(), wavelogUploaded = false,
wavelogStation = "",
lotwQslDate = "", vuccGrids = emptyList(), theirVuccGrids = emptyList(),
dxcc = null, country = "", cqZone = null, region = "" dxcc = null, country = "", cqZone = null, region = ""
).toEntity()) ).toEntity())
} else { } else {
val saved = if (previous?.lotwConfirmed == true && sameConfirmedContact(record, previous)) { val saved = if (previous?.lotwConfirmed == true && sameConfirmedContact(record, previous)) {
record.withConfirmation(previous) record.withConfirmation(previous)
} else if (previous?.lotwConfirmed == true) record.copy( } else if (previous?.lotwConfirmed == true) record.copy(
lotwConfirmed = false, lotwQslDate = "", vuccGrids = emptyList(), lotwConfirmed = false, lotwQslDate = "", vuccGrids = emptyList(), theirVuccGrids = emptyList(),
dxcc = null, country = "", cqZone = null, region = "" dxcc = null, country = "", cqZone = null, region = ""
) else record ) else record
val received = if (previous != null && ( val received = if (previous != null && (
@@ -76,9 +82,23 @@ class QsoRepository(
override suspend fun delete(id: Long) = withContext(dispatcher) { importMutex.withLock { dao.delete(id) } } override suspend fun delete(id: Long) = withContext(dispatcher) { importMutex.withLock { dao.delete(id) } }
override suspend fun markUploaded(ids: List<Long>) = withContext(dispatcher) { override suspend fun markUploaded(ids: List<Long>, grids: List<String>) = withContext(dispatcher) {
if (ids.isEmpty()) return@withContext if (ids.isEmpty()) return@withContext
dao.markUploaded(ids) dao.markUploaded(ids)
// Stamp the station grid set this batch went out under (a line/corner prefill from
// the grid finder carries 2–4 grids). The logbook row shows it after the timestamp.
val normalized = grids.map { it.trim().uppercase(Locale.US).take(4) }
.filter { it.length >= 4 }.distinct().sorted()
if (normalized.isEmpty()) return@withContext
val stamped = ids.mapNotNull { id ->
dao.find(id)?.toDomain()?.takeIf { it.vuccGrids != normalized }?.copy(vuccGrids = normalized)
}
if (stamped.isNotEmpty()) dao.saveBatch(stamped.map { it.toEntity() })
}
override suspend fun markWavelogUploaded(ids: List<Long>, stationId: String) = withContext(dispatcher) {
if (ids.isEmpty()) return@withContext
importMutex.withLock { dao.markWavelogUploaded(ids, stationId) }
} }
override suspend fun exportAdi(ids: Set<Long>?, includeIncomplete: Boolean): String = withContext(dispatcher) { override suspend fun exportAdi(ids: Set<Long>?, includeIncomplete: Boolean): String = withContext(dispatcher) {
@@ -105,6 +125,19 @@ class QsoRepository(
importMutex.withLock { mergeRecords(records, fromLoTW = true) } importMutex.withLock { mergeRecords(records, fromLoTW = true) }
} }
/**
* Merges QSO records pulled from Wavelog (the sync/download direction).
*
* Matching uses the same [stableQsoKey] identity as every other import, so a record
* already recorded locally (typically uploaded from this app) is not duplicated — it
* simply learns its Wavelog station profile and, where the local row is missing them,
* the opposite-station fields the server kept. Fresh records are inserted already
* marked as uploaded, so a later upload never sends them straight back.
*/
override suspend fun mergeWavelog(records: List<QsoRecord>): AdifImportResult = withContext(dispatcher) {
importMutex.withLock { mergeWavelogRecords(records) }
}
override suspend fun consolidateConfirmations(): Int = withContext(dispatcher) { override suspend fun consolidateConfirmations(): Int = withContext(dispatcher) {
importMutex.withLock { importMutex.withLock {
val working = dao.getAll().map(QsoEntity::toDomain).toMutableList() val working = dao.getAll().map(QsoEntity::toDomain).toMutableList()
@@ -132,9 +165,7 @@ class QsoRepository(
val renames = rewriteOfficialNames(working) val renames = rewriteOfficialNames(working)
val lookup = working.indices.groupBy { working[it].confirmationLookupKey() } val lookup = working.indices.groupBy { working[it].confirmationLookupKey() }
.mapValues { it.value.toMutableList() }.toMutableMap() .mapValues { it.value.toMutableList() }.toMutableMap()
val keyIndex = working.indices.groupBy { stableQsoKey(working[it]) } val knownKeys = working.map { stableQsoKey(it) }.toMutableSet()
.mapValues { it.value.toMutableList() }.toMutableMap()
val knownKeys = keyIndex.keys.toMutableSet()
val changes = linkedMapOf<Int, QsoRecord>().apply { val changes = linkedMapOf<Int, QsoRecord>().apply {
putAll(consolidation.merged) putAll(consolidation.merged)
putAll(renames) putAll(renames)
@@ -149,27 +180,38 @@ class QsoRepository(
val exact = candidates.filter { working[it].startUtcMillis == remote.startUtcMillis } val exact = candidates.filter { working[it].startUtcMillis == remote.startUtcMillis }
val match = exact.singleOrNull() ?: candidates.singleOrNull() val match = exact.singleOrNull() ?: candidates.singleOrNull()
if (match == null) { if (match == null) {
// A confirmation downloaded by the pre-fix parser sits as its own row with the // A confirmation downloaded by an early release sits as its own row with the
// band direction read mirrored (the downlink was stored as the uplink). It has no // band direction read mirrored (the downlink was stored as the uplink). The
// local partner (so nothing folds it) and its dedupe key equals the fresh report, // strict lookup above cannot see it: its bands fail the comparison, and its
// so it would otherwise be skipped forever, keeping the wrong band on display. // dedupe key can differ too (fields written by older releases — timestamp
// When the bands are exactly mirrored, adopt the report's direction instead. // precision, FT4 sub-mode spelling, missing callsign — so it is not treated
val stale = if (fromLoTW) keyIndex[stableQsoKey(remote)]?.firstOrNull() // as known either). Fold the report into the richest mirrored row instead of
?.takeIf { index -> working[index].lotwConfirmed && // inserting a second row next to it; left as two rows, neither consolidation
working[index].band.equals(remote.rxBand, true) && // nor a later sync could reliably heal them.
working[index].rxBand.equals(remote.band, true) } else null val mirroredRows = if (fromLoTW) lookup[remote.confirmationLookupKey()].orEmpty().filter { index ->
if (stale != null) { working[index].band.isNotBlank() && remote.band.isNotBlank() &&
val fixed = working[stale].copy(band = remote.band, rxBand = remote.rxBand) !working[index].band.equals(remote.band, true) &&
working[stale] = fixed sameContactIdentity(working[index], remote) &&
changes[stale] = fixed bandsMatchOrMirror(working[index], remote)
updated++ } else emptyList()
if (mirroredRows.isNotEmpty()) {
val target = mirroredRows.maxBy { contentScore(working[it]) }
mirroredRows.forEach { index ->
val healed = working[index].copy(
band = remote.band.ifBlank { working[index].band },
rxBand = remote.rxBand.ifBlank { working[index].rxBand }
)
val folded = if (index == target) healed.withConfirmation(remote) else healed
working[index] = folded
changes[index] = folded
}
updated += mirroredRows.size
return@forEach return@forEach
} }
if (stableQsoKey(remote) in knownKeys) { skipped++; return@forEach } if (stableQsoKey(remote) in knownKeys) { skipped++; return@forEach }
val added = remote.copy(id = 0L, satelliteName = officialSatelliteName(remote.satelliteName)) val added = remote.copy(id = 0L, satelliteName = officialSatelliteName(remote.satelliteName))
changes[working.size] = added changes[working.size] = added
lookup.getOrPut(added.confirmationLookupKey()) { mutableListOf() }.add(working.size) lookup.getOrPut(added.confirmationLookupKey()) { mutableListOf() }.add(working.size)
keyIndex.getOrPut(stableQsoKey(added)) { mutableListOf() }.add(working.size)
knownKeys += stableQsoKey(added) knownKeys += stableQsoKey(added)
working += added working += added
imported++ imported++
@@ -188,6 +230,56 @@ class QsoRepository(
return AdifImportResult(imported, skipped, updated) return AdifImportResult(imported, skipped, updated)
} }
private suspend fun mergeWavelogRecords(records: List<QsoRecord>): AdifImportResult {
val working = dao.getAll().map(QsoEntity::toDomain).toMutableList()
// Rows imported under a tracking-source name join the ARRL name first, so the
// lookup below sees one identity for both sides of a match.
val renames = rewriteOfficialNames(working)
val indexByKey = HashMap<String, Int>(working.size)
working.indices.forEach { index -> indexByKey.putIfAbsent(stableQsoKey(working[index]), index) }
val changes = linkedMapOf<Int, QsoRecord>().apply { putAll(renames) }
var imported = 0
var updated = renames.size
var skipped = 0
val seenKeys = hashSetOf<String>()
records.forEach { remote ->
val key = stableQsoKey(remote)
if (!seenKeys.add(key)) {
skipped++
return@forEach
}
val index = indexByKey[key]
if (index == null) {
val added = remote.copy(
id = 0L,
wavelogUploaded = true,
satelliteName = officialSatelliteName(remote.satelliteName)
)
changes[working.size] = added
indexByKey[stableQsoKey(added)] = working.size
working += added
imported++
} else {
val previous = working[index]
val merged = previous.copy(
wavelogUploaded = true,
wavelogStation = remote.wavelogStation.ifBlank { previous.wavelogStation },
theirGrid = previous.theirGrid.ifBlank { remote.theirGrid },
theirVuccGrids = previous.theirVuccGrids.ifEmpty { remote.theirVuccGrids }
)
if (merged != previous) {
working[index] = merged
changes[index] = merged
updated++
} else {
skipped++
}
}
}
dao.saveBatch(changes.values.map { it.toEntity() })
return AdifImportResult(imported, skipped, updated)
}
/** Result of folding already-split rows back together. */ /** Result of folding already-split rows back together. */
private data class Consolidation( private data class Consolidation(
/** index in the stored list -> the record to save (local row + its confirmation). */ /** index in the stored list -> the record to save (local row + its confirmation). */
@@ -265,9 +357,12 @@ private fun QsoEntity.toDomain() = QsoRecord(
propagationMode = propagationMode, propagationMode = propagationMode,
lotwConfirmed = lotwConfirmed, lotwConfirmed = lotwConfirmed,
lotwUploaded = lotwUploaded, lotwUploaded = lotwUploaded,
wavelogUploaded = wavelogUploaded,
wavelogStation = wavelogStation,
lotwReceived = lotwReceived, lotwReceived = lotwReceived,
lotwQslDate = lotwQslDate, lotwQslDate = lotwQslDate,
vuccGrids = vuccGrids.split(',').filter(String::isNotBlank), vuccGrids = vuccGrids.split(',').filter(String::isNotBlank),
theirVuccGrids = theirVuccGrids.split(',').filter(String::isNotBlank),
dxcc = dxcc, dxcc = dxcc,
country = country, country = country,
cqZone = cqZone, cqZone = cqZone,
@@ -301,9 +396,12 @@ 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,
wavelogStation = wavelogStation.trim(),
lotwReceived = lotwReceived, lotwReceived = lotwReceived,
lotwQslDate = lotwQslDate, lotwQslDate = lotwQslDate,
vuccGrids = vuccGrids.joinToString(","), vuccGrids = vuccGrids.joinToString(","),
theirVuccGrids = theirVuccGrids.joinToString(","),
dxcc = dxcc, dxcc = dxcc,
country = country, country = country,
cqZone = cqZone, cqZone = cqZone,
@@ -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 }
@@ -30,7 +30,8 @@ import com.rtbishop.look4sat.core.domain.model.OtherSettings
import com.rtbishop.look4sat.core.domain.model.PassesSettings 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.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
@@ -147,27 +148,9 @@ class SettingsRepo(
} }
//endregion //endregion
//region # Wavelog worked-grids settings //region # Worked-grid data (written by the LoTW / Wavelog syncs)
private val keyWavelogUrl = "wavelogUrl"
private val keyWavelogToken = "wavelogToken"
private val keyWorkedGrids = "workedGrids" private val keyWorkedGrids = "workedGrids"
private val _wavelogSettings = MutableStateFlow(getWavelogSettings())
override val wavelogSettings: StateFlow<WavelogSettings> = _wavelogSettings
override fun updateWavelogSettings(settings: WavelogSettings) {
preferences.edit {
putString(keyWavelogUrl, settings.url.trim())
putString(keyWavelogToken, settings.token.trim())
}
_wavelogSettings.value = settings.copy(url = settings.url.trim(), token = settings.token.trim())
}
private fun getWavelogSettings(): WavelogSettings = WavelogSettings(
url = preferences.getString(keyWavelogUrl, null).orEmpty(),
token = preferences.getString(keyWavelogToken, null).orEmpty()
)
override fun getWorkedGrids(): Set<String> { override fun getWorkedGrids(): Set<String> {
val json = preferences.getString(keyWorkedGrids, null).orEmpty() val json = preferences.getString(keyWorkedGrids, null).orEmpty()
if (json.isBlank()) return emptySet() if (json.isBlank()) return emptySet()
@@ -386,6 +369,112 @@ 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 # Wavelog sync bookkeeping
private val keyWavelogStations = "wavelogStations"
private val keyWavelogSyncCursors = "wavelogSyncCursors"
private val keyWavelogSyncUrl = "wavelogSyncUrl"
private val keyLastWavelogSyncEpochMs = "wavelogLastSyncEpochMs"
override fun getWavelogStations(): List<com.rtbishop.look4sat.core.domain.repository.WavelogStationInfo> {
val json = preferences.getString(keyWavelogStations, null).orEmpty()
if (json.isBlank()) return emptyList()
return try {
val array = org.json.JSONArray(json)
(0 until array.length()).mapNotNull { i ->
val o = array.optJSONObject(i) ?: return@mapNotNull null
val id = o.optString("id").ifBlank { return@mapNotNull null }
com.rtbishop.look4sat.core.domain.repository.WavelogStationInfo(
id = id,
name = o.optString("name"),
callsign = o.optString("call"),
grid = o.optString("grid"),
active = o.optBoolean("active", true)
)
}
} catch (_: Exception) {
emptyList()
}
}
override fun setWavelogStations(stations: List<com.rtbishop.look4sat.core.domain.repository.WavelogStationInfo>) {
val array = org.json.JSONArray()
stations.forEach { station ->
array.put(
org.json.JSONObject()
.put("id", station.id)
.put("name", station.name)
.put("call", station.callsign)
.put("grid", station.grid)
.put("active", station.active)
)
}
preferences.edit { putString(keyWavelogStations, array.toString()) }
}
override fun getWavelogSyncCursors(): Map<String, Long> {
val json = preferences.getString(keyWavelogSyncCursors, null).orEmpty()
if (json.isBlank()) return emptyMap()
return try {
val root = org.json.JSONObject(json)
root.keys().asSequence().associateWith { key -> root.optLong(key, 0L) }
} catch (_: Exception) {
emptyMap()
}
}
override fun setWavelogSyncCursors(cursors: Map<String, Long>) {
val root = org.json.JSONObject()
cursors.forEach { (stationId, cursor) -> root.put(stationId, cursor) }
preferences.edit { putString(keyWavelogSyncCursors, root.toString()) }
}
override fun getWavelogSyncUrl(): String =
preferences.getString(keyWavelogSyncUrl, null).orEmpty()
override fun setWavelogSyncUrl(url: String) =
preferences.edit { putString(keyWavelogSyncUrl, url) }
override fun getLastWavelogSyncEpochMs(): Long =
preferences.getLong(keyLastWavelogSyncEpochMs, 0L)
override fun setLastWavelogSyncEpochMs(value: Long) =
preferences.edit { putLong(keyLastWavelogSyncEpochMs, value) }
//endregion
//region # Transceivers settings //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
@@ -468,6 +557,34 @@ class SettingsRepo(
return true return true
} }
override fun getCurrentGrid(): String? {
val station = _stationPosition.value
val now = System.currentTimeMillis()
// Only trust a location fix that is both recent (≤ 24 h) and newer than the stored
// station, so a stale fix can never override a position the operator just set.
val fix = lastKnownGridFix()?.takeIf { now - it.second <= 24 * 3_600_000L }
val chosen = if (fix != null && fix.second >= station.timestamp) fix.first else station.qthLocator
return chosen.takeIf(String::isNotBlank)
}
/** Last known GPS/NETWORK fix converted to a locator; read-only, null without permission. */
private fun lastKnownGridFix(): Pair<String, Long>? {
return try {
val provider = when {
LocationManagerCompat.hasProvider(locationManager, providerGps) -> providerGps
LocationManagerCompat.hasProvider(locationManager, providerNet) -> providerNet
else -> return null
}
val location = locationManager.getLastKnownLocation(provider) ?: return null
val locator = positionToQth(location.latitude, location.longitude) ?: return null
locator to location.time
} catch (_: SecurityException) {
null
} catch (_: IllegalArgumentException) {
null
}
}
private fun getStationPosition(): GeoPos { private fun getStationPosition(): GeoPos {
val latitude = (preferences.getString(keyStationLatitude, null) ?: "0.0").toDouble() val latitude = (preferences.getString(keyStationLatitude, null) ?: "0.0").toDouble()
val longitude = (preferences.getString(keyStationLongitude, null) ?: "0.0").toDouble() val longitude = (preferences.getString(keyStationLongitude, null) ?: "0.0").toDouble()
@@ -1,97 +0,0 @@
/*
* Look4Sat. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2019-2026 Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.core.data.repository
import com.rtbishop.look4sat.core.domain.repository.IWavelogRepository
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.json.JSONArray
import org.json.JSONObject
/**
* Fetches the list of worked gridsquares from a self-hosted Wavelog instance.
*
* Uses the Wavelog v1 API endpoint (verified against Wavelog 3.0.2):
* POST {base}/index.php/api/logbook_get_worked_grids
* Body: {"key": "<api key>", "logbook_id": "<logbook id>"}
* Response 201: JSON array of 4-char gridsquares, e.g. ["JN47","JO30"]
* (see Wavelog Api.php logbook_get_worked_grids / Api_model::get_grids_worked_in_logbook)
*
* The token field carries "<api_key>:<logbook_id>" so one credential line
* configures both values (the dialog explains this to the user).
*/
class WavelogRepository : IWavelogRepository {
override suspend fun fetchWorkedGrids(url: String, token: String): Set<String>? = withContext(Dispatchers.IO) {
val parts = token.trim().split(":")
val apiKey = parts.getOrNull(0)?.trim().orEmpty()
val logbookId = parts.getOrNull(1)?.trim().orEmpty()
if (url.isBlank() || apiKey.isBlank() || logbookId.isBlank()) return@withContext null
val base = url.trim().trimEnd('/')
val requestUrl = "$base/index.php/api/logbook_get_worked_grids"
try {
val connection = java.net.URL(requestUrl).openConnection() as java.net.HttpURLConnection
connection.connectTimeout = 10_000
connection.readTimeout = 15_000
connection.requestMethod = "POST"
connection.doOutput = true
connection.setRequestProperty("Content-Type", "application/json")
val body = JSONObject().apply {
put("key", apiKey)
put("logbook_id", logbookId)
}.toString().toByteArray()
connection.outputStream.use { it.write(body) }
val code = connection.responseCode
if (code !in 200..299) {
connection.disconnect()
return@withContext null
}
val response = connection.inputStream.bufferedReader().use { it.readText() }
connection.disconnect()
parseWorkedGrids(response)
} catch (e: Exception) {
println("WavelogRepository fetch failure: $e")
null
}
}
internal fun parseWorkedGrids(body: String): Set<String>? {
return try {
val array = when {
body.trimStart().startsWith("[") -> JSONArray(body)
else -> {
// Tolerate {"grids": [...]} wrappers too
JSONObject(body).optJSONArray("grids") ?: return null
}
}
val result = mutableSetOf<String>()
for (i in 0 until array.length()) {
val grid = array.optString(i, "").trim().uppercase()
// Keep only well-formed 4-character Maidenhead squares (e.g. OL62)
if (grid.length == 4 && grid[0] in 'A'..'R' && grid[1] in 'A'..'R'
&& grid[2] in '0'..'9' && grid[3] in '0'..'9'
) {
result.add(grid)
}
}
result
} catch (_: Exception) {
null
}
}
}
@@ -0,0 +1,142 @@
/*
* Look4Sat-BA7OPF. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2026 BA7OPF.
* Based on Look4Sat by Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.core.data.repository
import com.rtbishop.look4sat.core.domain.logbook.AdifCodec
import com.rtbishop.look4sat.core.domain.logbook.QsoRecord
import com.rtbishop.look4sat.core.domain.repository.IWavelogSyncRepository
import com.rtbishop.look4sat.core.domain.repository.WavelogStationInfo
import com.rtbishop.look4sat.core.domain.repository.WavelogSyncFetch
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import org.json.JSONArray
import org.json.JSONObject
import java.util.concurrent.CancellationException
import java.util.concurrent.TimeUnit
/**
* Pulls QSO records from a self-hosted Wavelog via the v1 API.
*
* Verified against Wavelog 3.0.2 (`Api.php::get_contacts_adif`):
* - `POST {base}/index.php/api/get_contacts_adif`
* with `{"key", "station_id": [id...], "fetchfromid": <last id>, "limit"}`.
* - Responds `{"status":"successful","lastfetchedid":<id>,"exported_qsos":N,"adif":"..."}`;
* only rows whose internal id is greater than `fetchfromid` are exported, so the cursor
* makes the pull incremental. Each station keeps its own cursor: a station profile added
* later starts from 0 and pulls its full history without touching the others'.
* - The export is a full ADIF document; decoding reuses [AdifCodec].
*/
class WavelogSyncRepository internal constructor(
client: OkHttpClient = OkHttpClient()
) : IWavelogSyncRepository {
private val client = client.newBuilder()
.connectTimeout(15, TimeUnit.SECONDS)
.readTimeout(120, TimeUnit.SECONDS)
.callTimeout(180, TimeUnit.SECONDS)
.retryOnConnectionFailure(false)
.build()
private val jsonType = "application/json; charset=utf-8".toMediaType()
override suspend fun fetchNewRecords(
url: String,
apiKey: String,
stations: List<WavelogStationInfo>,
cursors: Map<String, Long>,
full: Boolean
): WavelogSyncFetch? = withContext(Dispatchers.IO) {
val base = url.trim().trimEnd('/')
val key = apiKey.trim()
if (base.isBlank() || key.isBlank() || stations.isEmpty()) return@withContext null
val records = mutableListOf<QsoRecord>()
val newCursors = mutableMapOf<String, Long>()
val failed = mutableListOf<String>()
// Every station profile is fetched (including ones the server marks inactive:
// their historical QSOs still belong to the account and the logbook shows them).
stations.forEach { station ->
var cursor = if (full) 0L else cursors[station.id] ?: 0L
var advanced = false
var stationFailed = false
try {
var guard = 0
while (guard++ < MAX_CHUNKS_PER_STATION) {
val page = fetchPage(base, key, station.id, cursor)
if (page == null) {
stationFailed = true
break
}
cursor = maxOf(cursor, page.cursor)
advanced = true
page.records.forEach { records += it.copy(wavelogStation = station.id) }
if (page.exported < CHUNK_LIMIT) break
}
} catch (e: CancellationException) {
throw e
} catch (_: Exception) {
stationFailed = true
}
// A partially fetched station keeps the cursor of its last good page, so the
// next sync resumes exactly where this one stopped — nothing is fetched twice
// and nothing is skipped.
if (advanced) newCursors[station.id] = cursor
if (stationFailed) failed += station.id
}
WavelogSyncFetch(records, newCursors, failed)
}
internal data class Page(val records: List<QsoRecord>, val cursor: Long, val exported: Int)
private fun fetchPage(base: String, key: String, stationId: String, fromId: Long): Page? {
val body = JSONObject().apply {
put("key", key)
put("station_id", JSONArray().put(stationId))
put("fetchfromid", fromId)
put("limit", CHUNK_LIMIT)
}.toString()
val request = Request.Builder()
.url("$base/index.php/api/get_contacts_adif")
.post(body.toRequestBody(jsonType))
.build()
return client.newCall(request).execute().use { response ->
if (!response.isSuccessful) return@use null
parsePage(response.body.string())
}
}
internal fun parsePage(text: String): Page? {
val json = runCatching { JSONObject(text) }.getOrNull() ?: return null
if (json.optString("status") != "successful") return null
if (!json.has("lastfetchedid")) return null
val cursor = json.optLong("lastfetchedid", 0L)
val exported = json.optInt("exported_qsos", 0)
val adif = json.optString("adif")
if (adif.isBlank()) return Page(emptyList(), cursor, exported)
return Page(AdifCodec.decode(adif), cursor, exported)
}
private companion object {
const val CHUNK_LIMIT = 5000
const val MAX_CHUNKS_PER_STATION = 200
}
}
@@ -0,0 +1,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()
@@ -0,0 +1,82 @@
/*
* Look4Sat. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2019-2026 Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.core.data.lotw
import com.rtbishop.look4sat.core.domain.repository.LoTWOperationException
import com.rtbishop.look4sat.core.domain.repository.LoTWProblem
import kotlinx.coroutines.runBlocking
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertThrows
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* previewCertificate parses a .p12 without touching storage — just enough to
* learn the certificate's DXCC entity, so the settings dialog can offer the
* entity's Province/State dropdown before the operator confirms the import.
*
* Fixture test_tqsl_empty.p12 mirrors a modern TQSL export: PBES2/AES-256,
* empty password, TQSL subject attribute 1.3.6.1.4.1.12348.1.1 = BA7OPF and
* extensions .2/.3/.4 = first/last QSO date, DXCC 318 (China).
*/
class LoTWUploadRepositoryPreviewTest {
private class MemStorage : LoTWStorage {
val files = mutableMapOf<String, ByteArray>()
override fun read(name: String): ByteArray? = files[name]
override fun write(name: String, data: ByteArray) { files[name] = data.copyOf() }
override fun delete(name: String) { files.remove(name) }
}
private fun fixture(name: String): ByteArray =
javaClass.classLoader!!.getResourceAsStream(name)!!.use { it.readBytes() }
private fun repository(storage: MemStorage) = LoTWUploadRepository(
storage, { error("station config not needed") }, now = { System.currentTimeMillis() }
)
@Test
fun `preview parses TQSL-style certificate without persisting it`() {
runBlocking {
val storage = MemStorage()
val repo = repository(storage)
val info = repo.previewCertificate(fixture("test_tqsl_empty.p12"), charArrayOf())
assertEquals("BA7OPF", info.callsign)
assertEquals(318, info.dxcc)
assertEquals("2026-01-01", info.firstQsoDate)
assertTrue("nothing written", storage.files.isEmpty())
assertNull("no certificate stored", repo.certificate())
}
}
@Test
fun `wrong password fails as certificate password and wipes the attempt`() {
runBlocking {
val storage = MemStorage()
val repo = repository(storage)
val password = "definitely-wrong".toCharArray()
val error = assertThrows(LoTWOperationException::class.java) {
runBlocking { repo.previewCertificate(fixture("test_pbes2.p12"), password) }
}
assertEquals(LoTWProblem.CERTIFICATE_PASSWORD, error.reason)
assertTrue("password wiped", password.all { it == '\u0000' })
assertTrue("nothing written", storage.files.isEmpty())
}
}
}
@@ -131,4 +131,40 @@ class Pkcs12ReaderTest {
Pkcs12Reader.read("not a p12 file at all".toByteArray(), "x".toCharArray()) Pkcs12Reader.read("not a p12 file at all".toByteArray(), "x".toCharArray())
} }
} }
@Test
fun readsEmptyPasswordCertpbeNoneLayout() {
// Certificates exported without a password must import with an empty char
// array — the reader must decrypt, not demand a password.
val (key, cert) = Pkcs12Reader.read(fixture("test_pbes2_empty.p12"), charArrayOf())
assertEquals("RSA", key.algorithm)
assertTrue("key/cert pair", keyMatches(key, cert))
}
@Test
fun readsEmptyPasswordEncryptedCertLayout() {
val (key, cert) = Pkcs12Reader.read(fixture("test_pbes2_empty_enc.p12"), charArrayOf())
assertEquals("RSA", key.algorithm)
assertTrue("key/cert pair", keyMatches(key, cert))
}
@Test
fun emptyPasswordOnProtectedFileFailsAsDecrypt() {
// Blank input on a password-protected file must fail as a decryption error
// (mapped to "password" upstream) — never silently succeed.
val e = assertThrows(Exception::class.java) {
Pkcs12Reader.read(fixture(), charArrayOf())
}
assertTrue("BadPadding", e is javax.crypto.BadPaddingException)
}
@Test
fun platformKeystoreAcceptsEmptyPassword() {
// LoTWKeyMaterial first tries the platform PKCS12 KeyStore: verify the
// empty-password load/getKey call convention this fix relies on.
val ks = java.security.KeyStore.getInstance("PKCS12")
ks.load(fixture("test_pbes2_empty.p12").inputStream(), charArrayOf())
val alias = ks.aliases().nextElement()
assertNotNull(ks.getKey(alias, charArrayOf()))
}
} }
@@ -18,6 +18,7 @@
*/ */
package com.rtbishop.look4sat.core.data.repository 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
} }
@@ -271,6 +415,8 @@ private class FakeSettingsRepo(dataSources: DataSourcesSettings = defaultDataSou
override fun setStationPosition(locator: String): Boolean = true override fun setStationPosition(locator: String): Boolean = true
override fun getCurrentGrid(): String? = null
override fun getSatelliteTypesIds(types: List<String>): List<Int> = emptyList() override fun getSatelliteTypesIds(types: List<String>): List<Int> = emptyList()
override fun setSatelliteTypeIds(type: String, ids: List<Int>) { override fun setSatelliteTypeIds(type: String, ids: List<Int>) {
@@ -307,10 +453,14 @@ private class FakeSettingsRepo(dataSources: DataSourcesSettings = defaultDataSou
override fun setAmSatCallsign(callsign: String) = Unit override fun setAmSatCallsign(callsign: String) = Unit
override val wavelogSettings: StateFlow<com.rtbishop.look4sat.core.domain.model.WavelogSettings> =
MutableStateFlow(com.rtbishop.look4sat.core.domain.model.WavelogSettings())
override fun updateWavelogSettings(settings: com.rtbishop.look4sat.core.domain.model.WavelogSettings) = Unit
override val wavelogUploadSettings: StateFlow<com.rtbishop.look4sat.core.domain.model.WavelogUploadSettings> =
MutableStateFlow(com.rtbishop.look4sat.core.domain.model.WavelogUploadSettings())
override fun updateWavelogUploadSettings(settings: com.rtbishop.look4sat.core.domain.model.WavelogUploadSettings) = Unit
override fun getWorkedGrids(): Set<String> = emptySet() override fun getWorkedGrids(): Set<String> = emptySet()
@@ -328,6 +478,14 @@ private class FakeSettingsRepo(dataSources: DataSourcesSettings = defaultDataSou
override fun setLastLotwSyncDate(date: String) = Unit override fun setLastLotwSyncDate(date: String) = Unit
override fun getLastLotwSyncCallsign(): String = "" override fun getLastLotwSyncCallsign(): String = ""
override fun setLastLotwSyncCallsign(callsign: String) = Unit override fun setLastLotwSyncCallsign(callsign: String) = Unit
override fun getWavelogStations(): List<com.rtbishop.look4sat.core.domain.repository.WavelogStationInfo> = emptyList()
override fun setWavelogStations(stations: List<com.rtbishop.look4sat.core.domain.repository.WavelogStationInfo>) = Unit
override fun getWavelogSyncCursors(): Map<String, Long> = emptyMap()
override fun setWavelogSyncCursors(cursors: Map<String, Long>) = Unit
override fun getWavelogSyncUrl(): String = ""
override fun setWavelogSyncUrl(url: String) = Unit
override fun getLastWavelogSyncEpochMs(): Long = 0L
override fun setLastWavelogSyncEpochMs(value: Long) = Unit
override fun getWorkedGridQsos(): Map<String, List<com.rtbishop.look4sat.core.domain.model.GridQso>> = emptyMap() override fun getWorkedGridQsos(): Map<String, List<com.rtbishop.look4sat.core.domain.model.GridQso>> = emptyMap()
@@ -167,6 +167,29 @@ class LoTWRepositoryTest {
result.values.forEach { assertEquals(1, it.size) } result.values.forEach { assertEquals(1, it.size) }
} }
@Test
fun parseQsosCapturesOppositeGridSet() {
// The opposite station's grids (GRIDSQUARE + VUCC_GRIDS) must ride on the
// GridQso so the logbook can show multi-grid confirmations in its QSL slot.
val single = "<PROP_MODE:3>SAT\n<SAT_NAME:5>FO-29\n<GRIDSQUARE:4>NL47\n<EOR>\n"
assertEquals(
listOf("NL47"),
repo.parseConfirmedGridQsos(report(single))!!["NL47"]!!.first().theirGrids
)
val pair = "<PROP_MODE:3>SAT\n<SAT_NAME:5>SO-50\n<VUCC_GRIDS:11>EN52en,EN53fa\n<EOR>\n"
assertEquals(
listOf("EN52", "EN53"),
repo.parseConfirmedGridQsos(report(pair))!!["EN52"]!!.first().theirGrids
)
// A 6-char GRIDSQUARE plus a VUCC pair: every field, truncated to 4 chars, in order.
val six = "<PROP_MODE:3>SAT\n<SAT_NAME:5>SO-50\n" +
"<GRIDSQUARE:6>OM60IL\n<VUCC_GRIDS:11>EN52en,EN53fa\n<EOR>\n"
assertEquals(
listOf("OM60", "EN52", "EN53"),
repo.parseConfirmedGridQsos(report(six))!!["OM60"]!!.first().theirGrids
)
}
@Test @Test
fun parseQsosRejectsBodyWithoutEoh() { fun parseQsosRejectsBodyWithoutEoh() {
assertNull(repo.parseConfirmedGridQsos("<HTML>Username/password incorrect</HTML>")) assertNull(repo.parseConfirmedGridQsos("<HTML>Username/password incorrect</HTML>"))
@@ -52,6 +52,9 @@ class QsoRepositoryTest {
// The row is also renamed to the ARRL spelling while it is merged. // The row is also renamed to the ARRL spelling while it is merged.
assertEquals("SO-50", rows.first().satelliteName) assertEquals("SO-50", rows.first().satelliteName)
assertEquals(145_850_000L, rows.first().txFrequencyHz) assertEquals(145_850_000L, rows.first().txFrequencyHz)
// The opposite station's grid set from the report lands on the row.
assertEquals("EN52", rows.first().theirGrid)
assertEquals("EN52,EN53", rows.first().theirVuccGrids)
} }
@Test @Test
@@ -124,6 +127,106 @@ class QsoRepositoryTest {
assertEquals(setOf("SO-50", "FOO-1"), dao.getAll().map { it.satelliteName }.toSet()) assertEquals(setOf("SO-50", "FOO-1"), dao.getAll().map { it.satelliteName }.toSet())
} }
@Test
fun consolidateConfirmations_foldsADoubledConfirmedPairKeepingTheRicherRow() = runBlocking {
// The mirrored import the old parser created (no frequencies, no report-side grids —
// the pre-fix parser filled neither — band direction reversed)... seeded FIRST so it
// carries the lower id.
dao.save(
reportConfirmation(satName = "SO-50")
.copy(band = "70CM", rxBand = "2M", theirGrid = "", theirVuccGrids = emptyList())
.toEntity()
)
// ...plus the app row that was confirmed on its own later (frequencies, upload state).
val localId = dao.save(
loggedInApp("SO-50", lotwUploaded = true).copy(lotwConfirmed = true).toEntity()
)
assertEquals(1, repository.consolidateConfirmations())
val rows = dao.getAll()
assertEquals(1, rows.size)
assertEquals(localId, rows.first().id)
// The surviving row keeps the correct uplink-first bands and gains the report fills.
assertEquals("2M", rows.first().band)
assertEquals("70CM", rows.first().rxBand)
assertEquals("OL62", rows.first().myGrid)
assertEquals(318, rows.first().dxcc)
assertEquals("China", rows.first().country)
assertTrue(rows.first().lotwUploaded)
}
@Test
fun mergeLoTW_healsADoubledMirroredContact() = runBlocking {
// Two mirrored copies of one confirmation (as the pre-fix parser imported on separate
// syncs — no frequencies, no report-side grids): the full sync must consolidate them
// into one row with the report's direction.
val stale = reportConfirmation(satName = "SO-50")
.copy(band = "70CM", rxBand = "2M", theirGrid = "", theirVuccGrids = emptyList())
dao.save(stale.toEntity())
dao.save(stale.copy(id = 0L).toEntity())
repository.mergeLoTW(listOf(reportConfirmation(satName = "SO-50")))
val rows = dao.getAll()
assertEquals(1, rows.size)
assertEquals("2M", rows.first().band)
assertEquals("70CM", rows.first().rxBand)
// The report's fills land on the healed row.
assertEquals("EN52", rows.first().theirGrid)
}
@Test
fun mergeLoTW_healsAMirroredRowWhoseDedupeKeyDivergedFromTheReport() = runBlocking {
// The doubled contacts of the reported bug: a mirrored row written by an older release
// (millisecond-precision timestamp here — enough to make the dedupe key, which holds
// the exact time, differ from the report's) used to gain a second row next to it
// instead of being healed, because every net relied on the key or the strict bands.
val staleId = dao.save(
reportConfirmation(satName = "SO-50")
.copy(
band = "70CM", rxBand = "2M", theirGrid = "", theirVuccGrids = emptyList(),
startUtcMillis = qsoStart + 456L
)
.toEntity()
)
repository.mergeLoTW(listOf(reportConfirmation(satName = "SO-50")))
val rows = dao.getAll()
assertEquals(1, rows.size)
assertEquals(staleId, rows.first().id)
assertEquals("2M", rows.first().band)
assertEquals("70CM", rows.first().rxBand)
assertTrue(rows.first().lotwConfirmed)
assertEquals("EN52", rows.first().theirGrid)
}
@Test
fun mergeLoTW_foldsAMirroredRowSpelledAsPlainMfskIntoItsFt4Report() = runBlocking {
// Releases that persisted no sub-mode stored FT4 rows as plain MFSK while the report
// carries MFSK + FT4. The spelling difference must not block the heal either.
dao.save(
reportConfirmation(satName = "SO-50")
.copy(
band = "70CM", rxBand = "2M", mode = "MFSK", submode = "",
theirGrid = "", theirVuccGrids = emptyList()
)
.toEntity()
)
repository.mergeLoTW(
listOf(reportConfirmation(satName = "SO-50").copy(mode = "MFSK", submode = "FT4"))
)
val rows = dao.getAll()
assertEquals(1, rows.size)
assertEquals("2M", rows.first().band)
assertEquals("70CM", rows.first().rxBand)
assertTrue(rows.first().lotwConfirmed)
assertEquals("EN52", rows.first().theirGrid)
}
@Test @Test
fun mergeLoTW_fixesBandDirectionOfAStaleConfirmationRow() = runBlocking { fun mergeLoTW_fixesBandDirectionOfAStaleConfirmationRow() = runBlocking {
// A confirmation downloaded by the pre-fix parser: ARRL name but the band direction // A confirmation downloaded by the pre-fix parser: ARRL name but the band direction
@@ -192,6 +295,68 @@ class QsoRepositoryTest {
assertEquals(1, dao.getAll().size) assertEquals(1, dao.getAll().size)
} }
@Test
fun markWavelogUploaded_setsTheFlagAndTheStationStamp() = runBlocking {
val id = dao.save(loggedInApp("SO-50", lotwUploaded = false).toEntity())
repository.markWavelogUploaded(listOf(id), "3")
val row = dao.getAll().single()
assertTrue(row.wavelogUploaded)
assertFalse(row.lotwUploaded)
assertEquals("3", row.wavelogStation)
}
@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 })
}
@Test
fun mergeWavelog_insertsNewRecordsAlreadyMarkedAndStationTagged() = runBlocking {
val result = repository.mergeWavelog(
listOf(loggedInApp("SO-50", lotwUploaded = false).copy(wavelogStation = "2"))
)
assertEquals(1, result.imported)
val row = dao.getAll().single()
assertTrue(row.wavelogUploaded)
assertEquals("2", row.wavelogStation)
assertFalse(row.lotwUploaded)
}
@Test
fun mergeWavelog_matchesALocalRowInsteadOfDuplicating() = runBlocking {
val id = dao.save(loggedInApp("SAUDISAT 1C", lotwUploaded = false).copy(wavelogUploaded = true).toEntity())
repository.mergeWavelog(
listOf(loggedInApp("SAUDISAT 1C", lotwUploaded = false).copy(wavelogStation = "1", theirGrid = "EN52"))
)
val rows = dao.getAll()
assertEquals(1, rows.size)
assertEquals(id, rows.first().id)
assertEquals("1", rows.first().wavelogStation)
assertEquals("EN52", rows.first().theirGrid)
// The row joins the ARRL spelling like every other import path.
assertEquals("SO-50", rows.first().satelliteName)
}
@Test
fun mergeWavelog_isIdempotentAcrossSyncs() = runBlocking {
val records = listOf(loggedInApp("SO-50", lotwUploaded = false).copy(wavelogStation = "1"))
repository.mergeWavelog(records)
repository.mergeWavelog(records)
assertEquals(1, dao.getAll().size)
}
/** A record as the log page creates it: tracker name, repeater pair, uplink band. */ /** 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,
@@ -221,7 +386,8 @@ class QsoRepositoryTest {
cqz = 24, cqz = 24,
state = "GD", state = "GD",
myGrid = "OL62", myGrid = "OL62",
myGrids = setOf("OL62", "OL63") myGrids = setOf("OL62", "OL63"),
theirGrids = listOf("EN52", "EN53")
).toConfirmedRecord("BA7OPF") ).toConfirmedRecord("BA7OPF")
private fun QsoRecord.toEntity() = QsoEntity( private fun QsoRecord.toEntity() = QsoEntity(
@@ -251,9 +417,12 @@ class QsoRepositoryTest {
propagationMode = propagationMode, propagationMode = propagationMode,
lotwConfirmed = lotwConfirmed, lotwConfirmed = lotwConfirmed,
lotwUploaded = lotwUploaded, lotwUploaded = lotwUploaded,
wavelogUploaded = wavelogUploaded,
wavelogStation = wavelogStation,
lotwReceived = lotwReceived, lotwReceived = lotwReceived,
lotwQslDate = lotwQslDate, lotwQslDate = lotwQslDate,
vuccGrids = vuccGrids.joinToString(","), vuccGrids = vuccGrids.joinToString(","),
theirVuccGrids = theirVuccGrids.joinToString(","),
dxcc = dxcc, dxcc = dxcc,
country = country, country = country,
cqZone = cqZone, cqZone = cqZone,
@@ -288,5 +457,16 @@ 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>, stationId: String) {
ids.forEach { id ->
rows[id]?.let {
rows[id] = it.copy(
wavelogUploaded = true,
wavelogStation = if (stationId.isBlank()) it.wavelogStation else stationId
)
}
}
}
} }
} }
@@ -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
@@ -319,8 +344,10 @@ private class FakeSettingsRepoForSearch : ISettingsRepo {
override val radioControlSettings: StateFlow<RadioControlSettings> = MutableStateFlow( override val radioControlSettings: StateFlow<RadioControlSettings> = MutableStateFlow(
RadioControlSettings(false, RadioControlSettings.MODEL_YAESU_FT817, "", "", "", "", 9600) RadioControlSettings(false, RadioControlSettings.MODEL_YAESU_FT817, "", "", "", "", 9600)
) )
override val wavelogSettings: StateFlow<com.rtbishop.look4sat.core.domain.model.WavelogSettings> =
MutableStateFlow(com.rtbishop.look4sat.core.domain.model.WavelogSettings()) override val wavelogUploadSettings: StateFlow<com.rtbishop.look4sat.core.domain.model.WavelogUploadSettings> =
MutableStateFlow(com.rtbishop.look4sat.core.domain.model.WavelogUploadSettings())
override fun updateWavelogUploadSettings(settings: com.rtbishop.look4sat.core.domain.model.WavelogUploadSettings) = Unit
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())
@@ -330,6 +357,7 @@ private class FakeSettingsRepoForSearch : ISettingsRepo {
override fun setStationPosition(latitude: Double, longitude: Double, altitude: Double): Boolean = true override fun setStationPosition(latitude: Double, longitude: Double, altitude: Double): Boolean = true
override fun setStationPosition(): Boolean = true override fun setStationPosition(): Boolean = true
override fun setStationPosition(locator: String): Boolean = true override fun setStationPosition(locator: String): Boolean = true
override fun getCurrentGrid(): String? = null
override fun getSatelliteTypesIds(types: List<String>): List<Int> = override fun getSatelliteTypesIds(types: List<String>): List<Int> =
types.flatMap { typeIds[it].orEmpty() }.distinct() types.flatMap { typeIds[it].orEmpty() }.distinct()
@@ -348,7 +376,7 @@ private class FakeSettingsRepoForSearch : ISettingsRepo {
override fun setSatelliteOffset(catnum: Int, offset: String) = Unit override fun setSatelliteOffset(catnum: Int, offset: String) = Unit
override fun getAmSatCallsign(): String = "" override fun getAmSatCallsign(): String = ""
override fun setAmSatCallsign(callsign: String) = Unit override fun setAmSatCallsign(callsign: String) = Unit
override fun updateWavelogSettings(settings: com.rtbishop.look4sat.core.domain.model.WavelogSettings) = Unit
override fun getWorkedGrids(): Set<String> = emptySet() override fun getWorkedGrids(): Set<String> = emptySet()
override fun setWorkedGrids(grids: Set<String>) = Unit override fun setWorkedGrids(grids: Set<String>) = Unit
override fun getWorkedGridQsos(): Map<String, List<com.rtbishop.look4sat.core.domain.model.GridQso>> = emptyMap() override fun getWorkedGridQsos(): Map<String, List<com.rtbishop.look4sat.core.domain.model.GridQso>> = emptyMap()
@@ -362,4 +390,12 @@ private class FakeSettingsRepoForSearch : ISettingsRepo {
override fun setLastLotwSyncDate(date: String) = Unit override fun setLastLotwSyncDate(date: String) = Unit
override fun getLastLotwSyncCallsign(): String = "" override fun getLastLotwSyncCallsign(): String = ""
override fun setLastLotwSyncCallsign(callsign: String) = Unit override fun setLastLotwSyncCallsign(callsign: String) = Unit
override fun getWavelogStations(): List<com.rtbishop.look4sat.core.domain.repository.WavelogStationInfo> = emptyList()
override fun setWavelogStations(stations: List<com.rtbishop.look4sat.core.domain.repository.WavelogStationInfo>) = Unit
override fun getWavelogSyncCursors(): Map<String, Long> = emptyMap()
override fun setWavelogSyncCursors(cursors: Map<String, Long>) = Unit
override fun getWavelogSyncUrl(): String = ""
override fun setWavelogSyncUrl(url: String) = Unit
override fun getLastWavelogSyncEpochMs(): Long = 0L
override fun setLastWavelogSyncEpochMs(value: Long) = Unit
} }
@@ -0,0 +1,74 @@
/*
* Look4Sat-BA7OPF. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2026 BA7OPF.
* Based on Look4Sat by Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.core.data.repository
import org.json.JSONObject
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
/** Parsing of the `get_contacts_adif` response envelope (transport-level wiring is exercised on device). */
class WavelogSyncRepositoryTest {
private val repository = WavelogSyncRepository()
@Test
fun successfulPageParsesRecordsAndCursor() {
val adif = "<ADIF_VER:5>3.1.7\n<EOH>\n" +
"<QSO_DATE:8>20260916<TIME_ON:4>0745<CALL:6>BG5JSB<GRIDSQUARE:4>EN52" +
"<MODE:2>FM<PROP_MODE:3>SAT<SAT_NAME:5>SO-50<EOR>\n"
val text = JSONObject()
.put("status", "successful")
.put("lastfetchedid", 42)
.put("exported_qsos", 1)
.put("adif", adif)
.toString()
val page = repository.parsePage(text)
assertNotNull(page)
assertEquals(42L, page!!.cursor)
assertEquals(1, page.exported)
assertEquals(1, page.records.size)
assertEquals("BG5JSB", page.records.single().theirCallsign)
assertEquals("EN52", page.records.single().theirGrid)
}
@Test
fun failedStatusIsRejected() {
assertNull(repository.parsePage("""{"status":"failed","reason":"missing api key"}"""))
}
@Test
fun missingCursorIsRejected() {
assertNull(repository.parsePage("""{"status":"successful","exported_qsos":0,"adif":null}"""))
assertNull(repository.parsePage("not json at all"))
}
@Test
fun emptyExportKeepsTheCursorAndYieldsNoRecords() {
val page = repository.parsePage("""{"status":"successful","lastfetchedid":7,"exported_qsos":0,"adif":null}""")
assertNotNull(page)
assertEquals(7L, page!!.cursor)
assertTrue(page.records.isEmpty())
}
}
@@ -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
)
}
Binary file not shown.
Binary file not shown.
@@ -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)
@@ -17,12 +17,33 @@ interface IQsoRepository {
suspend fun find(id: Long): QsoRecord? suspend fun find(id: Long): QsoRecord?
suspend fun save(record: QsoRecord): Long suspend fun save(record: QsoRecord): Long
suspend fun delete(id: Long) suspend fun delete(id: Long)
suspend fun markUploaded(ids: List<Long>) /**
* Marks the batch as uploaded and stamps the station-location grids the batch was
* signed with (1–4 gridsquares: inside a grid / on a line / on a corner), so the
* logbook row can show which grids the QSO went out under.
*/
suspend fun markUploaded(ids: List<Long>, grids: List<String> = emptyList())
/**
* Marks the batch as uploaded to Wavelog (independent of the LoTW upload state) and
* stamps the station profile (台址) the batch went out through, so the logbook's
* 台址 selector can group the records. An empty [stationId] leaves the stamp as-is.
*/
suspend fun markWavelogUploaded(ids: List<Long>, stationId: String = "")
suspend fun exportAdi(ids: Set<Long>? = null, includeIncomplete: Boolean = false): String suspend fun 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
suspend fun mergeLoTW(records: List<QsoRecord>): AdifImportResult suspend fun mergeLoTW(records: List<QsoRecord>): AdifImportResult
/**
* Merges QSO records pulled from Wavelog by a sync (the download direction):
* matches by the same stable identity as every other import, tags matched and
* freshly inserted rows with their Wavelog station profile, and marks them as
* already uploaded so a later upload never sends them straight back.
*/
suspend fun mergeWavelog(records: List<QsoRecord>): AdifImportResult
/** /**
* Folds LoTW confirmations that were imported as their own rows back into the local * Folds LoTW confirmations that were imported as their own rows back into the local
* record they belong to (see [com.rtbishop.look4sat.core.domain.logbook.splitConfirmationPairs]). * record they belong to (see [com.rtbishop.look4sat.core.domain.logbook.splitConfirmationPairs]).
@@ -27,6 +27,8 @@ fun GridQso.toConfirmedRecord(accountCallsign: String): QsoRecord = QsoRecord(
startUtcMillis = epochMs, startUtcMillis = epochMs,
theirCallsign = call, theirCallsign = call,
myCallsign = accountCallsign.trim().uppercase(), myCallsign = accountCallsign.trim().uppercase(),
theirGrid = theirGrids.firstOrNull().orEmpty(),
theirVuccGrids = theirGrids,
myGrid = (myGrids.firstOrNull() ?: myGrid).orEmpty(), myGrid = (myGrids.firstOrNull() ?: myGrid).orEmpty(),
vuccGrids = myGrids.toList(), vuccGrids = myGrids.toList(),
band = bandUp, band = bandUp,
@@ -31,6 +31,7 @@ fun LoTWProblem.label(): String = when (this) {
LoTWProblem.SATELLITE -> "satellite name not in ARRL's list" LoTWProblem.SATELLITE -> "satellite name not in ARRL's list"
LoTWProblem.MODE -> "mode not accepted by LoTW" LoTWProblem.MODE -> "mode not accepted by LoTW"
LoTWProblem.QSO_DATE -> "date outside the certificate" LoTWProblem.QSO_DATE -> "date outside the certificate"
LoTWProblem.QSO_FUTURE -> "record time is in the future"
LoTWProblem.CALLSIGN_MISMATCH -> "MY callsign does not match the certificate" LoTWProblem.CALLSIGN_MISMATCH -> "MY callsign does not match the certificate"
LoTWProblem.INVALID_CONTACT -> "invalid callsign/record" LoTWProblem.INVALID_CONTACT -> "invalid callsign/record"
LoTWProblem.STATION_GRID -> "station grid missing" LoTWProblem.STATION_GRID -> "station grid missing"
@@ -0,0 +1,20 @@
/*
* Look4Sat. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2019-2026 Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*/
package com.rtbishop.look4sat.core.domain.logbook
/**
* The records a resubmit actually re-signs: whatever the operator checked, complete rows only.
*
* Unlike a normal upload this deliberately keeps already-uploaded and confirmed records —
* the point is to send them again under a corrected station location, and LoTW treats an
* identical contact (call/band/mode/time/satellite) as an update of the existing record.
*/
fun resubmitCandidates(records: List<QsoRecord>, selectedIds: Set<Long>): List<QsoRecord> =
records.filter { it.id in selectedIds && it.status == QsoStatus.COMPLETE }
@@ -80,7 +80,7 @@ fun sameContactIdentity(local: QsoRecord, remote: QsoRecord): Boolean {
local.theirCallsign.trim().equals(remote.theirCallsign.trim(), true) && local.theirCallsign.trim().equals(remote.theirCallsign.trim(), true) &&
(local.myCallsign.isBlank() || remote.myCallsign.isBlank() || local.myCallsign.equals(remote.myCallsign, true)) && (local.myCallsign.isBlank() || remote.myCallsign.isBlank() || local.myCallsign.equals(remote.myCallsign, true)) &&
local.isSatellite == remote.isSatellite && local.isSatellite == remote.isSatellite &&
local.displayMode == remote.displayMode && local.matchableMode == remote.matchableMode &&
abs(local.startUtcMillis - remote.startUtcMillis) < 60_000L abs(local.startUtcMillis - remote.startUtcMillis) < 60_000L
} }
@@ -93,45 +93,100 @@ fun sameConfirmedContact(local: QsoRecord, remote: QsoRecord): Boolean =
sameContactIdentity(local, remote) && sameBandOrBlank(local.band, remote.band) sameContactIdentity(local, remote) && sameBandOrBlank(local.band, remote.band)
fun QsoRecord.confirmationLookupKey(): String = listOf( fun QsoRecord.confirmationLookupKey(): String = listOf(
theirCallsign.trim().uppercase(Locale.US), satelliteIdentity(satelliteName), displayMode theirCallsign.trim().uppercase(Locale.US), satelliteIdentity(satelliteName), matchableMode
).joinToString("|") ).joinToString("|")
/** A local row and the confirmation row that belongs to it, as indices into one list. */ /** A row to keep and the duplicate row to fold into it (then delete), as indices into one list. */
data class SplitConfirmationPair(val localIndex: Int, val confirmationIndex: Int) data class SplitConfirmationPair(val localIndex: Int, val confirmationIndex: Int)
/** /**
* Rows that a confirmation should have been folded into but was not, so they can be * Rows that should have been one row but were not, so they can be consolidated back into one.
* consolidated back into one.
* *
* Confirmations merged before the identity fix landed as their own rows: the tracker * Two known holes leave a contact stored twice:
* name vs ARRL name comparison failed, and the band direction of the report was read *
* mirrored (BAND_RX was taken as the uplink), so even satellites with matching names * 1. Confirmations merged before the identity fix landed as their own rows: the tracker name
* were stored twice. The pair is only accepted when the contact identity matches and * vs ARRL name comparison failed, and the band direction of the report was read mirrored
* the bands are either identical or exactly mirrored — the signature of that second * (BAND_RX taken as the uplink) — so an unconfirmed local row and its confirmation stayed
* mismatch — which keeps unrelated QSOs of the same operator and minute apart. * apart. Pass 1 folds the confirmation into the local row.
* 2. A contact can also end up with TWO confirmed rows: a mirrored import the old parser
* created plus the row that was confirmed on its own later, or a duplicate import added
* while ambiguous candidates made the match fail. Pass 1 cannot fold those — both sides
* are confirmed — so pass 2 pairs confirmed rows with each other.
*
* Both passes accept a pair only when the contact identity matches and the bands are either
* identical or exactly mirrored — the mirror being the fingerprint of the pre-fix parser —
* which keeps unrelated QSOs of the same operator and minute apart. In pass 2 the row with
* more content is kept (frequencies/grids/reports beat an empty mirrored import) and the
* other one is folded into it, so no data is lost.
*/ */
fun splitConfirmationPairs(records: List<QsoRecord>): List<SplitConfirmationPair> { fun splitConfirmationPairs(records: List<QsoRecord>): List<SplitConfirmationPair> {
val pendingIndices = records.indices.filter { !records[it].lotwConfirmed }
val taken = mutableSetOf<Int>() val taken = mutableSetOf<Int>()
return records.indices.filter { records[it].lotwConfirmed }.mapNotNull { confirmationIndex -> val pairs = mutableListOf<SplitConfirmationPair>()
// Pass 1: fold a downloaded confirmation into the unconfirmed local row it belongs to.
val pendingIndices = records.indices.filter { !records[it].lotwConfirmed }
records.indices.filter { records[it].lotwConfirmed }.forEach { confirmationIndex ->
val confirmation = records[confirmationIndex] val confirmation = records[confirmationIndex]
val localIndex = pendingIndices.firstOrNull { index -> val localIndex = pendingIndices.firstOrNull { index ->
index !in taken && sameContactIdentity(records[index], confirmation) && index !in taken && sameContactIdentity(records[index], confirmation) &&
bandsMatchOrMirror(records[index], confirmation) bandsMatchOrMirror(records[index], confirmation)
} ?: return@mapNotNull null } ?: return@forEach
taken += localIndex taken += localIndex
SplitConfirmationPair(localIndex, confirmationIndex) taken += confirmationIndex
pairs += SplitConfirmationPair(localIndex, confirmationIndex)
} }
// Pass 2: two CONFIRMED rows describing the same contact (mirrored import + the confirmed
// row it never folded into, or a duplicate import). Keep the richer row, drop the other.
val confirmedLeft = records.indices.filter { records[it].lotwConfirmed && it !in taken }
confirmedLeft.forEach { index ->
if (index in taken) return@forEach
val cluster = mutableListOf(index)
confirmedLeft.forEach { candidate ->
if (candidate != index && candidate !in taken &&
cluster.all {
sameContactIdentity(records[it], records[candidate]) &&
bandsMatchOrMirror(records[it], records[candidate])
}
) {
cluster += candidate
}
}
if (cluster.size < 2) return@forEach
cluster.forEach { taken += it }
val keep = cluster.maxBy { contentScore(records[it]) }
cluster.filter { it != keep }.forEach { pairs += SplitConfirmationPair(keep, it) }
}
return pairs
}
/** More operator/report detail first — decides which row survives a pass-2 fold. */
fun contentScore(record: QsoRecord): Int {
var score = 0
// Frequencies are the strongest operator-entered signal; a band label consistent with its
// frequency (frequencyBand(tx) == band) beats the mirrored parser output, whose labels
// contradict the side they describe.
record.txFrequencyHz?.let { hz -> score += 2; if (record.band == frequencyBand(hz)) score += 1 }
record.rxFrequencyHz?.let { hz -> score += 2; if (record.rxBand == frequencyBand(hz)) score += 1 }
score += listOf(record.sentReport, record.receivedReport, record.myGrid, record.theirGrid, record.comment)
.count { it.isNotBlank() }
score += record.vuccGrids.size
score += record.theirVuccGrids.size
if (record.lotwUploaded) score += 1
if (record.lotwReceived) score += 1
if (record.dxcc != null) score += 1
if (record.country.isNotBlank()) score += 1
if (record.lotwQslDate.isNotBlank()) score += 1
return score
} }
/** Same band on both sides, or the mirrored pair the pre-fix parser produced. */ /** Same band on both sides, or the mirrored pair the pre-fix parser produced. */
private fun bandsMatchOrMirror(local: QsoRecord, confirmation: QsoRecord): Boolean = fun bandsMatchOrMirror(local: QsoRecord, confirmation: QsoRecord): Boolean =
(sameBandOrBlank(local.band, confirmation.band) && sameBandOrBlank(local.rxBand, confirmation.rxBand)) || (sameBandOrBlank(local.band, confirmation.band) && sameBandOrBlank(local.rxBand, confirmation.rxBand)) ||
(sameBandOrBlank(local.band, confirmation.rxBand) && sameBandOrBlank(local.rxBand, confirmation.band)) (sameBandOrBlank(local.band, confirmation.rxBand) && sameBandOrBlank(local.rxBand, confirmation.band))
fun QsoRecord.withConfirmation(confirmed: QsoRecord): QsoRecord = copy( fun QsoRecord.withConfirmation(confirmed: QsoRecord): QsoRecord = copy(
myCallsign = myCallsign.ifBlank { confirmed.myCallsign }, myCallsign = myCallsign.ifBlank { confirmed.myCallsign },
theirGrid = confirmed.theirGrid.ifBlank { theirGrid }, theirGrid = confirmed.theirGrid.ifBlank { theirGrid },
theirVuccGrids = confirmed.theirVuccGrids.ifEmpty { theirVuccGrids },
myGrid = myGrid.ifBlank { confirmed.myGrid }, myGrid = myGrid.ifBlank { confirmed.myGrid },
sentReport = sentReport.ifBlank { confirmed.sentReport }, sentReport = sentReport.ifBlank { confirmed.sentReport },
receivedReport = receivedReport.ifBlank { confirmed.receivedReport }, receivedReport = receivedReport.ifBlank { confirmed.receivedReport },
@@ -159,6 +214,12 @@ val QsoRecord.displayMode: String
return label.trim().uppercase(Locale.US) return label.trim().uppercase(Locale.US)
} }
/** The mode label as the merge compares it: satellite FT4 is written as MFSK + FT4, and
* releases that dropped the sub-mode persisted plain MFSK — the same mode, spelled
* differently, so both spellings must match. */
val QsoRecord.matchableMode: String
get() = displayMode.let { if (it == "MFSK") "FT4" else it }
val QsoRecord.isSatellite: Boolean val QsoRecord.isSatellite: Boolean
get() = propagationMode.equals("SAT", true) || (propagationMode.isBlank() && satelliteName.isNotBlank()) get() = propagationMode.equals("SAT", true) || (propagationMode.isBlank() && satelliteName.isNotBlank())
@@ -18,6 +18,10 @@ data class QsoRecord(
val theirCallsign: String, val theirCallsign: String,
val myCallsign: String, val myCallsign: String,
val theirGrid: String = "", val theirGrid: String = "",
/** Every 4-char grid the OPPOSITE station logged this QSO under (ADIF
* <GRIDSQUARE> + <VUCC_GRIDS>; 1–4 grids). [theirGrid] keeps the first;
* the logbook's QSL slot shows the abbreviated set. */
val theirVuccGrids: List<String> = emptyList(),
val myGrid: String = "", val myGrid: String = "",
val sentReport: String = "", val sentReport: String = "",
val receivedReport: String = "", val receivedReport: String = "",
@@ -36,6 +40,12 @@ 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,
/** Wavelog station profile (台址) this QSO belongs to: the profile it was uploaded
* through, or the profile a sync pulled it from. Empty for records the app never
* involved with Wavelog — the logbook's 台址 selector shows those under "All" only. */
val wavelogStation: String = "",
val lotwReceived: Boolean = false, val 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"
@@ -81,52 +81,79 @@ object AwardCalculator {
) )
} }
private fun calculateDxcc(all: List<GridQso>): AwardProgress { /**
val keys = mutableSetOf<String>() * The award unit one QSO counts toward for [type] — the match key of the
for (q in all) { * award boundary assets' region "code" (WAPC -> "GD"/"HK", WAJA -> "34",
val code = q.dxcc ?: prefixEntityCode(q.call) * WAZ -> "24", WAS -> "CA", DXCC -> "318"). Null when the QSO does not
if (code != null) keys.add(code.toString()) * count toward that award (wrong entity or missing field). VUCC has no
* per-QSO key — its units are the store's grid keys — and returns null.
*
* Single source of truth shared by the progress counters above and the
* map's region-detail dialogs, so a tapped region lists exactly the QSOs
* that light it up.
*/
fun awardKey(type: AwardType, q: GridQso): String? = when (type) {
AwardType.DXCC -> (q.dxcc ?: prefixEntityCode(q.call))?.toString()
AwardType.WAPC -> wapcKey(q)
AwardType.WAJA -> if (entityOf(q) == Entity.JAPAN) {
q.state?.trim()?.takeIf { it.length == 2 && it.all(Char::isDigit) }
} else null
AwardType.WAZ -> q.cqz?.takeIf { it in 1..40 }?.toString()
AwardType.WAS -> if (entityOf(q) == Entity.USA) {
q.state?.trim()?.uppercase()?.takeIf { it in US_STATES }
} else null
AwardType.VUCC -> null
} }
/**
* Every confirmed QSO counted for award region [code] of [type], oldest
* first. The store keeps one entry per WORKED GRID, so a QSO spanning
* several grids arrives as several copies — [distinct] collapses them.
* Not scoped by 台址: the non-VUCC awards count (and fill the map)
* globally, so their detail lists do too.
*/
fun regionQsos(
qsosByGrid: Map<String, List<GridQso>>,
type: AwardType,
code: String
): List<GridQso> =
qsosByGrid.values.flatten()
.filter { awardKey(type, it) == code }
.distinct()
.sortedBy { it.epochMs }
private fun wapcKey(q: GridQso): String? = when (entityOf(q)) {
Entity.CHINA -> q.state?.trim()?.uppercase()?.takeIf { it in CN_STATES }
Entity.HONG_KONG -> HK_CODE
Entity.MACAO -> MO_CODE
Entity.TAIWAN -> TW_CODE
else -> null
}
private fun calculateDxcc(all: List<GridQso>): AwardProgress {
val keys = all.mapNotNull { awardKey(AwardType.DXCC, it) }.toSet()
return AwardProgress(AwardType.DXCC, keys, keys.size, AwardTargets.DXCC) return AwardProgress(AwardType.DXCC, keys, keys.size, AwardTargets.DXCC)
} }
private fun calculateWapc(all: List<GridQso>): AwardProgress { private fun calculateWapc(all: List<GridQso>): AwardProgress {
val keys = mutableSetOf<String>() val keys = all.mapNotNull { awardKey(AwardType.WAPC, it) }.toSet()
for (q in all) {
when (entityOf(q)) {
Entity.CHINA -> q.state?.trim()?.uppercase()?.takeIf { it in CN_STATES }?.let { keys.add(it) }
Entity.HONG_KONG -> keys.add(HK_CODE)
Entity.MACAO -> keys.add(MO_CODE)
Entity.TAIWAN -> keys.add(TW_CODE)
else -> {}
}
}
return AwardProgress(AwardType.WAPC, keys, keys.size, AwardTargets.WAPC) return AwardProgress(AwardType.WAPC, keys, keys.size, AwardTargets.WAPC)
} }
private fun calculateWaja(all: List<GridQso>): AwardProgress { private fun calculateWaja(all: List<GridQso>): AwardProgress {
val keys = mutableSetOf<String>() val keys = all.mapNotNull { awardKey(AwardType.WAJA, it) }.toSet()
for (q in all) {
if (entityOf(q) != Entity.JAPAN) continue
q.state?.trim()?.takeIf { it.length == 2 && it.all(Char::isDigit) }?.let { keys.add(it) }
}
return AwardProgress(AwardType.WAJA, keys, keys.size, AwardTargets.WAJA) return AwardProgress(AwardType.WAJA, keys, keys.size, AwardTargets.WAJA)
} }
private fun calculateWaz(all: List<GridQso>): AwardProgress { private fun calculateWaz(all: List<GridQso>): AwardProgress {
val keys = mutableSetOf<String>() val keys = all.mapNotNull { awardKey(AwardType.WAZ, it) }.toSet()
for (q in all) {
q.cqz?.takeIf { it in 1..40 }?.let { keys.add(it.toString()) }
}
return AwardProgress(AwardType.WAZ, keys, keys.size, AwardTargets.WAZ) return AwardProgress(AwardType.WAZ, keys, keys.size, AwardTargets.WAZ)
} }
private fun calculateWas(all: List<GridQso>): AwardProgress { private fun calculateWas(all: List<GridQso>): AwardProgress {
val keys = mutableSetOf<String>() val keys = all.mapNotNull { awardKey(AwardType.WAS, it) }.toSet()
for (q in all) {
if (entityOf(q) != Entity.USA) continue
q.state?.trim()?.uppercase()?.takeIf { it in US_STATES }?.let { keys.add(it) }
}
return AwardProgress(AwardType.WAS, keys, keys.size, AwardTargets.WAS) return AwardProgress(AwardType.WAS, keys, keys.size, AwardTargets.WAS)
} }
@@ -72,7 +72,13 @@ data class GridQso(
* MY_STATE + MY_CQ_ZONE + MY_ITU_ZONE + MY_IOTA + MY_COUNTRY), so the * MY_STATE + MY_CQ_ZONE + MY_ITU_ZONE + MY_IOTA + MY_COUNTRY), so the
* operated-grid selector can group QSOs per 台址 and show each 台址 with * operated-grid selector can group QSOs per 台址 and show each 台址 with
* its full grid set. Null for data synced before this field existed. */ * its full grid set. Null for data synced before this field existed. */
val stationKey: String? = null val stationKey: String? = null,
/** Every 4-char grid of the OPPOSITE station for this QSO — ADIF
* <GRIDSQUARE> plus every field of <VUCC_GRIDS> (comma-separated when
* the contact spans several squares; up to four in one QSO). The logbook
* shows the set in the QSL slot of a confirmed row; [myGrids] is the
* same thing for the user's own side. */
val theirGrids: List<String> = emptyList()
) { ) {
/** Short uplink/downlink band label ("U/V", "V/A"), or "" when unknown. */ /** Short uplink/downlink band label ("U/V", "V/A"), or "" when unknown. */
val bandLabel: String val bandLabel: String
@@ -98,3 +104,50 @@ 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 with
* COVERING semantics (2026-10-05): [stationId] is a grid-set key such as
* "OL62" or "OL62,OL63", and a record stays when its own [myStationGrids]
* COVER that key (record grid set ⊇ key set). So a multi-grid 台址 that
* covers a single-grid one (OL62/63 covers OL62) contributes its records to
* the single-grid scope's statistics — map fills, dialog lists and first-call
* labels all derive from this one rule. The reverse does NOT hold: records
* under a narrower 台址 never leak into a wider scope (single OL62 records
* stay out of the OL62/63 scope). Records without a MY-side grid belong to
* no 台址 and are dropped under any specific scope.
*/
fun List<GridQso>.scopedToStation(stationId: String?): List<GridQso> =
if (stationId == null) this
else {
val scope = stationId.split(',').toSet()
filter { it.myStationGrids().containsAll(scope) }
}
/**
* For every grid in the store, the callsign of the earliest QSO — scoped to the
* 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) */
@@ -102,11 +102,17 @@ data class DataSourcesSettings(
fun isTransceiverEnabled(index: Int): Boolean = transceiversEnabled.getOrElse(index) { true } fun isTransceiverEnabled(index: Int): Boolean = transceiversEnabled.getOrElse(index) { true }
} }
data class WavelogSettings( /** Wavelog configuration — QSO upload both ways; one of LoTW or Wavelog (mutually exclusive). */
data class WavelogUploadSettings(
val url: String = "", val url: String = "",
val token: String = "" val apiKey: String = "",
val stationId: String = "",
val stationName: String = "",
val stationCallsign: String = "",
val stationGrid: String = ""
) { ) {
val isConfigured: Boolean get() = url.isNotBlank() && token.isNotBlank() val isConfigured: Boolean get() = url.isNotBlank() && apiKey.isNotBlank()
val isReady: Boolean get() = isConfigured && stationId.isNotBlank()
} }
data class LoTWSettings( data class LoTWSettings(
@@ -37,17 +37,28 @@ 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 {
/** Days since 31 Dec 1979, the reference point every daynum in the app is based on. */
fun timeToDaynum(timeMillis: Long): Double = (timeMillis - 315446400000L) / 86400000.0
/**
* Days since 31 Dec 1979 for a TLE style YYDDD.ffffffff epoch. Raw epochs are not
* comparable across decades (a 1999 epoch reads as 99xxx, a 2026 one as 26xxx), so
* freshness checks must always go through this conversion.
*/
fun epochToDaynum(epoch: Double): Double {
var year = kotlin.math.floor(epoch * 1E-3) var year = kotlin.math.floor(epoch * 1E-3)
val day = (epoch * 1E-3 - year) * 1000.0 val day = (epoch * 1E-3 - year) * 1000.0
year = if (year < 57) year + 2000 else year + 1900 year = if (year < 57) year + 2000 else year + 1900
@@ -63,4 +74,5 @@ data class OrbitalData(
val i = kotlin.math.floor(365.25 * aYear).toLong() val i = kotlin.math.floor(365.25 * aYear).toLong()
return i + (30.6001 * 14).toLong() + 1720994.5 + b return i + (30.6001 * 14).toLong() + 1720994.5 + b
} }
}
} }
@@ -19,11 +19,15 @@
package com.rtbishop.look4sat.core.domain.repository package com.rtbishop.look4sat.core.domain.repository
import com.rtbishop.look4sat.core.domain.logbook.QsoRecord import com.rtbishop.look4sat.core.domain.logbook.QsoRecord
import java.util.Locale
interface ILoTWUploadRepository { interface ILoTWUploadRepository {
suspend fun certificate(): LoTWCertificate? suspend fun certificate(): LoTWCertificate?
suspend fun station(): LoTWStation? suspend fun station(): LoTWStation?
suspend fun importCertificate(data: ByteArray, password: CharArray): LoTWCertificate suspend fun importCertificate(data: ByteArray, password: CharArray): LoTWCertificate
/** Parse a .p12 without persisting it — enough to learn the certificate's DXCC
* entity so the station form can offer its region field before the import. */
suspend fun previewCertificate(data: ByteArray, password: CharArray): LoTWCertificate
suspend fun saveCertificatePassword(password: CharArray): LoTWCertificate suspend fun saveCertificatePassword(password: CharArray): LoTWCertificate
suspend fun saveStation(station: LoTWStation): LoTWStation suspend fun saveStation(station: LoTWStation): LoTWStation
suspend fun removeCertificate() suspend fun removeCertificate()
@@ -96,7 +100,23 @@ data class LoTWUploadPreview(
val duplicateSkipped: Int = 0, val duplicateSkipped: Int = 0,
/** Ids of the records that actually made it into this TQ8 batch. Only these /** Ids of the records that actually made it into this TQ8 batch. Only these
* may be marked "uploaded" after an accepted POST — never the full candidate list. */ * may be marked "uploaded" after an accepted POST — never the full candidate list. */
val submittedIds: List<Long> = emptyList() val submittedIds: List<Long> = emptyList(),
/** The full station-location grid set this batch is signed with (1 inside a grid,
* 2 on a line, 4 on a corner). Stamped onto the uploaded records so the logbook
* can show which gridsquares the QSO went out under. */
val grids: List<String> = emptyList(),
/** True when this batch is a resubmit: already-uploaded/confirmed records were allowed
* through so the corrected station location reaches LoTW as an update of the contact. */
val resubmit: Boolean = false
)
/** Pre-upload location check: the freshest known position grid is not covered by the
* station grids an upload would be signed with. */
data class LoTWPositionWarning(
/** Normalized 4-character grid of the freshest known position. */
val currentGrid: String,
/** Distinct station-location grids the upload would be signed with. */
val stationGrids: List<String>
) )
/** Why the un-uploadable records of a selection cannot be signed, and what was left out. */ /** Why the un-uploadable records of a selection cannot be signed, and what was left out. */
@@ -128,7 +148,25 @@ class LoTWOperationException(val reason: LoTWProblem, val detail: String = "") :
enum class LoTWProblem { enum class LoTWProblem {
CERTIFICATE_PASSWORD, CERTIFICATE_INVALID, CERTIFICATE_EXPIRED, CERTIFICATE_MISSING, CERTIFICATE_PASSWORD, CERTIFICATE_INVALID, CERTIFICATE_EXPIRED, CERTIFICATE_MISSING,
CERTIFICATE_FORMAT, STORAGE, EMPTY_SELECTION, CALLSIGN_MISMATCH, QSO_DATE, STATION_GRID, CERTIFICATE_FORMAT, STORAGE, EMPTY_SELECTION, CALLSIGN_MISMATCH, QSO_DATE, QSO_FUTURE, STATION_GRID,
STATION_REGION, STATION_ZONE, STATION_IOTA, MODE, BAND, SATELLITE, INVALID_CONTACT, STATION_REGION, STATION_ZONE, STATION_IOTA, MODE, BAND, SATELLITE, INVALID_CONTACT,
LOCATION_MISMATCH, TOO_MANY_CONTACTS LOCATION_MISMATCH, TOO_MANY_CONTACTS
} }
/**
* Roaming check run before upload: warns when the operator's freshest known position
* (see [ISettingsRepo.getCurrentGrid]) lies outside every grid of the station location the
* batch would be signed with — the signature of operating while roaming with a station
* location that was never updated. An unknown position, missing station grids, or a covered
* position (boundary operations included) produce no warning.
*/
fun positionWarning(currentGrid: String?, stationGrids: List<String>): LoTWPositionWarning? {
val current = currentGrid?.grid4() ?: return null
val station = stationGrids.mapNotNull { it.grid4() }.toSet()
if (station.isEmpty()) return null
if (current in station) return null
return LoTWPositionWarning(current, station.sorted())
}
/** Normalized 4-character grid, null when the value is not a usable grid. */
private fun String.grid4(): String? = trim().uppercase(Locale.US).take(4).takeIf { it.length >= 4 }
@@ -33,7 +33,8 @@ interface IMainContainer {
val databaseRepo: IDatabaseRepo val databaseRepo: IDatabaseRepo
val amSatRepo: IAmSatRepository val amSatRepo: IAmSatRepository
val updateRepo: IUpdateRepository val updateRepo: IUpdateRepository
val wavelogRepo: IWavelogRepository val wavelogUploadRepository: IWavelogUploadRepository
val wavelogSyncRepository: IWavelogSyncRepository
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
@@ -23,7 +23,8 @@ import com.rtbishop.look4sat.core.domain.model.OtherSettings
import com.rtbishop.look4sat.core.domain.model.PassesSettings 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.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
@@ -48,6 +49,12 @@ interface ISettingsRepo {
fun setStationPosition(latitude: Double, longitude: Double, altitude: Double): Boolean fun setStationPosition(latitude: Double, longitude: Double, altitude: Double): Boolean
fun setStationPosition(): Boolean fun setStationPosition(): Boolean
fun setStationPosition(locator: String): Boolean fun setStationPosition(locator: String): Boolean
/**
* Freshest known position grid for the roaming check, read-only: the newer of a fresh
* (≤ 24 h) system location fix and the stored station position. Null only when neither
* yields a usable locator. Never writes the station position.
*/
fun getCurrentGrid(): String?
//endregion //endregion
//region # Database update settings //region # Database update settings
@@ -100,9 +107,7 @@ interface ISettingsRepo {
fun setAmSatCallsign(callsign: String) fun setAmSatCallsign(callsign: String)
//endregion //endregion
//region # Wavelog worked-grids settings //region # Worked-grid data (written by the LoTW / Wavelog syncs)
val wavelogSettings: StateFlow<WavelogSettings>
fun updateWavelogSettings(settings: WavelogSettings)
fun getWorkedGrids(): Set<String> fun getWorkedGrids(): Set<String>
fun setWorkedGrids(grids: Set<String>) fun setWorkedGrids(grids: Set<String>)
/** Confirmed satellite QSOs grouped by worked 4-char gridsquare (map tap detail). */ /** Confirmed satellite QSOs grouped by worked 4-char gridsquare (map tap detail). */
@@ -118,6 +123,26 @@ 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 # Wavelog sync bookkeeping
/** Station profiles as last reported by the server — labels for the 台址 selector and targets for sync. */
fun getWavelogStations(): List<WavelogStationInfo>
fun setWavelogStations(stations: List<WavelogStationInfo>)
/** stationId -> lastfetchedid cursors of the last successful sync (per station profile). */
fun getWavelogSyncCursors(): Map<String, Long>
fun setWavelogSyncCursors(cursors: Map<String, Long>)
/** Wavelog base URL the stored cursors belong to (empty when never synced). */
fun getWavelogSyncUrl(): String
fun setWavelogSyncUrl(url: String)
/** Epoch ms of the last successful Wavelog sync (0 when never synced). */
fun getLastWavelogSyncEpochMs(): Long
fun setLastWavelogSyncEpochMs(value: Long)
//endregion
//region # LoTW confirmed-grids settings //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)
@@ -1,28 +0,0 @@
/*
* Look4Sat. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2019-2026 Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.core.domain.repository
/** Fetches worked gridsquares from a self-hosted Wavelog instance (API v2). */
interface IWavelogRepository {
/**
* Fetch worked grids for the configured Wavelog URL/token.
* Returns the grid set, or null on any failure.
*/
suspend fun fetchWorkedGrids(url: String, token: String): Set<String>?
}
@@ -0,0 +1,57 @@
/*
* Look4Sat-BA7OPF. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2026 BA7OPF.
* Based on Look4Sat by Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.core.domain.repository
import com.rtbishop.look4sat.core.domain.logbook.QsoRecord
/** What a Wavelog sync pulls: everything, or only records newer than the stored cursors. */
enum class WavelogSyncMode { Full, Incremental }
/** Outcome of one sync fetch. */
data class WavelogSyncFetch(
/** Records pulled this run, each tagged with the station profile it came from. */
val records: List<QsoRecord>,
/** stationId -> lastfetchedid to resume from on the next incremental run. */
val cursors: Map<String, Long>,
/** Station ids that could not be fetched (transport/parse); empty when all succeeded. */
val failedStations: List<String>
)
/**
* Pulls QSO records from a self-hosted Wavelog (v1 API, `api/get_contacts_adif`).
* One request per station profile; the server exports only rows newer than the
* `fetchfromid` cursor, so repeated syncs stay incremental per station.
*/
interface IWavelogSyncRepository {
/**
* Fetches records for every entry of [stations]. [cursors] maps stationId to the
* last id already synced (missing -> 0); [full] re-pulls each station from the
* beginning, ignoring the cursors. Returns null when the fetch cannot run at all
* (blank url/key or no stations); individual station failures land in
* [WavelogSyncFetch.failedStations] with the remaining stations still fetched.
*/
suspend fun fetchNewRecords(
url: String,
apiKey: String,
stations: List<WavelogStationInfo>,
cursors: Map<String, Long>,
full: Boolean
): WavelogSyncFetch?
}
@@ -0,0 +1,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
}
@@ -0,0 +1,137 @@
/*
* Look4Sat-BA7OPF. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2026 BA7OPF.
* Based on Look4Sat by Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.core.domain.repository
import com.rtbishop.look4sat.core.domain.logbook.QsoRecord
import com.rtbishop.look4sat.core.domain.model.GridQso
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.util.Locale
/** Trimmed base form of a Wavelog server URL (".../wavelog/" -> ".../wavelog"). */
fun normalizeWavelogUrl(url: String): String = url.trim().trimEnd('/')
/**
* Resolves the effective sync mode for a request:
* - an explicit [WavelogSyncMode.Full] is always honored;
* - an [WavelogSyncMode.Incremental] request is honored only when the stored sync URL
* matches the current one — a first sync or a server switch falls back to
* [WavelogSyncMode.Full], so cursors from another server never leak in.
*/
fun resolveWavelogSyncMode(storedUrl: String, currentUrl: String, requested: WavelogSyncMode?): WavelogSyncMode {
if (requested == WavelogSyncMode.Full) return WavelogSyncMode.Full
val sameServer = storedUrl.isNotBlank() && storedUrl == normalizeWavelogUrl(currentUrl)
return if (sameServer) WavelogSyncMode.Incremental else WavelogSyncMode.Full
}
/** Grid data derived from Wavelog-synced records, in the same shape the LoTW sync produces. */
data class WavelogGridData(
val grids: Set<String>,
val gridQsos: Map<String, List<GridQso>>,
val roamedGrids: Set<String>
)
internal fun isValidGrid4(grid: String): Boolean =
grid.length == 4 && grid[0] in 'A'..'R' && grid[1] in 'A'..'R' &&
grid[2] in '0'..'9' && grid[3] in '0'..'9'
/**
* Derives the worked-grid set, the per-grid QSO detail and the roamed (own) grids from
* records pulled by a Wavelog sync — mirroring what the LoTW report parser produces, so
* both sync sources feed the map's highlight and its 台址 selector the same data shape.
*/
fun buildWavelogGridData(records: List<QsoRecord>): WavelogGridData {
val grids = linkedSetOf<String>()
val roamed = linkedSetOf<String>()
val gridQsos = linkedMapOf<String, MutableList<GridQso>>()
records.forEach { record ->
val isSatellite = record.satelliteName.isNotBlank() || record.propagationMode.equals("SAT", true)
if (!isSatellite) return@forEach
val theirGrids = (record.theirVuccGrids.ifEmpty { listOf(record.theirGrid) })
.map { it.trim().uppercase(Locale.US).take(4) }
.filter(::isValidGrid4)
.distinct()
if (theirGrids.isEmpty()) return@forEach
val myGrids = (record.vuccGrids.ifEmpty { listOf(record.myGrid) })
.map { it.trim().uppercase(Locale.US).take(4) }
.filter(::isValidGrid4)
.toSet()
roamed += myGrids
val qso = GridQso(
call = record.theirCallsign,
epochMs = record.startUtcMillis,
satName = record.satelliteName,
mode = record.mode,
bandUp = record.band,
bandDown = record.rxBand,
dxcc = record.dxcc,
country = record.country.ifBlank { null },
cqz = record.cqZone,
state = record.region.ifBlank { null },
myGrid = myGrids.sorted().firstOrNull(),
myGrids = myGrids,
myCallsign = record.myCallsign.ifBlank { null },
theirGrids = theirGrids
)
theirGrids.forEach { grid ->
grids += grid
gridQsos.getOrPut(grid) { mutableListOf() }.add(qso)
}
}
return WavelogGridData(grids, gridQsos, roamed)
}
/**
* Applies a successful Wavelog sync to the stored map data and sync bookkeeping.
*
* Grids, per-grid QSO detail and roamed grids only ever GROW (union): both the
* incremental and the full re-pull merge into the stored sets, matching how the LoTW
* sync treats stale data — a full re-pull heals missing records but never deletes map
* data the app already knows about. Cursors are replaced wholesale ([cursors] covers
* every station just fetched), and the sync URL/epoch advance so the next run resumes
* incrementally. Persistence runs on the IO dispatcher — building the per-grid QSO
* JSON blocks the caller, and for large accounts that froze the main thread after a
* manual sync.
*
* @return the resulting number of worked grids.
*/
suspend fun applyWavelogSyncResult(
settingsRepo: ISettingsRepo,
records: List<QsoRecord>,
cursors: Map<String, Long>,
url: String,
now: Long = System.currentTimeMillis()
): Int {
val data = buildWavelogGridData(records)
val existingGrids = settingsRepo.getWorkedGrids()
val existingQsos = settingsRepo.getWorkedGridQsos()
val existingRoamed = settingsRepo.getRoamedGrids()
val mergedGrids = existingGrids + data.grids
val mergedQsos = mergeGridQsos(existingQsos, data.gridQsos)
val mergedRoamed = existingRoamed + data.roamedGrids
settingsRepo.setWavelogSyncCursors(cursors)
settingsRepo.setWavelogSyncUrl(normalizeWavelogUrl(url))
settingsRepo.setLastWavelogSyncEpochMs(now)
withContext(Dispatchers.IO) {
settingsRepo.setWorkedGrids(mergedGrids)
settingsRepo.setWorkedGridQsos(mergedQsos)
settingsRepo.setRoamedGrids(mergedRoamed)
}
return mergedGrids.size
}
@@ -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"))
}
} }
@@ -40,7 +40,8 @@ class LogbookMergeTest {
propagationMode = "SAT", propagationMode = "SAT",
status = QsoStatus.COMPLETE, status = QsoStatus.COMPLETE,
lotwConfirmed = true, lotwConfirmed = true,
vuccGrids = listOf("OM60", "OM50") vuccGrids = listOf("OM60", "OM50"),
theirVuccGrids = listOf("EN52", "EN53")
) )
@Test @Test
@@ -73,12 +74,24 @@ class LogbookMergeTest {
assertEquals(local.confirmationLookupKey(), confirmed.confirmationLookupKey()) assertEquals(local.confirmationLookupKey(), confirmed.confirmationLookupKey())
} }
@Test
fun confirmationLookupKey_groupsMfskAndFt4Spellings() {
// Satellite FT4 is MFSK + FT4; a row persisted by an older release as plain MFSK is
// the same mode and must resolve to the same contact.
val plainMfsk = confirmed.copy(mode = "MFSK", submode = "")
val ft4 = confirmed.copy(mode = "MFSK", submode = "FT4")
assertEquals(plainMfsk.confirmationLookupKey(), ft4.confirmationLookupKey())
assertTrue(sameContactIdentity(plainMfsk, ft4))
}
@Test @Test
fun withConfirmation_marksConfirmedAndMergesVuccGrids() { fun withConfirmation_marksConfirmedAndMergesVuccGrids() {
val merged = local.withConfirmation(confirmed) val merged = local.withConfirmation(confirmed)
assertTrue(merged.lotwConfirmed) assertTrue(merged.lotwConfirmed)
assertTrue(merged.lotwReceived) assertTrue(merged.lotwReceived)
assertEquals(listOf("OM60", "OM50"), merged.vuccGrids) assertEquals(listOf("OM60", "OM50"), merged.vuccGrids)
// The opposite station's grid set arrives with the confirmation too.
assertEquals(listOf("EN52", "EN53"), merged.theirVuccGrids)
// Local myCallsign is kept when present; only blanks are backfilled. // Local myCallsign is kept when present; only blanks are backfilled.
assertEquals("ba7opf", merged.myCallsign) assertEquals("ba7opf", merged.myCallsign)
} }
@@ -97,13 +110,16 @@ class LogbookMergeTest {
cqz = 24, cqz = 24,
state = "GD", state = "GD",
myGrid = "OM60", myGrid = "OM60",
myGrids = setOf("OM60", "OM50") myGrids = setOf("OM60", "OM50"),
theirGrids = listOf("EN52", "EN53")
) )
val record = qso.toConfirmedRecord("ba7opf") val record = qso.toConfirmedRecord("ba7opf")
assertEquals("BH6RJD", record.theirCallsign) assertEquals("BH6RJD", record.theirCallsign)
assertEquals("BA7OPF", record.myCallsign) assertEquals("BA7OPF", record.myCallsign)
assertEquals("OM60", record.myGrid) assertEquals("OM60", record.myGrid)
assertEquals(listOf("OM60", "OM50"), record.vuccGrids) assertEquals(listOf("OM60", "OM50"), record.vuccGrids)
assertEquals("EN52", record.theirGrid)
assertEquals(listOf("EN52", "EN53"), record.theirVuccGrids)
assertTrue(record.lotwConfirmed) assertTrue(record.lotwConfirmed)
assertTrue(record.isSatellite) assertTrue(record.isSatellite)
assertEquals("70CM", record.band) assertEquals("70CM", record.band)
@@ -198,6 +214,30 @@ class LogbookMergeTest {
assertEquals(1, pairs.first().confirmationIndex) assertEquals(1, pairs.first().confirmationIndex)
} }
@Test
fun splitConfirmationPairs_foldsTwoConfirmedRowsAndKeepsTheRicher() {
// A contact stored twice with BOTH sides confirmed: the mirrored import the old parser
// created (no frequencies, report-only fills) plus the app row that was confirmed on
// its own later (frequencies, upload state). Pass 1 cannot fold those; without pass 2
// they stay doubled in the logbook forever.
val mirroredImport = reportedRecord("SO-50", band = "70CM", rxBand = "2M").copy(id = 1L)
val confirmedLocal = loggedRecord("SAUDISAT 1C", 145_850_000L, 436_795_000L)
.copy(id = 2L, lotwConfirmed = true)
val pairs = splitConfirmationPairs(listOf(mirroredImport, confirmedLocal))
assertEquals(1, pairs.size)
// The richer row (frequencies + consistent bands + upload state) is the survivor.
assertEquals(1, pairs.first().localIndex)
assertEquals(0, pairs.first().confirmationIndex)
}
@Test
fun splitConfirmationPairs_keepsTwoConfirmedRowsOfDifferentMinutesApart() {
val one = reportedRecord("SO-50", band = "70CM", rxBand = "2M").copy(id = 1L)
val otherMinute = reportedRecord("SO-50", band = "2M", rxBand = "70CM")
.copy(id = 2L, startUtcMillis = REPORTED_START + 90_000L)
assertTrue(splitConfirmationPairs(listOf(one, otherMinute)).isEmpty())
}
@Test @Test
fun splitConfirmationPairs_keepsUnrelatedRowsApart() { fun splitConfirmationPairs_keepsUnrelatedRowsApart() {
val uploaded = loggedRecord("SAUDISAT 1C", 145_850_000L, 436_795_000L) val uploaded = loggedRecord("SAUDISAT 1C", 145_850_000L, 436_795_000L)
@@ -0,0 +1,61 @@
/*
* Look4Sat. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2019-2026 Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*/
package com.rtbishop.look4sat.core.domain.logbook
import org.junit.Assert.assertEquals
import org.junit.Test
class LogbookResubmitTest {
private fun record(
id: Long,
status: QsoStatus = QsoStatus.COMPLETE,
uploaded: Boolean = false,
confirmed: Boolean = false
) = QsoRecord(
id = id,
startUtcMillis = 1_760_000_000_000L + id,
theirCallsign = "BG5JSB",
myCallsign = "BA7OPF",
mode = "FM",
satelliteName = "SO-50",
status = status,
lotwUploaded = uploaded,
lotwConfirmed = confirmed
)
@Test
fun keepsUploadedAndConfirmedRecords() {
// The whole point of a resubmit: rows already on LoTW must not be filtered out.
val records = listOf(record(1, uploaded = true), record(2, confirmed = true), record(3))
assertEquals(listOf(1L, 2L, 3L), resubmitCandidates(records, setOf(1, 2, 3)).map { it.id })
}
@Test
fun dropsRecordsOutsideTheSelection() {
val records = listOf(record(1), record(2), record(3))
assertEquals(listOf(2L), resubmitCandidates(records, setOf(2)).map { it.id })
}
@Test
fun dropsIncompleteRecords() {
val records = listOf(
record(1, status = QsoStatus.DRAFT),
record(2, status = QsoStatus.ABORTED),
record(3, status = QsoStatus.COMPLETE)
)
assertEquals(listOf(3L), resubmitCandidates(records, setOf(1, 2, 3)).map { it.id })
}
@Test
fun emptySelectionYieldsNothing() {
assertEquals(emptyList<Long>(), resubmitCandidates(listOf(record(1)), emptySet()).map { it.id })
}
}
@@ -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()
)
}
}
@@ -152,4 +152,49 @@ class AwardCalculatorTest {
assertEquals(40, progress.first { it.type == AwardType.WAZ }.target) assertEquals(40, progress.first { it.type == AwardType.WAZ }.target)
assertEquals(50, progress.first { it.type == AwardType.WAS }.target) assertEquals(50, progress.first { it.type == AwardType.WAS }.target)
} }
@Test
fun `award key matches the region codes for every award type`() {
val cn = qso("BG7XYZ", dxcc = 318, cqz = 24, state = "GD")
assertEquals("318", AwardCalculator.awardKey(AwardType.DXCC, cn))
assertEquals("GD", AwardCalculator.awardKey(AwardType.WAPC, cn))
assertEquals("24", AwardCalculator.awardKey(AwardType.WAZ, cn))
// DXCC code falls back to a prefix guess when LoTW omitted the field
assertEquals("152", AwardCalculator.awardKey(AwardType.DXCC, qso("XX9A")))
assertEquals(null, AwardCalculator.awardKey(AwardType.DXCC, qso("ZS1ABC")))
// WAPC special entities
assertEquals("HK", AwardCalculator.awardKey(AwardType.WAPC, qso("VR2X", dxcc = 321)))
assertEquals("MO", AwardCalculator.awardKey(AwardType.WAPC, qso("XX9A", dxcc = 152)))
assertEquals("TW", AwardCalculator.awardKey(AwardType.WAPC, qso("BV2A", dxcc = 386)))
// WAJA: only Japanese prefecture numbers count
assertEquals("34", AwardCalculator.awardKey(AwardType.WAJA, qso("JH0ABC", dxcc = 339, state = "34")))
assertEquals(null, AwardCalculator.awardKey(AwardType.WAJA, qso("BG7XYZ", dxcc = 318, state = "34")))
assertEquals(null, AwardCalculator.awardKey(AwardType.WAJA, qso("JH0ABC", dxcc = 339, state = "GD")))
// WAS: only US states count
assertEquals("CA", AwardCalculator.awardKey(AwardType.WAS, qso("W1AW", dxcc = 291, state = "CA")))
assertEquals(null, AwardCalculator.awardKey(AwardType.WAS, qso("BG7XYZ", dxcc = 318, state = "CA")))
// out-of-range CQ zone and VUCC have no key
assertEquals(null, AwardCalculator.awardKey(AwardType.WAZ, qso("BG7XYZ", cqz = 41)))
assertEquals(null, AwardCalculator.awardKey(AwardType.VUCC, cn))
}
@Test
fun `region qsos dedupe multi-grid copies and sort oldest first`() {
val older = GridQso(
call = "BA7AAA", epochMs = 100L, satName = "SO-50", mode = "FM",
bandUp = "2M", bandDown = "70CM", dxcc = 318
)
val newer = GridQso(
call = "BA7BBB", epochMs = 200L, satName = "SO-50", mode = "FM",
bandUp = "2M", bandDown = "70CM", dxcc = 318
)
val store = mapOf(
"OL62" to listOf(newer, older),
"PM95" to listOf(older), // same QSO recorded under a second worked grid
"OL62,PM01" to listOf(qso("W1AW", dxcc = 291))
)
val result = AwardCalculator.regionQsos(store, AwardType.DXCC, "318")
assertEquals(listOf(older, newer), result)
assertEquals(emptyList<GridQso>(), AwardCalculator.regionQsos(store, AwardType.DXCC, "292"))
}
} }
@@ -0,0 +1,148 @@
/*
* 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 covering station's records too`() {
// 方案 A 覆盖语义: 选中单格台址 OL62 时,覆盖它的多格台址 (OL62,PM01)
// 的通联也计入;反向不泄漏。
val multi = qso("A", myGrids = setOf("OL62", "PM01"))
val single = qso("B", myGrid = "OL62")
val list = listOf(multi, single)
assertEquals(listOf(multi), list.scopedToStation("OL62,PM01"))
assertEquals(listOf(multi, single), list.scopedToStation("OL62"))
}
@Test
fun `narrower station never leaks into a wider scope`() {
val single = qso("B", myGrid = "OL62")
val pair = qso("A", myGrids = setOf("OL62", "PM01"))
// 宽台址 (OL62,PM01) 选中时,单格 OL62 的记录不出现
assertEquals(emptyList<GridQso>(), listOf(single, pair).scopedToStation("OL62,PM01").filter { it === single })
assertEquals(listOf(pair), listOf(single, pair).scopedToStation("OL62,PM01"))
}
@Test
fun `covering is per-cell set containment not string equality`() {
// 多格台址的 key 是排序拼接,覆盖判定必须按网格集合而非字符串
val quad = qso("A", myGrids = setOf("OL62", "OL63", "OL64", "OL65"))
assertEquals(listOf(quad), listOf(quad).scopedToStation("OL63"))
assertEquals(listOf(quad), listOf(quad).scopedToStation("OL62,OL65"))
assertEquals(emptyList<GridQso>(), listOf(quad).scopedToStation("OL62,PM01"))
}
@Test
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"))
}
}
@@ -26,7 +26,8 @@ import com.rtbishop.look4sat.core.domain.model.OtherSettings
import com.rtbishop.look4sat.core.domain.model.PassesSettings 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.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
@@ -247,7 +248,8 @@ class LoTWGridSyncTest {
override val radioControlSettings: StateFlow<RadioControlSettings> = MutableStateFlow( override val radioControlSettings: StateFlow<RadioControlSettings> = MutableStateFlow(
RadioControlSettings(false, RadioControlSettings.MODEL_YAESU_FT817, "", "", "", "", 9600) RadioControlSettings(false, RadioControlSettings.MODEL_YAESU_FT817, "", "", "", "", 9600)
) )
override val wavelogSettings: StateFlow<WavelogSettings> = MutableStateFlow(WavelogSettings())
override val wavelogUploadSettings: StateFlow<WavelogUploadSettings> = MutableStateFlow(WavelogUploadSettings())
override val lotwSettings: StateFlow<LoTWSettings> = MutableStateFlow(LoTWSettings()) override val lotwSettings: StateFlow<LoTWSettings> = MutableStateFlow(LoTWSettings())
private var workedGrids: Set<String> = emptySet() private var workedGrids: Set<String> = emptySet()
@@ -270,12 +272,26 @@ class LoTWGridSyncTest {
override fun getLastLotwSyncCallsign(): String = lastSyncCallsign override fun getLastLotwSyncCallsign(): String = lastSyncCallsign
override fun setLastLotwSyncCallsign(callsign: String) { lastSyncCallsign = callsign } override fun setLastLotwSyncCallsign(callsign: String) { lastSyncCallsign = callsign }
private var wavelogStations: List<com.rtbishop.look4sat.core.domain.repository.WavelogStationInfo> = emptyList()
private var wavelogSyncCursors: Map<String, Long> = emptyMap()
private var wavelogSyncUrl: String = ""
private var lastWavelogSyncEpochMs: Long = 0L
override fun getWavelogStations(): List<com.rtbishop.look4sat.core.domain.repository.WavelogStationInfo> = wavelogStations
override fun setWavelogStations(stations: List<com.rtbishop.look4sat.core.domain.repository.WavelogStationInfo>) { wavelogStations = stations }
override fun getWavelogSyncCursors(): Map<String, Long> = wavelogSyncCursors
override fun setWavelogSyncCursors(cursors: Map<String, Long>) { wavelogSyncCursors = cursors }
override fun getWavelogSyncUrl(): String = wavelogSyncUrl
override fun setWavelogSyncUrl(url: String) { wavelogSyncUrl = url }
override fun getLastWavelogSyncEpochMs(): Long = lastWavelogSyncEpochMs
override fun setLastWavelogSyncEpochMs(value: Long) { lastWavelogSyncEpochMs = value }
override fun setSelectedIds(ids: List<Int>) = TODO() override fun setSelectedIds(ids: List<Int>) = TODO()
override fun setSelectedTypes(types: List<String>) = TODO() override fun setSelectedTypes(types: List<String>) = TODO()
override fun setPassesSettings(settings: PassesSettings) = TODO() override fun setPassesSettings(settings: PassesSettings) = TODO()
override fun setStationPosition(latitude: Double, longitude: Double, altitude: Double): Boolean = TODO() override fun setStationPosition(latitude: Double, longitude: Double, altitude: Double): Boolean = TODO()
override fun setStationPosition(): Boolean = TODO() override fun setStationPosition(): Boolean = TODO()
override fun setStationPosition(locator: String): Boolean = TODO() override fun setStationPosition(locator: String): Boolean = TODO()
override fun getCurrentGrid(): String? = TODO()
override fun getSatelliteTypesIds(types: List<String>): List<Int> = TODO() override fun getSatelliteTypesIds(types: List<String>): List<Int> = TODO()
override fun setSatelliteTypeIds(type: String, ids: List<Int>) = TODO() override fun setSatelliteTypeIds(type: String, ids: List<Int>) = TODO()
override fun updateDatabaseState(state: DatabaseState) = TODO() override fun updateDatabaseState(state: DatabaseState) = TODO()
@@ -290,7 +306,8 @@ class LoTWGridSyncTest {
override fun setSatelliteOffset(catnum: Int, offset: String) = TODO() override fun setSatelliteOffset(catnum: Int, offset: String) = TODO()
override fun getAmSatCallsign(): String = "" override fun getAmSatCallsign(): String = ""
override fun setAmSatCallsign(callsign: String) = TODO() override fun setAmSatCallsign(callsign: String) = TODO()
override fun updateWavelogSettings(settings: WavelogSettings) = TODO()
override fun updateWavelogUploadSettings(settings: WavelogUploadSettings) = TODO()
override fun updateLoTWSettings(settings: LoTWSettings) = TODO() override fun updateLoTWSettings(settings: LoTWSettings) = TODO()
} }
} }
@@ -0,0 +1,56 @@
/*
* Look4Sat. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2019-2026 Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.core.domain.repository
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
class LoTWPositionWarningTest {
@Test
fun coveredPositionProducesNoWarning() {
assertNull(positionWarning("OL62aa", listOf("OL62")))
}
@Test
fun positionInsideAnyStationGridIsCovered() {
assertNull(positionWarning("OL61", listOf("OL61", "OL62", "OM60", "OM61")))
}
@Test
fun positionOutsideTheStationWarns() {
val warning = positionWarning("om91", listOf("OL62"))
assertEquals("OM91", warning?.currentGrid)
assertEquals(listOf("OL62"), warning?.stationGrids)
}
@Test
fun stationGridsAreNormalizedForComparison() {
assertNull(positionWarning("OL62", listOf("ol62aa")))
assertNull(positionWarning("ol62", listOf("OL62")))
}
@Test
fun unknownPositionOrStationProducesNoWarning() {
assertNull(positionWarning(null, listOf("OL62")))
assertNull(positionWarning("", listOf("OL62")))
assertNull(positionWarning("OM91", emptyList()))
assertNull(positionWarning("OM91", listOf("")))
}
}
@@ -0,0 +1,259 @@
/*
* Look4Sat-BA7OPF. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2026 BA7OPF.
* Based on Look4Sat by Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.core.domain.repository
import com.rtbishop.look4sat.core.domain.logbook.QsoRecord
import com.rtbishop.look4sat.core.domain.logbook.QsoStatus
import com.rtbishop.look4sat.core.domain.model.DataSourcesSettings
import com.rtbishop.look4sat.core.domain.model.DatabaseState
import com.rtbishop.look4sat.core.domain.model.GridQso
import com.rtbishop.look4sat.core.domain.model.LoTWSettings
import com.rtbishop.look4sat.core.domain.model.MarkedStation
import com.rtbishop.look4sat.core.domain.model.OtherSettings
import com.rtbishop.look4sat.core.domain.model.PassesSettings
import com.rtbishop.look4sat.core.domain.model.RCSettings
import com.rtbishop.look4sat.core.domain.model.RadioControlSettings
import com.rtbishop.look4sat.core.domain.model.WavelogUploadSettings
import com.rtbishop.look4sat.core.domain.predict.GeoPos
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.runBlocking
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
class WavelogSyncTest {
// region # resolveWavelogSyncMode
@Test
fun fullRequestIsAlwaysHonored() {
assertEquals(WavelogSyncMode.Full, resolveWavelogSyncMode("", "http://a", WavelogSyncMode.Full))
assertEquals(WavelogSyncMode.Full, resolveWavelogSyncMode("http://a", "http://a", WavelogSyncMode.Full))
}
@Test
fun incrementalIsHonoredOnlyOnTheSameServer() {
assertEquals(
WavelogSyncMode.Incremental,
resolveWavelogSyncMode("http://a", "http://a/", WavelogSyncMode.Incremental)
)
assertEquals(WavelogSyncMode.Full, resolveWavelogSyncMode("", "http://a", WavelogSyncMode.Incremental))
assertEquals(
WavelogSyncMode.Full,
resolveWavelogSyncMode("http://old", "http://a", WavelogSyncMode.Incremental)
)
}
@Test
fun urlNormalizationTrimsWhitespaceAndSlashes() {
assertEquals("http://a/wavelog", normalizeWavelogUrl(" http://a/wavelog/ "))
}
// endregion
// region # buildWavelogGridData
@Test
fun gridDataCollectsTheirGridsAndRoamedOwnGrids() {
val data = buildWavelogGridData(
listOf(
record(start = 1_000L, theirGrid = "EN52", myGrid = "OL62"),
record(start = 2_000L, theirGrid = "EN53XX", myGrid = "OL62TI")
)
)
assertEquals(setOf("EN52", "EN53"), data.grids)
assertEquals(setOf("OL62"), data.roamedGrids)
assertEquals(1, data.gridQsos.getValue("EN52").size)
assertEquals(1, data.gridQsos.getValue("EN53").size)
}
@Test
fun gridDataSpreadsAMultiGridQsoAcrossItsGrids() {
val data = buildWavelogGridData(
listOf(record(start = 1_000L, theirGrid = "EN52", theirVucc = listOf("EN52", "EN53")))
)
assertEquals(setOf("EN52", "EN53"), data.grids)
assertEquals(1, data.gridQsos.getValue("EN52").size)
assertEquals(1, data.gridQsos.getValue("EN53").size)
// The same QSO object is shared, so its detail matches from either grid.
assertEquals(data.gridQsos.getValue("EN52").single(), data.gridQsos.getValue("EN53").single())
}
@Test
fun gridDataIgnoresNonSatelliteRecordsAndBlankGrids() {
val data = buildWavelogGridData(
listOf(
record(start = 1L, theirGrid = "", sat = ""),
record(start = 2L, theirGrid = "XX99", sat = "SO-50")
)
)
assertTrue(data.grids.isEmpty())
assertTrue(data.roamedGrids.isEmpty())
assertTrue(data.gridQsos.isEmpty())
}
@Test
fun gridQsoCarriesTheFullRecordDetails() {
val data = buildWavelogGridData(listOf(record(start = 1_000L, theirGrid = "EN52", myGrid = "OL62TI")))
val qso = data.gridQsos.getValue("EN52").single()
assertEquals("BG5JVM", qso.call)
assertEquals(1_000L, qso.epochMs)
assertEquals("SO-50", qso.satName)
assertEquals("FM", qso.mode)
assertEquals("2M", qso.bandUp)
assertEquals("70CM", qso.bandDown)
assertEquals("OL62", qso.myGrid)
assertEquals(setOf("OL62"), qso.myGrids)
assertEquals(listOf("EN52"), qso.theirGrids)
}
// endregion
// region # applyWavelogSyncResult
@Test
fun applySyncMergesGridsAndAdvancesBookkeeping() = runBlocking {
val settings = FakeSettings()
settings.setWorkedGrids(setOf("PM01"))
val count = applyWavelogSyncResult(
settings,
listOf(record(start = 1_000L, theirGrid = "EN52", myGrid = "OL62")),
mapOf("1" to 500L),
"http://a/wavelog/",
now = 42L
)
assertEquals(setOf("PM01", "EN52"), settings.getWorkedGrids())
assertEquals(setOf("OL62"), settings.getRoamedGrids())
assertEquals(setOf("EN52"), settings.getWorkedGridQsos().keys)
assertEquals(mapOf("1" to 500L), settings.getWavelogSyncCursors())
assertEquals("http://a/wavelog", settings.getWavelogSyncUrl())
assertEquals(42L, settings.getLastWavelogSyncEpochMs())
assertEquals(2, count)
}
@Test
fun applySyncNeverShrinksStoredData() = runBlocking {
val settings = FakeSettings()
settings.setWorkedGrids(setOf("PM01"))
settings.setRoamedGrids(setOf("OM60"))
applyWavelogSyncResult(settings, listOf(record(1_000L, "EN52", myGrid = "OL62")), mapOf("1" to 10L), "http://a")
applyWavelogSyncResult(settings, listOf(record(2_000L, "EN53", myGrid = "OL63")), mapOf("1" to 20L), "http://a")
assertEquals(setOf("PM01", "EN52", "EN53"), settings.getWorkedGrids())
assertEquals(setOf("OM60", "OL62", "OL63"), settings.getRoamedGrids())
assertEquals(mapOf("1" to 20L), settings.getWavelogSyncCursors())
}
// endregion
private fun record(
start: Long,
theirGrid: String = "",
theirVucc: List<String> = emptyList(),
myGrid: String = "OL62TI",
sat: String = "SO-50"
) = QsoRecord(
startUtcMillis = start,
theirCallsign = "BG5JVM",
myCallsign = "BA7OPF",
theirGrid = theirGrid,
theirVuccGrids = theirVucc,
myGrid = myGrid,
txFrequencyHz = 145_850_000L,
rxFrequencyHz = 436_795_000L,
band = "2M",
rxBand = "70CM",
mode = "FM",
satelliteName = sat,
propagationMode = if (sat.isNotBlank()) "SAT" else "",
status = QsoStatus.COMPLETE
)
private class FakeSettings : ISettingsRepo {
private var workedGrids: Set<String> = emptySet()
private var workedGridQsos: Map<String, List<GridQso>> = emptyMap()
private var roamedGrids: Set<String> = emptySet()
private var cursors: Map<String, Long> = emptyMap()
private var syncUrl: String = ""
private var lastSync: Long = 0L
override fun getWorkedGrids(): Set<String> = workedGrids
override fun setWorkedGrids(grids: Set<String>) { workedGrids = grids }
override fun getWorkedGridQsos(): Map<String, List<GridQso>> = workedGridQsos
override fun setWorkedGridQsos(qsos: Map<String, List<GridQso>>) { workedGridQsos = qsos }
override fun getRoamedGrids(): Set<String> = roamedGrids
override fun setRoamedGrids(grids: Set<String>) { roamedGrids = grids }
override fun getWavelogSyncCursors(): Map<String, Long> = cursors
override fun setWavelogSyncCursors(cursors: Map<String, Long>) { this.cursors = cursors }
override fun getWavelogSyncUrl(): String = syncUrl
override fun setWavelogSyncUrl(url: String) { syncUrl = url }
override fun getLastWavelogSyncEpochMs(): Long = lastSync
override fun setLastWavelogSyncEpochMs(value: Long) { lastSync = value }
override val appVersionName: String get() = TODO()
override val selectedIds: StateFlow<List<Int>> get() = TODO()
override val selectedTypes: StateFlow<List<String>> get() = TODO()
override fun setSelectedIds(ids: List<Int>) = TODO()
override fun setSelectedTypes(types: List<String>) = TODO()
override val passesSettings: StateFlow<PassesSettings> get() = TODO()
override fun setPassesSettings(settings: PassesSettings) = TODO()
override val stationPosition: StateFlow<GeoPos> get() = TODO()
override fun setStationPosition(latitude: Double, longitude: Double, altitude: Double): Boolean = TODO()
override fun setStationPosition(): Boolean = TODO()
override fun setStationPosition(locator: String): Boolean = TODO()
override fun getCurrentGrid(): String? = TODO()
override val databaseState: StateFlow<DatabaseState> get() = TODO()
override fun getSatelliteTypesIds(types: List<String>): List<Int> = TODO()
override fun setSatelliteTypeIds(type: String, ids: List<Int>) = TODO()
override fun updateDatabaseState(state: DatabaseState) = TODO()
override val rcSettings: StateFlow<RCSettings> get() = TODO()
override fun updateRCSettings(settings: RCSettings) = TODO()
override val otherSettings: StateFlow<OtherSettings> get() = TODO()
override fun updateOtherSettings(transform: (OtherSettings) -> OtherSettings) = TODO()
override val dataSourcesSettings: StateFlow<DataSourcesSettings> get() = TODO()
override fun updateDataSourcesSettings(settings: DataSourcesSettings) = TODO()
override val dataSourcesStatus: StateFlow<Map<String, Int>> get() = TODO()
override fun updateDataSourcesStatus(status: Map<String, Int>) = TODO()
override val radioControlSettings: StateFlow<RadioControlSettings> get() = TODO()
override fun updateRadioControlSettings(settings: RadioControlSettings) = TODO()
override fun getSatelliteOffset(catnum: Int): String = TODO()
override fun setSatelliteOffset(catnum: Int, offset: String) = TODO()
override fun getSatelliteMode(catnum: Int): String = TODO()
override fun setSatelliteMode(catnum: Int, mode: String) = TODO()
override fun getAmSatCallsign(): String = TODO()
override fun setAmSatCallsign(callsign: String) = TODO()
override val wavelogUploadSettings: StateFlow<WavelogUploadSettings> get() = TODO()
override fun updateWavelogUploadSettings(settings: WavelogUploadSettings) = TODO()
override fun getWavelogStations(): List<WavelogStationInfo> = emptyList()
override fun setWavelogStations(stations: List<WavelogStationInfo>) = TODO()
override fun getMarkedGridStations(): Map<String, List<MarkedStation>> = TODO()
override fun setMarkedGridStations(stations: Map<String, List<MarkedStation>>) = TODO()
override val lotwSettings: StateFlow<LoTWSettings> get() = TODO()
override fun updateLoTWSettings(settings: LoTWSettings) = TODO()
override fun getLastLotwSyncDate(): String = TODO()
override fun setLastLotwSyncDate(date: String) = TODO()
override fun getLastLotwSyncCallsign(): String = TODO()
override fun setLastLotwSyncCallsign(callsign: String) = TODO()
}
}
@@ -492,6 +492,7 @@ fun SwipeRevealRow(
controller: SwipeController, controller: SwipeController,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
revealWidth: Dp = 64.dp, revealWidth: Dp = 64.dp,
gesturesEnabled: Boolean = true,
revealAction: () -> Unit, revealAction: () -> Unit,
revealButton: @Composable () -> Unit = { revealButton: @Composable () -> Unit = {
Box( Box(
@@ -555,7 +556,9 @@ fun SwipeRevealRow(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.graphicsLayer { translationX = offsetX } .graphicsLayer { translationX = offsetX }
.pointerInput(revealPx) { .pointerInput(revealPx, gesturesEnabled) {
// Selection mode turns reveals off; rows must not shift under the checkboxes.
if (!gesturesEnabled) return@pointerInput
detectHorizontalDragGestures( detectHorizontalDragGestures(
onDragStart = { settleJob?.cancel() }, onDragStart = { settleJob?.cancel() },
onHorizontalDrag = { change, dragAmount -> onHorizontalDrag = { change, dragAmount ->
@@ -0,0 +1,42 @@
/*
* 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.presentation
import java.util.Locale
/**
* Grid sets abbreviated the way operators read them: a grid is shortened to its
* last two characters while its field (first two characters) stays the same as
* the previous grid's; a field change is spelled out — "OL61/62" (same field)
* but "OM91/PM01" (a shared prefix would read as OM01, the wrong square).
* Used for the station grids a QSO was uploaded under (logbook rows and the
* radar log page) and for the opposite station's confirmed grid set.
*/
fun gridsLabel(grids: List<String>): String {
val clean = grids.map { it.trim().uppercase(Locale.US).take(4) }
.filter { it.length >= 4 }.distinct().sorted()
if (clean.isEmpty()) return ""
return buildString {
append(clean.first())
for (index in 1 until clean.size) {
val grid = clean[index]
if (grid.take(2) == clean[index - 1].take(2)) append('/').append(grid.takeLast(2))
else append('/').append(grid)
}
}
}
@@ -0,0 +1,62 @@
/*
* 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.presentation
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.sp
import com.rtbishop.look4sat.core.domain.repository.LoTWPositionWarning
/**
* Pre-upload position check: the operator's current position grid falls outside the station
* location the prepared batch would be signed with — the fingerprint of operating while
* roaming with a station location that was never updated. The operator either jumps to the
* station location to fix it, or ignores the warning and proceeds to the normal preview.
*/
@Composable
fun LoTWPositionWarningDialog(
warning: LoTWPositionWarning,
onFixStation: () -> Unit,
onIgnore: () -> Unit
) {
AlertDialog(
onDismissRequest = onIgnore,
shape = sheetDialogShape(),
containerColor = sheetDialogContainerColor(),
title = { SheetDialogTitle(stringResource(R.string.lotw_upload_grid_title)) },
text = {
Text(
text = stringResource(
R.string.lotw_upload_grid_mismatch,
warning.currentGrid,
gridsLabel(warning.stationGrids)
),
fontSize = 14.sp
)
},
confirmButton = {
TextButton(onClick = onFixStation) { Text(stringResource(R.string.lotw_upload_grid_fix)) }
},
dismissButton = {
TextButton(onClick = onIgnore) { Text(stringResource(R.string.lotw_upload_grid_ignore)) }
}
)
}
@@ -0,0 +1,74 @@
/*
* Look4Sat-BA7OPF. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2026 BA7OPF.
* Based on Look4Sat by Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.core.presentation
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.rtbishop.look4sat.core.domain.logbook.wavelogSkipSummary
import com.rtbishop.look4sat.core.domain.repository.WavelogUploadPreview
/**
* Confirmation sheet for a Wavelog-mode upload (radar log page and settings logbook):
* shows the station profile, the batch size and the records held back before the POST,
* so the operator commits the batch after seeing what goes out (the Wavelog analog of
* the LoTW preview flow).
*/
@Composable
fun WavelogUploadPreviewDialog(
preview: WavelogUploadPreview,
busy: Boolean,
onConfirm: () -> Unit,
onDismiss: () -> Unit
) {
AlertDialog(
onDismissRequest = onDismiss,
shape = sheetDialogShape(),
containerColor = sheetDialogContainerColor(),
title = { SheetDialogTitle(stringResource(R.string.prefs_logbook_upload_wavelog_title)) },
text = {
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
if (preview.stationLabel.isNotBlank()) {
Text(preview.stationLabel, fontSize = 13.sp)
}
Text("${preview.count} QSO(s) · ${preview.firstUtc} – ${preview.lastUtc}", fontSize = 13.sp)
val skip = wavelogSkipSummary(preview)
if (skip.isNotBlank()) {
Text(skip, fontSize = 12.sp, color = MaterialTheme.colorScheme.error)
}
Text(preview.contacts.joinToString("\n") { it }, fontSize = 12.sp, maxLines = 8)
}
},
confirmButton = {
TextButton(onClick = onConfirm, enabled = !busy) {
Text(stringResource(R.string.prefs_logbook_upload_confirm))
}
},
dismissButton = {
TextButton(onClick = onDismiss) { Text(stringResource(R.string.btn_cancel)) }
}
)
}
@@ -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>
@@ -125,6 +125,7 @@
<string name="map_visibility">能见度:%s</string> <string name="map_visibility">能见度:%s</string>
<!-- Worked-grid QSO dialog --> <!-- Worked-grid QSO dialog -->
<string name="grid_qso_calls_count" formatted="false">%d 个呼号 · %d 次通联</string> <string name="grid_qso_calls_count" formatted="false">%d 个呼号 · %d 次通联</string>
<string name="region_qso_empty">该区域暂无已确认通联</string>
<string name="grid_qso_count_one">%d QSO</string> <string name="grid_qso_count_one">%d QSO</string>
<string name="grid_qso_count_many">%d QSOs</string> <string name="grid_qso_count_many">%d QSOs</string>
<string name="grid_qso_first">首通</string> <string name="grid_qso_first">首通</string>
@@ -157,15 +158,29 @@
<string name="prefs_station_lat_text">输入站点的纬度</string> <string name="prefs_station_lat_text">输入站点的纬度</string>
<string name="prefs_station_lon_text">输入站点的经度</string> <string name="prefs_station_lon_text">输入站点的经度</string>
<string name="prefs_locator_title">QTH 网格</string> <string name="prefs_locator_title">QTH 网格</string>
<string name="prefs_wavelog_title">Wavelog 已通联网格</string>
<string name="prefs_wavelog_configure">配置</string> <string name="prefs_wavelog_configure">配置</string>
<string name="prefs_wavelog_configured">已从 Wavelog 同步 — 通联 %1$d 个网格</string>
<string name="prefs_wavelog_not_configured">未配置 — 连接你的 Wavelog 日志以在地图上高亮已通联网格</string>
<string name="prefs_wavelog_url">Wavelog 地址</string> <string name="prefs_wavelog_url">Wavelog 地址</string>
<string name="prefs_wavelog_token">API key :日志 ID</string>
<string name="prefs_wavelog_hint">已同步网格数:%1$d。格式:API key:日志ID。填好直接点同步,会自动保存并拉取。</string>
<string name="prefs_wavelog_sync">立即同步</string>
<string name="prefs_wavelog_syncing">同步中…</string> <string name="prefs_wavelog_syncing">同步中…</string>
<string name="prefs_wavelog_upload_title">Wavelog 配置</string>
<string name="prefs_wavelog_exclusive">与 LoTW 二选一配置,两者不兼容。</string>
<string name="prefs_wavelog_sync_incremental">增量同步</string>
<string name="prefs_wavelog_sync_full">全量同步</string>
<string name="prefs_wavelog_clear">清除配置</string>
<string name="prefs_wavelog_clear_title">清除 Wavelog 配置?</string>
<string name="prefs_wavelog_clear_message">清除后将切回 LoTW 模式,LoTW 功能恢复可用。</string>
<string name="prefs_wavelog_switch_title">配置提醒</string>
<string name="prefs_wavelog_switch_message">已配置 LoTW。配置 Wavelog 后,App 内 LoTW 的上传与同步将不可用(两者二选一,不兼容)。继续?</string>
<string name="prefs_wavelog_switch_continue">继续</string>
<string name="prefs_lotw_disabled_wavelog">Wavelog 模式中不可用(二选一)</string>
<string name="prefs_wavelog_upload_not_configured">未配置 — 把本机通联记录上传到 Wavelog</string>
<string name="prefs_wavelog_upload_configured">站点 %1$s — 可以上传</string>
<string name="prefs_wavelog_upload_apikey">API key(读写权限)</string>
<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>
@@ -197,12 +212,25 @@
<string name="prefs_logbook_upload">上传</string> <string name="prefs_logbook_upload">上传</string>
<string name="prefs_logbook_upload_title">上传到 LoTW</string> <string name="prefs_logbook_upload_title">上传到 LoTW</string>
<string name="prefs_logbook_upload_confirm">确认上传</string> <string name="prefs_logbook_upload_confirm">确认上传</string>
<string name="prefs_logbook_resubmit">重传选中 (%1$d)</string>
<string name="prefs_logbook_selected">已选 %1$d 条</string>
<string name="prefs_logbook_select_hint">长按记录行可多选重传</string>
<string name="prefs_logbook_resubmit_note">已上传记录将按当前台址就地更新;已被奖项使用的旧网格可能被拒绝。</string>
<string name="prefs_logbook_station_label">台址</string>
<string name="prefs_logbook_station_all">全部</string>
<string name="prefs_logbook_upload_wavelog_title">上传到 Wavelog</string>
<string name="lotw_upload_grid_title">上传前核对</string>
<string name="lotw_upload_grid_fix">修改台址</string>
<string name="lotw_upload_grid_ignore">忽略</string>
<string name="lotw_upload_grid_mismatch">当前定位 %1$s 不在证书台址网格 %2$s 内——如果正在此处操作,请先更新台址再上传。</string>
<string name="lotw_upload_pos_hint">当前定位 %1$s 不在证书台址网格 %2$s 内 · 点此更新</string>
<string name="prefs_lotw_upload_title">LoTW 上传证书</string> <string name="prefs_lotw_upload_title">LoTW 上传证书</string>
<string name="prefs_lotw_upload_configured">证书已导入 · 台址网格 %1$s</string> <string name="prefs_lotw_upload_configured">证书已导入 · 台址网格 %1$s</string>
<string name="prefs_lotw_upload_not_configured">未配置 — 导入 TrustedQSL 证书后即可上传通联</string> <string name="prefs_lotw_upload_not_configured">未配置 — 导入 TrustedQSL 证书后即可上传通联</string>
<string name="prefs_lotw_upload_busy">处理中…</string> <string name="prefs_lotw_upload_busy">处理中…</string>
<string name="prefs_lotw_upload_cert_hint">导入你的 TrustedQSL .p12 证书(与 TQSL 同款)用于签名上传通联。密码仅加密保存在手机本地。</string> <string name="prefs_lotw_upload_cert_hint">导入你的 TrustedQSL .p12 证书(与 TQSL 同款)用于签名上传通联。密码仅加密保存在手机本地。</string>
<string name="prefs_lotw_upload_password">证书密码</string> <string name="prefs_lotw_upload_password">证书密码</string>
<string name="prefs_lotw_upload_password_hint">证书无密码时留空即可</string>
<string name="prefs_lotw_upload_import">选择 .p12 文件</string> <string name="prefs_lotw_upload_import">选择 .p12 文件</string>
<string name="prefs_lotw_upload_import_confirm">导入</string> <string name="prefs_lotw_upload_import_confirm">导入</string>
<string name="prefs_lotw_upload_error_password">证书密码错误,请检查 TQSL 导出 .p12 时设置的密码</string> <string name="prefs_lotw_upload_error_password">证书密码错误,请检查 TQSL 导出 .p12 时设置的密码</string>
@@ -215,6 +243,8 @@
<string name="prefs_lotw_upload_remove">移除证书</string> <string name="prefs_lotw_upload_remove">移除证书</string>
<string name="prefs_lotw_upload_station_title">台址</string> <string name="prefs_lotw_upload_station_title">台址</string>
<string name="prefs_lotw_upload_region_hint">未选择</string> <string name="prefs_lotw_upload_region_hint">未选择</string>
<string name="prefs_lotw_upload_region_label">省份</string>
<string name="prefs_lotw_upload_region_pending">导入证书后可选</string>
<string name="prefs_lotw_upload_grid">网格(逗号分隔,可多格)</string> <string name="prefs_lotw_upload_grid">网格(逗号分隔,可多格)</string>
<string name="prefs_lotw_upload_save">保存</string> <string name="prefs_lotw_upload_save">保存</string>
<string name="prefs_lotw_upload_close">关闭</string> <string name="prefs_lotw_upload_close">关闭</string>
@@ -232,6 +262,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>
@@ -406,6 +438,9 @@
<string name="qso_edit_comment">备注</string> <string name="qso_edit_comment">备注</string>
<string name="qso_edit_save">保存</string> <string name="qso_edit_save">保存</string>
<string name="qso_edit_cancel">取消</string> <string name="qso_edit_cancel">取消</string>
<string name="log_out_window_title">不在过境窗口</string>
<string name="log_out_window_message">当前时间不在该过境窗口内,将默认按过境中点 %1$s 录入;如需修改,请点击该条通联记录。</string>
<string name="log_out_window_confirm">录入</string>
<string name="qso_edit_err_callsign">呼号不能为空</string> <string name="qso_edit_err_callsign">呼号不能为空</string>
<string name="qso_edit_err_datetime">日期或时间格式不对 —— 应为 yyyy-MM-dd 与 HH:mm:ss(UTC)</string> <string name="qso_edit_err_datetime">日期或时间格式不对 —— 应为 yyyy-MM-dd 与 HH:mm:ss(UTC)</string>
<string name="qso_edit_err_frequency">频率格式不对 —— 请输入 MHz,例如 145.850</string> <string name="qso_edit_err_frequency">频率格式不对 —— 请输入 MHz,例如 145.850</string>
@@ -145,6 +145,7 @@
<string name="map_visibility">Visibility: %s</string> <string name="map_visibility">Visibility: %s</string>
<!-- Worked-grid QSO dialog --> <!-- Worked-grid QSO dialog -->
<string name="grid_qso_calls_count" formatted="false">%d callsigns · %d QSOs</string> <string name="grid_qso_calls_count" formatted="false">%d callsigns · %d QSOs</string>
<string name="region_qso_empty">No confirmed QSOs in this region yet</string>
<string name="grid_qso_count_one">%d QSO</string> <string name="grid_qso_count_one">%d QSO</string>
<string name="grid_qso_count_many">%d QSOs</string> <string name="grid_qso_count_many">%d QSOs</string>
<string name="grid_qso_first">First</string> <string name="grid_qso_first">First</string>
@@ -186,15 +187,29 @@
<string name="prefs_station_lat_text">Set your ground station\'s latitude</string> <string name="prefs_station_lat_text">Set your ground station\'s latitude</string>
<string name="prefs_station_lon_text">Set your ground station\'s longitude</string> <string name="prefs_station_lon_text">Set your ground station\'s longitude</string>
<string name="prefs_locator_title">QTH locator settings</string> <string name="prefs_locator_title">QTH locator settings</string>
<string name="prefs_wavelog_title">Wavelog worked grids</string>
<string name="prefs_wavelog_configure">Configure</string> <string name="prefs_wavelog_configure">Configure</string>
<string name="prefs_wavelog_configured">Synced from Wavelog — %1$d grids worked</string>
<string name="prefs_wavelog_not_configured">Not configured — connect your Wavelog logbook to highlight worked grids on the map</string>
<string name="prefs_wavelog_url">Wavelog URL</string> <string name="prefs_wavelog_url">Wavelog URL</string>
<string name="prefs_wavelog_token">API key : logbook ID</string>
<string name="prefs_wavelog_hint">Worked grids synced: %1$d. Format: API key, colon, logbook ID. Just tap Sync — credentials are saved automatically.</string>
<string name="prefs_wavelog_sync">Sync now</string>
<string name="prefs_wavelog_syncing">Syncing…</string> <string name="prefs_wavelog_syncing">Syncing…</string>
<string name="prefs_wavelog_upload_title">Wavelog configuration</string>
<string name="prefs_wavelog_exclusive">Configure either LoTW or Wavelog — the two cannot be used together.</string>
<string name="prefs_wavelog_sync_incremental">Incremental sync</string>
<string name="prefs_wavelog_sync_full">Full sync</string>
<string name="prefs_wavelog_clear">Clear configuration</string>
<string name="prefs_wavelog_clear_title">Clear the Wavelog configuration?</string>
<string name="prefs_wavelog_clear_message">LoTW features become available again.</string>
<string name="prefs_wavelog_switch_title">Notice</string>
<string name="prefs_wavelog_switch_message">LoTW is already configured. With Wavelog configured, LoTW upload and sync in this app become unavailable (the two are mutually exclusive). Continue?</string>
<string name="prefs_wavelog_switch_continue">Continue</string>
<string name="prefs_lotw_disabled_wavelog">Unavailable while Wavelog is configured</string>
<string name="prefs_wavelog_upload_not_configured">Not configured — upload your local QSO records to Wavelog</string>
<string name="prefs_wavelog_upload_configured">Station %1$s — ready to upload</string>
<string name="prefs_wavelog_upload_apikey">API key (read &amp; write)</string>
<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>
@@ -226,12 +241,25 @@
<string name="prefs_logbook_upload">Upload</string> <string name="prefs_logbook_upload">Upload</string>
<string name="prefs_logbook_upload_title">Upload to LoTW</string> <string name="prefs_logbook_upload_title">Upload to LoTW</string>
<string name="prefs_logbook_upload_confirm">Upload</string> <string name="prefs_logbook_upload_confirm">Upload</string>
<string name="prefs_logbook_resubmit">Resubmit (%1$d)</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_resubmit_note">Existing contacts will be updated in place — a grid already used for an award may be rejected.</string>
<string name="prefs_logbook_station_label">Station</string>
<string name="prefs_logbook_station_all">All</string>
<string name="prefs_logbook_upload_wavelog_title">Upload to Wavelog</string>
<string name="lotw_upload_grid_title">Upload check</string>
<string name="lotw_upload_grid_fix">Fix station location</string>
<string name="lotw_upload_grid_ignore">Ignore</string>
<string name="lotw_upload_grid_mismatch">Current position %1$s is outside the certificate station grids %2$s — if you are operating here, update the station location before uploading.</string>
<string name="lotw_upload_pos_hint">Current position %1$s is outside the station grids %2$s — tap to update</string>
<string name="prefs_lotw_upload_title">LoTW upload certificate</string> <string name="prefs_lotw_upload_title">LoTW upload certificate</string>
<string name="prefs_lotw_upload_configured">Certificate imported · station grid %1$s</string> <string name="prefs_lotw_upload_configured">Certificate imported · station grid %1$s</string>
<string name="prefs_lotw_upload_not_configured">Not configured — import your TrustedQSL certificate to upload QSOs</string> <string name="prefs_lotw_upload_not_configured">Not configured — import your TrustedQSL certificate to upload QSOs</string>
<string name="prefs_lotw_upload_busy">Working…</string> <string name="prefs_lotw_upload_busy">Working…</string>
<string name="prefs_lotw_upload_cert_hint">Import your TrustedQSL .p12 certificate (same one you use in TQSL) to sign and upload QSOs. The password is stored encrypted on this device only.</string> <string name="prefs_lotw_upload_cert_hint">Import your TrustedQSL .p12 certificate (same one you use in TQSL) to sign and upload QSOs. The password is stored encrypted on this device only.</string>
<string name="prefs_lotw_upload_password">Certificate password</string> <string name="prefs_lotw_upload_password">Certificate password</string>
<string name="prefs_lotw_upload_password_hint">Leave blank if the certificate has no password</string>
<string name="prefs_lotw_upload_import">Choose .p12 file</string> <string name="prefs_lotw_upload_import">Choose .p12 file</string>
<string name="prefs_lotw_upload_import_confirm">Import</string> <string name="prefs_lotw_upload_import_confirm">Import</string>
<string name="prefs_lotw_upload_error_password">Incorrect certificate password. Check the password you set when exporting the .p12 from TQSL.</string> <string name="prefs_lotw_upload_error_password">Incorrect certificate password. Check the password you set when exporting the .p12 from TQSL.</string>
@@ -244,6 +272,8 @@
<string name="prefs_lotw_upload_remove">Remove certificate</string> <string name="prefs_lotw_upload_remove">Remove certificate</string>
<string name="prefs_lotw_upload_station_title">Station location</string> <string name="prefs_lotw_upload_station_title">Station location</string>
<string name="prefs_lotw_upload_region_hint">Not selected</string> <string name="prefs_lotw_upload_region_hint">Not selected</string>
<string name="prefs_lotw_upload_region_label">Region</string>
<string name="prefs_lotw_upload_region_pending">Import certificate to select</string>
<string name="prefs_lotw_upload_grid">Grid(s), comma-separated</string> <string name="prefs_lotw_upload_grid">Grid(s), comma-separated</string>
<string name="prefs_lotw_upload_save">Save</string> <string name="prefs_lotw_upload_save">Save</string>
<string name="prefs_lotw_upload_close">Close</string> <string name="prefs_lotw_upload_close">Close</string>
@@ -260,6 +290,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>
@@ -434,6 +466,9 @@ Upstream: https://github.com/rt-bishop/Look4Sat</string>
<string name="qso_edit_comment">Comment</string> <string name="qso_edit_comment">Comment</string>
<string name="qso_edit_save">Save</string> <string name="qso_edit_save">Save</string>
<string name="qso_edit_cancel">Cancel</string> <string name="qso_edit_cancel">Cancel</string>
<string name="log_out_window_title">Outside pass window</string>
<string name="log_out_window_message">The current time is outside this pass window. The contact will be logged at the pass midpoint %1$s by default; tap the record afterwards to change the time if needed.</string>
<string name="log_out_window_confirm">Log it</string>
<string name="qso_edit_err_callsign">Callsign is required</string> <string name="qso_edit_err_callsign">Callsign is required</string>
<string name="qso_edit_err_datetime">Invalid date/time — use yyyy-MM-dd and HH:mm:ss (UTC)</string> <string name="qso_edit_err_datetime">Invalid date/time — use yyyy-MM-dd and HH:mm:ss (UTC)</string>
<string name="qso_edit_err_frequency">Invalid frequency — enter MHz, e.g. 145.850</string> <string name="qso_edit_err_frequency">Invalid frequency — enter MHz, e.g. 145.850</string>
@@ -44,6 +44,9 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.rtbishop.look4sat.core.presentation.SheetDialogTitle
import com.rtbishop.look4sat.core.presentation.sheetDialogContainerColor
import com.rtbishop.look4sat.core.presentation.sheetDialogShape
import com.ve3nea.morse_expert.MainActivity import com.ve3nea.morse_expert.MainActivity
import kotlin.math.roundToInt import kotlin.math.roundToInt
@@ -91,7 +94,9 @@ fun CwSettingsDialog(controller: MainActivity, onDismiss: () -> Unit) {
AlertDialog( AlertDialog(
onDismissRequest = onDismiss, onDismissRequest = onDismiss,
title = { Text("CW Settings") }, shape = sheetDialogShape(),
containerColor = sheetDialogContainerColor(),
title = { SheetDialogTitle("CW Settings") },
text = { text = {
Column( Column(
modifier = Modifier modifier = Modifier
+14
View File
@@ -4,8 +4,22 @@ plugins {
android { android {
namespace = "com.rtbishop.look4sat.feature.map" namespace = "com.rtbishop.look4sat.feature.map"
testOptions {
unitTests {
// Robolectric needs real Android resources for Compose UI tests.
isIncludeAndroidResources = true
}
}
} }
dependencies { dependencies {
implementation(libs.other.osmdroid) implementation(libs.other.osmdroid)
// Real org.json for unit tests (android.jar stub throws "not mocked")
testImplementation(libs.test.json)
testImplementation(libs.test.junit4)
testImplementation(libs.test.coroutines)
testImplementation(libs.robolectric)
testImplementation(libs.androidx.test.core)
testImplementation(libs.compose.ui.test.junit4)
debugImplementation(libs.compose.debug.manifest)
} }
+1 -1
View File
@@ -1 +1 @@
{"type":"dxcc","regions":[{"id":0,"code":176,"name":"Fiji","label_lon":177.9759,"label_lat":-17.9376,"rings":[[[180.0,-16.0671],[180.0,-16.5552],[179.3641,-16.8014],[178.7251,-17.012],[178.5968,-16.6392],[179.0966,-16.434],[179.4135,-16.3791],[180.0,-16.0671]],[[178.1256,-17.5048],[178.3736,-17.3399],[178.7181,-17.6285],[178.5527,-18.1506],[177.9327,-18.288],[177.3815,-18.1643],[177.285,-17.7247],[177.6709,-17.3811]],[[-179.7933,-16.0209],[-179.9174,-16.5018],[-180.0,-16.5552],[-180.0,-16.0671]]]},{"id":1,"code":470,"name":"Tanzania","label_lon":34.1421,"label_lat":-6.2078,"rings":[[[33.9037,-0.95],[34.0726,-1.0598],[37.6987,-3.097],[37.7669,-3.6771],[39.2022,-4.6768],[38.7405,-5.9089],[38.7998,-6.4757],[39.44,-6.84],[39.47,-7.1],[39.1947,-7.7039],[39.252,-8.0078],[39.1865,-8.4855],[39.5357,-9.1124],[39.9496,-10.0984],[40.3166,-10.3171],[40.3166,-10.3171],[39.521,-10.8969],[38.4276,-11.2852],[37.8276,-11.2688],[37.4713,-11.5688],[36.7752,-11.5945],[36.5141,-11.7209],[35.3124,-11.4391],[34.56,-11.52],[34.28,-10.16],[33.9408,-9.6937],[33.7397,-9.4171],[32.7594,-9.2306],[32.1919,-8.9304],[31.5563,-8.762],[31.1578,-8.5946],[30.74,-8.34],[30.74,-8.34],[30.2,-7.08],[29.62,-6.52],[29.42,-5.94],[29.52,-5.42],[29.34,-4.5],[29.7535,-4.4524],[30.1163,-4.0901],[30.5055,-3.5686],[30.7522,-3.3593],[30.743,-3.0343],[30.5277,-2.8076],[30.4697,-2.4139],[30.4697,-2.4138],[30.7583,-2.2872],[30.8161,-1.6989],[30.4191,-1.1347],[30.7699,-1.0146],[31.8662,-1.0274]]]},{"id":2,"code":302,"name":"W. Sahara","label_lon":-12.572,"label_lat":24.2306,"rings":[[[-8.6656,27.6564],[-8.6651,27.5895],[-8.6844,27.3957],[-8.6873,25.8811],[-11.9694,25.9334],[-11.9372,23.3746],[-12.8742,23.2848],[-13.1188,22.7712],[-12.9291,21.3271],[-16.8452,21.3333],[-17.0634,20.9998],[-17.0204,21.4223],[-17.003,21.4207],[-14.751,21.5006],[-14.6308,21.8609],[-14.2212,22.3102],[-13.8911,23.691],[-12.501,24.7701],[-12.0308,26.0309],[-11.7182,26.1041],[-11.3926,26.8834],[-10.5513,26.9908],[-10.1894,26.8609],[-9.7353,26.8609],[-9.413,27.0885],[-8.7949,27.1207],[-8.8178,27.6564]]]},{"id":3,"code":1,"name":"Canada","label_lon":-110.2438,"label_lat":56.7019,"rings":[[[-122.84,49.0],[-122.9742,49.0025],[-124.9102,49.9846],[-125.6246,50.4166],[-127.4356,50.8306],[-127.9928,51.7158],[-127.8503,52.3296],[-129.1298,52.7554],[-129.3052,53.5616],[-130.515,54.2876],[-130.5361,54.8028],[-130.5361,54.8028],[-129.98,55.285],[-130.0078,55.9158],[-131.7078,56.5521],[-132.7304,57.6929],[-133.3556,58.4103],[-134.2711,58.8611],[-134.945,59.2706],[-135.4758,59.7878],[-136.4797,59.4639],[-137.4525,58.905],[-138.3409,59.5621],[-139.039,60.0],[-140.013,60.2768],[-140.9978,60.3064],[-140.9925,66.0],[-140.986,69.712],[-140.986,69.712],[-139.1205,69.471],[-137.5464,68.99],[-136.5036,68.898],[-135.6258,69.3151],[-134.4146,69.6274],[-132.9292,69.5053],[-131.4314,69.9445],[-129.7947,70.1937],[-129.1077,69.7793],[-128.3616,70.0129],[-128.1382,70.4838],[-127.4471,70.3772],[-125.7563,69.4806],[-124.4248,70.1584],[-124.2897,69.3997],[-123.0611,69.5637],[-122.6835,69.8555],[-121.4723,69.7978],[-119.9429,69.3779],[-117.6027,69.0113],[-116.2264,68.8415],[-115.2469,68.9059],[-113.8979,68.3989],[-115.3049,67.9026],[-113.4973,67.6881],[-110.798,67.8061],[-109.9462,67.981],[-108.8802,67.3814],[-107.7924,67.8874],[-108.813,68.3116],[-108.1672,68.6539],[-106.95,68.7],[-106.15,68.8],[-105.3428,68.5612],[-104.3379,68.018],[-103.2211,68.0978],[-101.4543,67.6469],[-99.9019,67.8057],[-98.4432,67.7816],[-98.5586,68.4039],[-97.6695,68.5786],[-96.1199,68.2394],[-96.1259,67.2934],[-95.4894,68.0907],[-94.685,68.0638],[-94.2328,69.069],[-95.3041,69.6857],[-96.4713,70.0898],[-96.3911,71.1948],[-95.2088,71.9205],[-93.89,71.7601],[-92.8782,71.3187],[-91.5196,70.1913],[-92.4069,69.7],[-90.5471,69.4977],[-90.5515,68.475],[-89.2151,69.2587],[-88.0197,68.6151],[-88.3175,67.8734],[-87.3502,67.1987],[-86.3061,67.9215],[-85.5766,68.7846],[-85.522,69.8821],[-84.1008,69.8054],[-82.6226,69.6583],[-81.2804,69.162],[-81.2202,68.6657],[-81.9644,68.1325],[-81.2593,67.5972],[-81.3865,67.1108],[-83.3446,66.4115],[-84.7354,66.2573],[-85.7694,66.5583],[-86.0676,66.0563],[-87.0314,65.213],[-87.3232,64.7756],[-88.483,64.099],[-89.9144,64.0327],[-90.704,63.6102],[-90.77,62.9602],[-91.9334,62.8351],[-93.157,62.0247],[-94.2415,60.8987],[-94.6293,60.1102],[-94.6846,58.9488],[-93.215,58.7821],[-92.7646,57.8457],[-92.297,57.0871],[-90.8977,57.2847],[-89.0395,56.8517],[-88.0398,56.4716],[-87.3242,55.9991],[-86.0712,55.7238],[-85.0118,55.3026],[-83.3606,55.2449],[-82.2729,55.1483],[-82.4362,54.2823],[-82.125,53.277],[-81.4008,52.1579],[-79.9129,51.2084],[-79.143,51.5339],[-78.6019,52.5621],[-79.1242,54.1414],[-79.8296,54.6677],[-78.2287,55.1365],[-77.0956,55.8374],[-76.5414,56.5342],[-76.6232,57.2026],[-77.3023,58.0521],[-78.5169,58.8046],[-77.3368,59.8526],[-77.7727,60.7579],[-78.1069,62.3196],[-77.4107,62.5505],[-75.6962,62.2784],[-74.6682,62.1811],[-73.8399,62.4438],[-72.9085,62.1051],[-71.6771,61.5254],[-71.3737,61.1372],[-69.5904,61.0614],[Line truncated {"type":"dxcc","regions":[{"id":0,"code":176,"name":"Fiji","label_lon":177.9759,"label_lat":-17.9376,"rings":[[[180.0,-16.0671],[180.0,-16.5552],[179.3641,-16.8014],[178.7251,-17.012],[178.5968,-16.6392],[179.0966,-16.434],[179.4135,-16.3791],[180.0,-16.0671]],[[178.1256,-17.5048],[178.3736,-17.3399],[178.7181,-17.6285],[178.5527,-18.1506],[177.9327,-18.288],[177.3815,-18.1643],[177.285,-17.7247],[177.6709,-17.3811]],[[-179.7933,-16.0209],[-179.9174,-16.5018],[-180.0,-16.5552],[-180.0,-16.0671]]]},{"id":1,"code":470,"name":"Tanzania","label_lon":34.1421,"label_lat":-6.2078,"rings":[[[33.9037,-0.95],[34.0726,-1.0598],[37.6987,-3.097],[37.7669,-3.6771],[39.2022,-4.6768],[38.7405,-5.9089],[38.7998,-6.4757],[39.44,-6.84],[39.47,-7.1],[39.1947,-7.7039],[39.252,-8.0078],[39.1865,-8.4855],[39.5357,-9.1124],[39.9496,-10.0984],[40.3166,-10.3171],[40.3166,-10.3171],[39.521,-10.8969],[38.4276,-11.2852],[37.8276,-11.2688],[37.4713,-11.5688],[36.7752,-11.5945],[36.5141,-11.7209],[35.3124,-11.4391],[34.56,-11.52],[34.28,-10.16],[33.9408,-9.6937],[33.7397,-9.4171],[32.7594,-9.2306],[32.1919,-8.9304],[31.5563,-8.762],[31.1578,-8.5946],[30.74,-8.34],[30.74,-8.34],[30.2,-7.08],[29.62,-6.52],[29.42,-5.94],[29.52,-5.42],[29.34,-4.5],[29.7535,-4.4524],[30.1163,-4.0901],[30.5055,-3.5686],[30.7522,-3.3593],[30.743,-3.0343],[30.5277,-2.8076],[30.4697,-2.4139],[30.4697,-2.4138],[30.7583,-2.2872],[30.8161,-1.6989],[30.4191,-1.1347],[30.7699,-1.0146],[31.8662,-1.0274]]]},{"id":2,"code":302,"name":"W. Sahara","label_lon":-12.572,"label_lat":24.2306,"rings":[[[-8.6656,27.6564],[-8.6651,27.5895],[-8.6844,27.3957],[-8.6873,25.8811],[-11.9694,25.9334],[-11.9372,23.3746],[-12.8742,23.2848],[-13.1188,22.7712],[-12.9291,21.3271],[-16.8452,21.3333],[-17.0634,20.9998],[-17.0204,21.4223],[-17.003,21.4207],[-14.751,21.5006],[-14.6308,21.8609],[-14.2212,22.3102],[-13.8911,23.691],[-12.501,24.7701],[-12.0308,26.0309],[-11.7182,26.1041],[-11.3926,26.8834],[-10.5513,26.9908],[-10.1894,26.8609],[-9.7353,26.8609],[-9.413,27.0885],[-8.7949,27.1207],[-8.8178,27.6564]]]},{"id":3,"code":1,"name":"Canada","label_lon":-110.2438,"label_lat":56.7019,"rings":[[[-122.84,49.0],[-122.9742,49.0025],[-124.9102,49.9846],[-125.6246,50.4166],[-127.4356,50.8306],[-127.9928,51.7158],[-127.8503,52.3296],[-129.1298,52.7554],[-129.3052,53.5616],[-130.515,54.2876],[-130.5361,54.8028],[-130.5361,54.8028],[-129.98,55.285],[-130.0078,55.9158],[-131.7078,56.5521],[-132.7304,57.6929],[-133.3556,58.4103],[-134.2711,58.8611],[-134.945,59.2706],[-135.4758,59.7878],[-136.4797,59.4639],[-137.4525,58.905],[-138.3409,59.5621],[-139.039,60.0],[-140.013,60.2768],[-140.9978,60.3064],[-140.9925,66.0],[-140.986,69.712],[-140.986,69.712],[-139.1205,69.471],[-137.5464,68.99],[-136.5036,68.898],[-135.6258,69.3151],[-134.4146,69.6274],[-132.9292,69.5053],[-131.4314,69.9445],[-129.7947,70.1937],[-129.1077,69.7793],[-128.3616,70.0129],[-128.1382,70.4838],[-127.4471,70.3772],[-125.7563,69.4806],[-124.4248,70.1584],[-124.2897,69.3997],[-123.0611,69.5637],[-122.6835,69.8555],[-121.4723,69.7978],[-119.9429,69.3779],[-117.6027,69.0113],[-116.2264,68.8415],[-115.2469,68.9059],[-113.8979,68.3989],[-115.3049,67.9026],[-113.4973,67.6881],[-110.798,67.8061],[-109.9462,67.981],[-108.8802,67.3814],[-107.7924,67.8874],[-108.813,68.3116],[-108.1672,68.6539],[-106.95,68.7],[-106.15,68.8],[-105.3428,68.5612],[-104.3379,68.018],[-103.2211,68.0978],[-101.4543,67.6469],[-99.9019,67.8057],[-98.4432,67.7816],[-98.5586,68.4039],[-97.6695,68.5786],[-96.1199,68.2394],[-96.1259,67.2934],[-95.4894,68.0907],[-94.685,68.0638],[-94.2328,69.069],[-95.3041,69.6857],[-96.4713,70.0898],[-96.3911,71.1948],[-95.2088,71.9205],[-93.89,71.7601],[-92.8782,71.3187],[-91.5196,70.1913],[-92.4069,69.7],[-90.5471,69.4977],[-90.5515,68.475],[-89.2151,69.2587],[-88.0197,68.6151],[-88.3175,67.8734],[-87.3502,67.1987],[-86.3061,67.9215],[-85.5766,68.7846],[-85.522,69.8821],[-84.1008,69.8054],[-82.6226,69.6583],[-81.2804,69.162],[-81.2202,68.6657],[-81.9644,68.1325],[-81.2593,67.5972],[-81.3865,67.1108],[-83.3446,66.4115],[-84.7354,66.2573],[-85.7694,66.5583],[-86.0676,66.0563],[-87.0314,65.213],[-87.3232,64.7756],[-88.483,64.099],[-89.9144,64.0327],[-90.704,63.6102],[-90.77,62.9602],[-91.9334,62.8351],[-93.157,62.0247],[-94.2415,60.8987],[-94.6293,60.1102],[-94.6846,58.9488],[-93.215,58.7821],[-92.7646,57.8457],[-92.297,57.0871],[-90.8977,57.2847],[-89.0395,56.8517],[-88.0398,56.4716],[-87.3242,55.9991],[-86.0712,55.7238],[-85.0118,55.3026],[-83.3606,55.2449],[-82.2729,55.1483],[-82.4362,54.2823],[-82.125,53.277],[-81.4008,52.1579],[-79.9129,51.2084],[-79.143,51.5339],[-78.6019,52.5621],[-79.1242,54.1414],[-79.8296,54.6677],[-78.2287,55.1365],[-77.0956,55.8374],[-76.5414,56.5342],[-76.6232,57.2026],[-77.3023,58.0521],[-78.5169,58.8046],[-77.3368,59.8526],[-77.7727,60.7579],[-78.1069,62.3196],[-77.4107,62.5505],[-75.6962,62.2784],[-74.6682,62.1811],[-73.8399,62.4438],[-72.9085,62.1051],[-71.6771,61.5254],[-71.3737,61.1372],[-69.5904,61.0614],[Line truncated
@@ -138,6 +138,14 @@ class AwardBoundaryOverlay : Overlay() {
style = Paint.Style.FILL style = Paint.Style.FILL
color = Color.argb(90, 76, 217, 100) color = Color.argb(90, 76, 217, 100)
} }
private val dotPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
style = Paint.Style.FILL
color = Color.argb(230, 64, 68, 76)
}
private val workedDotPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
style = Paint.Style.FILL
color = Color.argb(230, 76, 217, 100)
}
/** /**
* Recolor the amber boundary/label paints for the active map theme (dark amber + white halo on * Recolor the amber boundary/label paints for the active map theme (dark amber + white halo on
@@ -172,6 +180,15 @@ class AwardBoundaryOverlay : Overlay() {
/** Codes of worked regions -> green fill. */ /** Codes of worked regions -> green fill. */
var workedCodes: Set<String> = emptySet() var workedCodes: Set<String> = emptySet()
/** Code of the tapped region whose detail dialog is open -> thicker outline. */
var selectedCode: String? = null
/** The region under a tap at the given geo point on the map at [zoom],
* or null when the tap lands on empty space. Delegates to [AwardHitTest]
* so the tap tolerance matches the tiny-region dots drawn in [draw]. */
fun regionAt(latitude: Double, longitude: Double, zoom: Double): AwardRegion? =
AwardHitTest.hitRegion(regions, regionBounds, latitude, longitude, zoom)
/** Bounding boxes (computed on region assignment) for cheap culling. */ /** Bounding boxes (computed on region assignment) for cheap culling. */
private val regionBounds = mutableListOf<DoubleArray>() // [minLon,minLat,maxLon,maxLat] private val regionBounds = mutableListOf<DoubleArray>() // [minLon,minLat,maxLon,maxLat]
@@ -211,6 +228,14 @@ class AwardBoundaryOverlay : Overlay() {
if (b[1] > topLat || b[3] < bottomLat) continue if (b[1] > topLat || b[3] < bottomLat) continue
if (b[0] > rightLon || b[2] < leftLon) continue if (b[0] > rightLon || b[2] < leftLon) continue
val worked = region.code in workedCodes val worked = region.code in workedCodes
val selected = region.code == selectedCode
// Projected bbox size: decides between the region's own shape and
// the minimum-size dot below, and gates the label.
val regionW = abs((b[2] - b[0]) / 360.0 * worldWidthPx).toFloat()
val regionH =
abs((projectionToY(projection, b[3]) ?: 0f) - (projectionToY(projection, b[1]) ?: 0f))
val tiny = max(regionW, regionH) <= AwardHitTest.MIN_SHAPE_PX
val path = Path() val path = Path()
var pathHasPoints = false var pathHasPoints = false
@@ -237,10 +262,30 @@ class AwardBoundaryOverlay : Overlay() {
pathHasPoints = traceSegment(path, segment, projection, centerLon, worldWidthPx, closeRing) || pathHasPoints pathHasPoints = traceSegment(path, segment, projection, centerLon, worldWidthPx, closeRing) || pathHasPoints
} }
} }
if (!pathHasPoints) continue if (!pathHasPoints && !tiny) continue
if (worked) canvas.drawPath(path, workedPaint) if (worked) canvas.drawPath(path, workedPaint)
linePaint.strokeWidth = if (selected) LINE_WIDTH_SELECTED else LINE_WIDTH
canvas.drawPath(path, linePaint) canvas.drawPath(path, linePaint)
linePaint.strokeWidth = LINE_WIDTH
// Tiny-region fallback (Macao, island DXCCs at low zoom): the own
// shape is sub-pixel and invisible, so draw a minimum-size dot at
// the bbox centre — green when worked — and let it carry the
// region. AwardHitTest gives small regions (dot-sized included) an
// inflated MIN_TAP_TARGET_PX square hit area at the same centre,
// so the dot is reliably tappable at any zoom (Tianditu tiles have
// no zoom cap, so "just zoom in" is not a workable answer for
// finding these).
if (tiny) {
val cx = projectionToX(projection, normalizeLon((b[0] + b[2]) / 2.0), centerLon, worldWidthPx)
val cy = projectionToY(projection, (b[1] + b[3]) / 2.0)
if (cx != null && cy != null) {
val r = if (selected) DOT_RADIUS_PX + 3f else DOT_RADIUS_PX
canvas.drawCircle(cx, cy, r, if (worked) workedDotPaint else dotPaint)
canvas.drawCircle(cx, cy, r, linePaint)
}
}
// Label: draw when its anchor is on screen and the region is big // Label: draw when its anchor is on screen and the region is big
// enough on screen to hold the text (avoid clutter at low zoom). // enough on screen to hold the text (avoid clutter at low zoom).
@@ -250,11 +295,10 @@ class AwardBoundaryOverlay : Overlay() {
val lx = projectionToX(projection, labelLonNorm, centerLon, worldWidthPx) ?: continue val lx = projectionToX(projection, labelLonNorm, centerLon, worldWidthPx) ?: continue
val ly = projectionToY(projection, region.labelLat) ?: continue val ly = projectionToY(projection, region.labelLat) ?: continue
// Screen-size gate: the label only when the region spans enough px. // Screen-size gate: the label only when the region spans enough px.
// force_label regions (HK / Macau) always draw — their tiny land // force_label regions (HK / Macau) and tiny regions always draw —
// bbox would otherwise stay below the gate forever, and their label // their bbox would otherwise stay below the gate forever, and they
// anchor sits out on the sea where there is room for the text. // are exactly the ones that would otherwise be unfindable.
val regionH = (projectionToY(projection, b[3]) ?: 0f) - (projectionToY(projection, b[1]) ?: 0f) if (abs(regionH) < MIN_LABEL_REGION_PX && !region.forceLabel && !tiny) continue
if (abs(regionH) < MIN_LABEL_REGION_PX && !region.forceLabel) continue
val label = if (useEnglishLabels) region.nameEn ?: region.name else region.name val label = if (useEnglishLabels) region.nameEn ?: region.name else region.name
canvas.drawText(label, lx, ly - textHalfHeight, labelPaint) canvas.drawText(label, lx, ly - textHalfHeight, labelPaint)
} }
@@ -424,5 +468,8 @@ class AwardBoundaryOverlay : Overlay() {
private companion object { private companion object {
const val MIN_LABEL_REGION_PX = 42f const val MIN_LABEL_REGION_PX = 42f
const val MAX_MERCATOR_LAT = 85.05113 // Web Mercator latitude limit const val MAX_MERCATOR_LAT = 85.05113 // Web Mercator latitude limit
const val LINE_WIDTH = 1.5f
const val LINE_WIDTH_SELECTED = 3f
const val DOT_RADIUS_PX = 6f
} }
} }
@@ -0,0 +1,239 @@
/*
* Look4Sat. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2019-2026 Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.feature.map
import kotlin.math.abs
import kotlin.math.cos
import kotlin.math.hypot
import kotlin.math.max
import kotlin.math.pow
/**
* Tap hit-testing for the award boundary overlay: which region sits under the
* user's finger in DXCC/WAPC/WAJA/WAZ/WAS view. Pure geometry (no osmdroid
* types) so it is unit-testable; the overlay delegates to [hitRegion].
*
* Three hit shapes, mirroring how the overlay draws:
* - a small region (projects to <= [MIN_TAP_TARGET_PX] at the current zoom —
* including the dot-rendered ones) is hit by an inflated square target of
* [MIN_TAP_TARGET_PX] around its bbox centre, so finger taps land even on
* dot-sized islands (user request 2026-10-05);
* - a normal region is hit by an even-odd point-in-polygon test on its rings.
* Small targets win over polygon hits (Macao sits inside Guangdong's outline),
* and among polygon hits the smallest region wins (Hong Kong over China).
*
* - a scattered sea archipelago (>= 2 disjoint island rings, every island
* small — see [ARCHIPELAGO_MAX_RING_DEG]) is additionally hit anywhere in
* its overall envelope, so tapping open sea between the islands lands on
* the entity (user request 2026-10-06: 海上的群岛按照整体包络来判断 hit).
* Priority: small target > land polygon > archipelago envelope — tapping the
* Spanish coast that happens to lie in the Balearic envelope still opens
* Spain; only sea inside the envelope belongs to the archipelago.
*/
object AwardHitTest {
/** Projected size (max of width/height, px) below which a region is
* rendered as a minimum-size dot instead of its own shape. */
const val MIN_SHAPE_PX = 8f
/** Minimum tap target (px, square) for SMALL regions: any region whose
* projected size is below this gets an inflated square hit area of this
* size around its bbox centre (user request 2026-10-05 — small DXCCs
* like Hong Kong are hard to hit with a finger at their own size). */
const val MIN_TAP_TARGET_PX = 80f
/** Largest per-island span (deg) for a region to count as a scattered sea
* archipelago: every ring must be a small island, never a mainland
* (Japan/UK/Indonesia stay polygon-only). Archipelago islands are
* finger-proof at low zoom, so their hit region is the overall envelope. */
const val ARCHIPELAGO_MAX_RING_DEG = 2.5
/**
* The region under a tap at [tapLat]/[tapLon] on the map at [zoom], or
* null when the tap lands on empty space. [bounds] holds each region's
* `[minLon, minLat, maxLon, maxLat]` in [regions] order
* (AwardBoundaryOverlay.regionBounds).
*/
fun hitRegion(
regions: List<AwardRegion>,
bounds: List<DoubleArray>,
tapLat: Double,
tapLon: Double,
zoom: Double
): AwardRegion? {
val worldWidthPx = 256.0 * 2.0.pow(zoom)
val lonDegPerPx = 360.0 / worldWidthPx
val latDegPerPx = (lonDegPerPx * cos(Math.toRadians(tapLat))).coerceAtLeast(1e-9)
var bestSmall: AwardRegion? = null
var bestSmallDist = Double.MAX_VALUE
var bestPoly: AwardRegion? = null
var bestPolyArea = Double.MAX_VALUE
var bestArch: AwardRegion? = null
var bestArchArea = Double.MAX_VALUE
for (i in regions.indices) {
val b = bounds.getOrNull(i) ?: continue
// Cheap bbox prefilter with the tap-target slack (longitudes wrap).
val slack = MIN_TAP_TARGET_PX / 2.0
if (tapLat < b[1] - slack * latDegPerPx || tapLat > b[3] + slack * latDegPerPx) continue
val cLon = (b[0] + b[2]) / 2.0
val dLon = wrap180(tapLon - cLon)
if (abs(dLon) > (b[2] - b[0]) / 2.0 + slack * lonDegPerPx) continue
val w = (b[2] - b[0]) / lonDegPerPx
val h = (b[3] - b[1]) / latDegPerPx
val cLat = (b[1] + b[3]) / 2.0
val dx = dLon / lonDegPerPx
val dy = (tapLat - cLat) / latDegPerPx
val dist = hypot(dx, dy)
val region = regions[i]
if (max(w, h) <= MIN_TAP_TARGET_PX) {
// Small region: hit by an inflated square target around the
// bbox centre — at least MIN_TAP_TARGET_PX on each side — so
// finger taps land even on dot-sized entities.
if (abs(dx) <= slack && abs(dy) <= slack && dist < bestSmallDist) {
bestSmall = region
bestSmallDist = dist
}
} else if (contains(region, tapLat, tapLon)) {
// Land polygon hit: the smallest enclosing region wins.
if (w * h < bestPolyArea) {
bestPoly = region
bestPolyArea = w * h
}
} else if (isArchipelago(region)) {
// Scattered sea archipelago: the overall envelope is the hit
// region (open sea between the islands belongs to the entity).
// The envelope is measured in one unwrapped longitude frame so
// antimeridian groups (Fiji-style) keep a contiguous box.
val env = envelope(region)
val halfW = (env[2] - env[1]) / 2.0
val halfH = (env[4] - env[3]) / 2.0
val envLon = env[0] + wrap180(tapLon - env[0])
val inEnvelope =
abs(envLon - (env[1] + env[2]) / 2.0) <= halfW + slack * lonDegPerPx &&
abs(tapLat - (env[3] + env[4]) / 2.0) <= halfH + slack * latDegPerPx
val area = 4.0 * halfW * halfH
if (inEnvelope && area < bestArchArea) {
bestArch = region
bestArchArea = area
}
}
}
return bestSmall ?: bestPoly ?: bestArch
}
/**
* Scattered sea archipelago: at least two disjoint island rings and every
* ring small (<= [ARCHIPELAGO_MAX_RING_DEG] in both lon and lat, measured
* in an unwrapped longitude frame) — no dominant mainland. Mainland
* countries with stray islands (Japan, UK, Indonesia) fail the size test
* and keep plain polygon hits.
*/
fun isArchipelago(region: AwardRegion): Boolean {
if (region.rings.size < 2) return false
for (ring in region.rings) {
if (ring.isEmpty()) continue
val anchor = ring[0][0]
var minX = 0.0
var maxX = 0.0
var minY = ring[0][1]
var maxY = ring[0][1]
for (p in ring) {
val x = wrap180(p[0] - anchor)
if (x < minX) minX = x
if (x > maxX) maxX = x
if (p[1] < minY) minY = p[1]
if (p[1] > maxY) maxY = p[1]
}
if (max(maxX - minX, maxY - minY) > ARCHIPELAGO_MAX_RING_DEG) return false
}
return true
}
/**
* Envelope of all rings in one longitude frame anchored at the first ring's
* first point: `[anchorLon, minX, maxX, minY, maxY]`. Longitudes past ±180
* are unwrapped into the anchor's frame, keeping antimeridian groups whole.
*/
private fun envelope(region: AwardRegion): DoubleArray {
val first = region.rings.firstOrNull { it.isNotEmpty() } ?: return doubleArrayOf(0.0, 0.0, 0.0, 0.0, 0.0)
val anchor = first[0][0]
var minX = 0.0
var maxX = 0.0
var minY = first[0][1]
var maxY = first[0][1]
for (ring in region.rings) {
for (p in ring) {
val x = wrap180(p[0] - anchor)
if (x < minX) minX = x
if (x > maxX) maxX = x
if (p[1] < minY) minY = p[1]
if (p[1] > maxY) maxY = p[1]
}
}
return doubleArrayOf(anchor, anchor + minX, anchor + maxX, minY, maxY)
}
/** Even-odd point-in-polygon across all rings (rings are disjoint parts). */
fun contains(region: AwardRegion, lat: Double, lon: Double): Boolean =
region.rings.any { ringContains(it, lat, lon) }
/**
* Point-in-ring with the ring unwrapped into one continuous longitude
* frame (same trick as the overlay's tracer). Rings that cross the
* antimeridian (Fiji, the Pacific CQ zones) are stored with raw lons past
* ±180 or folded; testing them in raw coordinates would split the polygon
* at the date line and miss taps inside it. The tap is shifted to the
* frame's nearest world copy first.
*/
private fun ringContains(ring: List<DoubleArray>, lat: Double, lon: Double): Boolean {
val n = ring.size
if (n < 3) return false
val anchor = ring[0][0]
val lons = DoubleArray(n)
var prevDelta = 0.0
for (i in 0 until n) {
var delta = ring[i][0] - anchor
if (i > 0) {
while (delta - prevDelta > 180.0) delta -= 360.0
while (prevDelta - delta > 180.0) delta += 360.0
}
lons[i] = anchor + delta
prevDelta = delta
}
val tapLon = anchor + wrap180(lon - anchor)
var inside = false
for (j in 0 until n) {
val k = (j + 1) % n
val y1 = ring[j][1]
val y2 = ring[k][1]
if ((y1 > lat) != (y2 > lat)) {
val x = lons[j] + (lat - y1) / (y2 - y1) * (lons[k] - lons[j])
if (x > tapLon) inside = !inside
}
}
return inside
}
/** Shortest signed longitude difference a - b, in (-180, 180]. */
private fun wrap180(deg: Double): Double {
var d = deg % 360.0
if (d <= -180.0) d += 360.0
if (d > 180.0) d -= 360.0
return d
}
}
@@ -32,6 +32,7 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.IntrinsicSize import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.heightIn
@@ -85,7 +86,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
@@ -93,10 +98,13 @@ import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
import com.rtbishop.look4sat.core.presentation.IconCard import com.rtbishop.look4sat.core.presentation.IconCard
import com.rtbishop.look4sat.core.presentation.NextPassRow import com.rtbishop.look4sat.core.presentation.NextPassRow
import com.rtbishop.look4sat.core.presentation.R import com.rtbishop.look4sat.core.presentation.R
import com.rtbishop.look4sat.core.presentation.SheetDialogTitle
import com.rtbishop.look4sat.core.presentation.TimerRow import com.rtbishop.look4sat.core.presentation.TimerRow
import com.rtbishop.look4sat.core.presentation.TopBar import com.rtbishop.look4sat.core.presentation.TopBar
import com.rtbishop.look4sat.core.presentation.isVerticalLayout import com.rtbishop.look4sat.core.presentation.isVerticalLayout
import com.rtbishop.look4sat.core.presentation.layoutPadding import com.rtbishop.look4sat.core.presentation.layoutPadding
import com.rtbishop.look4sat.core.presentation.sheetDialogContainerColor
import com.rtbishop.look4sat.core.presentation.sheetDialogShape
import org.osmdroid.tileprovider.MapTileProviderBasic import org.osmdroid.tileprovider.MapTileProviderBasic
import org.osmdroid.tileprovider.tilesource.OnlineTileSourceBase import org.osmdroid.tileprovider.tilesource.OnlineTileSourceBase
import org.osmdroid.tileprovider.tilesource.XYTileSource import org.osmdroid.tileprovider.tilesource.XYTileSource
@@ -297,16 +305,13 @@ private fun MapScreen(
// Tapped worked grid -> centered QSO dialog. Local UI state: the map is the // Tapped worked grid -> centered QSO dialog. Local UI state: the map is the
// only consumer and it resets when leaving the page. // only consumer and it resets when leaving the page.
var selectedGrid by remember { mutableStateOf<String?>(null) } var selectedGrid by remember { mutableStateOf<String?>(null) }
// Tapped award region (DXCC/WAPC/WAJA/WAZ/WAS) -> its QSO detail dialog,
// paired with the award type it was tapped under. Local UI state like the
// grid selection above: the map is the only consumer.
var selectedRegion by remember { mutableStateOf<Pair<AwardType, AwardRegion>?>(null) }
// 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
@@ -314,18 +319,24 @@ private fun MapScreen(
// can differ between records of the same physical 台址 when LoTW omits // can differ between records of the same physical 台址 when LoTW omits
// optional fields). Data synced before multi-grid support falls back to // optional fields). Data synced before multi-grid support falls back to
// one group per grid, preserving the old selector exactly. // one group per grid, preserving the old selector exactly.
// Each group's `worked` cells are derived through scopedToStation (the
// covering rule): a single-grid 台址 also counts the cells worked by any
// multi-grid 台址 covering it (OL62 ← OL62/63, 四格点同理) — the reverse
// never leaks (2026-10-05 用户定稿, 方案 A).
val stationGroups: List<StationGroup> = remember(uiState.workedGridQsos) { val stationGroups: List<StationGroup> = remember(uiState.workedGridQsos) {
val byGridSet = LinkedHashMap<String, Pair<Set<String>, MutableSet<String>>>() val gridSets = LinkedHashMap<String, Set<String>>()
for ((grid, qsos) in uiState.workedGridQsos) { for ((_, 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 gridSets.getOrPut(key) { q.myStationGrids() }
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) } gridSets.map { (key, grids) ->
.sortedByDescending { it.workedCount } val worked = uiState.workedGridQsos.filterValues { qsos ->
qsos.scopedToStation(key).isNotEmpty()
}.keys
StationGroup(id = key, grids = grids, worked = worked)
}.sortedByDescending { it.workedCount }
} }
// Selected 台址 for VUCC counting; defaults to the group with the most // Selected 台址 for VUCC counting; defaults to the group with the most
// worked grids. Null = "All" (every 台址 combined). Empty when the store // worked grids. Null = "All" (every 台址 combined). Empty when the store
@@ -349,6 +360,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
@@ -362,6 +380,20 @@ private fun MapScreen(
val receiver = object : org.osmdroid.events.MapEventsReceiver { val receiver = object : org.osmdroid.events.MapEventsReceiver {
override fun singleTapConfirmedHelper(p: GeoPoint?): Boolean { override fun singleTapConfirmedHelper(p: GeoPoint?): Boolean {
if (!isGridMode || p == null) return false if (!isGridMode || p == null) return false
// Award views (DXCC/WAPC/WAJA/WAZ/WAS): the tap resolves to the
// boundary region under the finger and opens its QSO detail
// dialog. No zoom gate here — unlike the grid path below — so
// tiny islands stay reachable while zoomed out (Tianditu tiles
// have no zoom cap, so "just zoom in" is not an answer).
val awardMode = selectedAward?.takeIf { it != AwardType.VUCC }
if (awardMode != null) {
val overlay = mapView.overlays.getOrNull(OVERLAY_GRID) as? AwardBoundaryOverlay
?: return false
val region = overlay.regionAt(p.latitude, p.longitude, mapView.zoomLevelDouble)
?: return false
selectedRegion = awardMode to region
return true
}
val zoom = mapView.zoomLevelDouble val zoom = mapView.zoomLevelDouble
// Allow tapping worked cells as soon as the 4-char grid LINES // Allow tapping worked cells as soon as the 4-char grid LINES
// appear (GRID_ZOOM_SUB); grid labels now appear at that zoom too. // appear (GRID_ZOOM_SUB); grid labels now appear at that zoom too.
@@ -384,6 +416,12 @@ private fun MapScreen(
(mapView.overlays.getOrNull(OVERLAY_GRID) as? MaidenheadGridOverlay)?.selectedGrid = selectedGrid (mapView.overlays.getOrNull(OVERLAY_GRID) as? MaidenheadGridOverlay)?.selectedGrid = selectedGrid
mapView.invalidate() mapView.invalidate()
} }
// Same for the award boundary overlay's selected region.
LaunchedEffect(selectedRegion) {
(mapView.overlays.getOrNull(OVERLAY_GRID) as? AwardBoundaryOverlay)?.selectedCode =
selectedRegion?.second?.code
mapView.invalidate()
}
LaunchedEffect(uiState.track) { LaunchedEffect(uiState.track) {
// In grid mode the map is centered on the local grid square; following // In grid mode the map is centered on the local grid square; following
// the satellite subpoint here would override that centering on every // the satellite subpoint here would override that centering on every
@@ -536,7 +574,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,
@@ -547,6 +589,22 @@ private fun MapScreen(
onPinMark = { call -> onAction(MapAction.PinMarkedStation(grid, call)) } onPinMark = { call -> onAction(MapAction.PinMarkedStation(grid, call)) }
) )
} }
// Region detail dialog for a tapped award region. Read-only: the Match
// button and station marks are grid-scoped and stay hidden here. The list
// is the GLOBAL one (all 台址) — the non-VUCC awards count and fill the
// map globally, so their detail lists use the same scope.
selectedRegion?.let { (type, region) ->
val regionQsos = remember(uiState.workedGridQsos, type, region) {
AwardCalculator.regionQsos(uiState.workedGridQsos, type, region.code)
}
RegionQsoDialog(
type = type,
region = region,
qsos = regionQsos,
isUtc = uiState.isUtc,
onDismiss = { selectedRegion = null }
)
}
} }
// region Worked-grid QSO dialog // region Worked-grid QSO dialog
@@ -676,7 +734,9 @@ private fun WorkedGridQsoDialog(
if (showMarkInput) { if (showMarkInput) {
androidx.compose.material3.AlertDialog( androidx.compose.material3.AlertDialog(
onDismissRequest = { showMarkInput = false }, onDismissRequest = { showMarkInput = false },
title = { Text(stringResource(R.string.grid_mark_station)) }, shape = sheetDialogShape(),
containerColor = sheetDialogContainerColor(),
title = { SheetDialogTitle(stringResource(R.string.grid_mark_station)) },
text = { text = {
androidx.compose.material3.OutlinedTextField( androidx.compose.material3.OutlinedTextField(
value = callInput, value = callInput,
@@ -701,6 +761,110 @@ private fun WorkedGridQsoDialog(
} }
} }
/**
* Centered dialog listing the confirmed satellite QSOs counted for a tapped
* award region (DXCC/WAPC/WAJA/WAZ/WAS). Read-only variant of the worked-grid
* dialog: the header shows the region name + code, and the same expandable
* callsign rows. Match / station marks are grid-scoped and stay hidden.
* Dismissed by tapping outside. An unworked region opens with an empty list.
*/
@Composable
private fun RegionQsoDialog(
type: AwardType,
region: AwardRegion,
qsos: List<com.rtbishop.look4sat.core.domain.model.GridQso>,
isUtc: Boolean,
onDismiss: () -> Unit
) {
// WAZ regions are named by their bare zone number in the asset, so the
// title gets the full "CQ ZONE 24" wording (user request 2026-10-05) and
// the code is not repeated in the subtitle. DXCC carries its award prefix
// in the subtitle ("DXCC 318", same request); other awards keep the bare
// code.
val isZone = type == AwardType.WAZ
val codeLabel = when {
isZone -> ""
type == AwardType.DXCC -> "DXCC ${region.code} · "
else -> "${region.code} · "
}
androidx.compose.ui.window.Dialog(onDismissRequest = onDismiss) {
Card(
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerLow),
modifier = Modifier.fillMaxWidth(0.88f)
) {
Column {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.padding(start = 16.dp, end = 8.dp, top = 4.dp, bottom = 4.dp)
// Match the grid dialog header height exactly: its Match button
// enforces a 48dp minimum touch target, inflating the row and
// lowering the title to 6dp from the dialog edge.
.defaultMinSize(minHeight = 48.dp)
) {
// Header mirrors the worked-grid dialog exactly (same font,
// same paddings, same weight layout) — user request
// 2026-10-05: 标题字体/位置与 VUCC 的统一.
Column(Modifier.weight(1f)) {
Text(
text = if (isZone) "CQ ZONE ${region.code}" else regionDisplayName(region),
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.primary,
fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace
)
Text(
text = codeLabel + stringResource(
R.string.grid_qso_calls_count,
qsos.map { it.call }.distinct().size,
qsos.size
),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
androidx.compose.material3.HorizontalDivider()
Column(
Modifier
.heightIn(max = 380.dp)
.verticalScroll(rememberScrollState())
) {
if (qsos.isEmpty()) {
Text(
text = stringResource(R.string.region_qso_empty),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
)
} else {
// Same grouping as the worked-grid dialog: by callsign
// preserving first-contact order (list is oldest-first).
val grouped = remember(qsos) {
qsos.groupBy { it.call }.entries.sortedBy { it.value.first().epochMs }
}
grouped.forEach { (call, callQsos) ->
WorkedGridCallRow(call, callQsos = callQsos, isUtc = isUtc)
androidx.compose.material3.HorizontalDivider(
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.3f)
)
}
}
}
}
}
}
}
/** Region display label, same language rule as the overlay's map labels. */
@Composable
private fun regionDisplayName(region: AwardRegion): String {
val useEnglish =
androidx.compose.ui.platform.LocalConfiguration.current.locales[0].language != "zh"
return if (useEnglish) region.nameEn ?: region.name else region.name
}
@Composable @Composable
private fun MarkedStationRow( private fun MarkedStationRow(
marked: com.rtbishop.look4sat.core.domain.model.MarkedStation, marked: com.rtbishop.look4sat.core.domain.model.MarkedStation,
@@ -725,11 +889,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(
@@ -802,8 +967,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,
@@ -94,7 +94,20 @@ class MapViewModel(
} }
} }
viewModelScope.launch { viewModelScope.launch {
settingsRepo.wavelogSettings.collectLatest { _ -> // Grid + mark data reloads whenever the screen becomes visible (and once at
// init): the LoTW/Wavelog syncs run from Settings or the app start, so
// re-entering the map picks their results up.
isScreenVisible.collect { visible ->
if (visible) refreshMapGridData()
}
}
val (selectedCatNum, _) = satelliteRepo.selectedPass.value
selectDefaultSatellite(if (selectedCatNum != 0) selectedCatNum else -1)
getStationPosition()
}
/** Loads the worked-grid / mark data into the map state (see the visibility collector). */
private fun refreshMapGridData() {
val workedGrids = settingsRepo.getWorkedGrids() val workedGrids = settingsRepo.getWorkedGrids()
val markedGrids = settingsRepo.getMarkedGridStations() val markedGrids = settingsRepo.getMarkedGridStations()
// Marked stations auto-clear once their grid becomes worked: // Marked stations auto-clear once their grid becomes worked:
@@ -111,11 +124,6 @@ class MapViewModel(
) )
} }
} }
}
val (selectedCatNum, _) = satelliteRepo.selectedPass.value
selectDefaultSatellite(if (selectedCatNum != 0) selectedCatNum else -1)
getStationPosition()
}
fun onAction(action: MapAction) { fun onAction(action: MapAction) {
when (action) { when (action) {
@@ -0,0 +1,205 @@
/*
* Look4Sat. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2019-2026 Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.feature.map
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
/**
* Pin the award-region tap rules: tiny regions (rendered as dots) are hit by a
* tap radius around their bbox centre, normal regions by point-in-polygon,
* tiny dots win over enclosing polygons (Macao inside Guangdong), and rings
* crossing the antimeridian stay tappable on both sides of the date line.
*/
class AwardHitTestTest {
/** Axis-aligned box region: [lon1, lon2] x [lat1, lat2] as one ring. */
private fun box(code: String, lon1: Double, lat1: Double, lon2: Double, lat2: Double) = AwardRegion(
code = code,
name = code,
labelLon = (lon1 + lon2) / 2.0,
labelLat = (lat1 + lat2) / 2.0,
rings = listOf(
listOf(
doubleArrayOf(lon1, lat1), doubleArrayOf(lon2, lat1),
doubleArrayOf(lon2, lat2), doubleArrayOf(lon1, lat2)
)
)
)
private fun boundsOf(regions: List<AwardRegion>): List<DoubleArray> = regions.map { r ->
val lons = r.rings.flatten().map { it[0] }
val lats = r.rings.flatten().map { it[1] }
doubleArrayOf(lons.min(), lats.min(), lons.max(), lats.max())
}
private fun hit(regions: List<AwardRegion>, lat: Double, lon: Double, zoom: Double): AwardRegion? =
AwardHitTest.hitRegion(regions, boundsOf(regions), lat, lon, zoom)
@Test
fun `tiny region is hit inside the inflated target and missed beyond it`() {
// Macao-sized box (0.07 x 0.10 deg): 1.6 px wide at zoom 5 -> a dot,
// but its tap target is the MIN_TAP_TARGET_PX square around the centre.
val regions = listOf(box("MO", 113.535, 22.108, 113.604, 22.212))
assertEquals("MO", hit(regions, 22.16, 113.57, 5.0)?.code)
// half a degree away is still inside the 40 px half-target at zoom 5
assertEquals("MO", hit(regions, 22.16, 114.07, 5.0)?.code)
// two degrees away (~45 px) is outside it
assertNull(hit(regions, 22.16, 115.57, 5.0))
}
@Test
fun `small region gets an inflated tap target beyond its own size`() {
// Hong Kong-sized box: 13 x 9 px at zoom 5 — too small for a finger
// at its own size, so the tap target inflates to 80 px square
// (user request 2026-10-05).
val regions = listOf(box("HK", 113.843, 22.178, 114.406, 22.559))
assertEquals("HK", hit(regions, 22.37, 114.77, 5.0)?.code) // ~0.6 deg off = 14 px
assertEquals("HK", hit(regions, 22.37, 115.37, 5.0)?.code) // ~1.2 deg off = 27 px
assertNull(hit(regions, 22.37, 116.37, 5.0)) // ~2.2 deg off = 50 px
}
@Test
fun `normal region is hit by point in polygon only`() {
val regions = listOf(box("GD", 109.5, 20.2, 117.5, 25.6))
assertEquals("GD", hit(regions, 23.1, 113.3, 5.0)?.code)
assertNull(hit(regions, 30.0, 113.3, 5.0))
}
@Test
fun `tiny region wins over the polygon that encloses it`() {
val regions = listOf(
box("GD", 109.5, 20.2, 117.5, 25.6),
box("MO", 113.535, 22.108, 113.604, 22.212)
)
assertEquals("MO", hit(regions, 22.16, 113.57, 5.0)?.code)
// away from the dot the enclosing province answers for its own area
assertEquals("GD", hit(regions, 24.5, 110.0, 5.0)?.code)
}
@Test
fun `smallest enclosing polygon wins`() {
val regions = listOf(
box("CN", 73.0, 18.0, 135.0, 53.6),
box("HK", 113.8, 22.1, 114.5, 22.6)
)
assertEquals("HK", hit(regions, 22.3, 114.1, 5.0)?.code)
}
@Test
fun `ring crossing the antimeridian is tappable on both sides`() {
// Fiji-style ring whose raw lons run past +180 (wide enough to stay in
// the polygon-branch of the hit test).
val region = AwardRegion(
code = "176",
name = "Fiji",
labelLon = 180.0,
labelLat = -18.0,
rings = listOf(
listOf(
doubleArrayOf(172.0, -22.0), doubleArrayOf(188.0, -22.0),
doubleArrayOf(188.0, -14.0), doubleArrayOf(172.0, -14.0)
)
)
)
val regions = listOf(region)
assertEquals("176", hit(regions, -18.0, 175.0, 4.0)?.code)
assertEquals("176", hit(regions, -18.0, -175.0, 4.0)?.code)
assertNull(hit(regions, -18.0, 165.0, 4.0))
}
/** Two 0.2 x 0.2 deg island boxes [lon] apart, as one archipelago entity. */
private fun archipelago(code: String, lon1: Double, lon2: Double, lat: Double = -10.0) =
AwardRegion(
code = code,
name = code,
labelLon = (lon1 + lon2) / 2.0,
labelLat = lat,
rings = listOf(
listOf(
doubleArrayOf(lon1 - 0.1, lat - 0.1), doubleArrayOf(lon1 + 0.1, lat - 0.1),
doubleArrayOf(lon1 + 0.1, lat + 0.1), doubleArrayOf(lon1 - 0.1, lat + 0.1)
),
listOf(
doubleArrayOf(lon2 - 0.1, lat - 0.1), doubleArrayOf(lon2 + 0.1, lat - 0.1),
doubleArrayOf(lon2 + 0.1, lat + 0.1), doubleArrayOf(lon2 - 0.1, lat + 0.1)
)
)
)
@Test
fun `sea archipelago is hit by its overall envelope between the islands`() {
// Two tiny islands 10 deg apart: at zoom 5 the bbox is ~230 px, so the
// per-region inflated target does not apply — the whole envelope does
// (user request 2026-10-06: 海上的群岛按照整体包络来判断 hit).
val regions = listOf(archipelago("191", -165.0, -155.0))
assertEquals("191", hit(regions, -10.0, -160.0, 5.0)?.code) // open sea in the middle
assertEquals("191", hit(regions, -10.0, -156.0, 5.0)?.code) // near the east island
assertNull(hit(regions, -10.0, -152.0, 5.0)) // ~2.8 deg past the envelope
}
@Test
fun `small entity inside the archipelago envelope keeps its own target`() {
val regions = listOf(
archipelago("191", -165.0, -155.0),
box("KS", -161.1, -9.6, -160.9, -9.4) // tiny standalone island in the gap
)
// Near KS the small target (80 px) wins over the archipelago envelope.
assertEquals("KS", hit(regions, -9.5, -160.4, 5.0)?.code)
// Away from it the open sea belongs to the archipelago.
assertEquals("191", hit(regions, -10.0, -156.0, 5.0)?.code)
}
@Test
fun `mainland country with a stray island is not envelope hit`() {
// Big mainland ring + one small island ring: sea inside the bbox is NOT
// a tap target (the group is not a scattered archipelago).
val regions = listOf(
AwardRegion(
code = "JP",
name = "JP",
labelLon = 133.0,
labelLat = 37.0,
rings = listOf(
listOf(
doubleArrayOf(130.0, 31.0), doubleArrayOf(135.0, 31.0),
doubleArrayOf(135.0, 42.0), doubleArrayOf(130.0, 42.0)
),
listOf(
doubleArrayOf(138.0, 34.2), doubleArrayOf(138.3, 34.2),
doubleArrayOf(138.3, 34.5), doubleArrayOf(138.0, 34.5)
)
)
)
)
assertNull(hit(regions, 35.0, 137.0, 5.0)) // sea inside the bbox
assertEquals("JP", hit(regions, 37.0, 132.0, 5.0)?.code) // on the mainland
assertEquals("JP", hit(regions, 34.35, 138.15, 5.0)?.code) // on the island itself
}
@Test
fun `archipelago envelope crossing the antimeridian stays contiguous`() {
// Two islands straddling 180: the raw bbox is 357 deg wide, but the
// unwrapped envelope is a tight 3 deg box around the date line.
val regions = listOf(archipelago("FJ", 178.65, -178.65, lat = -18.0))
assertEquals("FJ", hit(regions, -18.0, 180.0, 5.0)?.code) // sea gap at the date line
assertEquals("FJ", hit(regions, -18.0, -180.0, 5.0)?.code) // same, other sign
assertNull(hit(regions, -18.0, 176.0, 5.0)) // well west of the group
}
}
@@ -0,0 +1,138 @@
/*
* 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 2 of the License, or
* (at your option) any later version.
*/
package com.rtbishop.look4sat.feature.map
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.test.assertTopPositionInRootIsEqualTo
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.dp
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
/**
* Regression test for dialog header spacing parity. The worked-grid (VUCC) dialog header
* carries a trailing Match button whose 48dp minimum touch target inflates the header row,
* pushing its title down to 6dp from the dialog edge. Region dialogs (DXCC/WAPC/WAJA/WAS/
* WAZ) without the button used to hug the top edge at 4dp, reading as cramped. The region
* header now pins the same 48dp row height, so BOTH titles must land at the identical
* offset below their header's top edge. Measured via real Compose layout (not guessed),
* see look4sat-customization/references/robolectric-compose-ui-testing-wsl-2026-09.md.
*/
@RunWith(AndroidJUnit4::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE)
@Config(sdk = [34])
class DialogHeaderSpacingTest {
@get:Rule
val composeRule = createComposeRule()
@Test
fun gridAndRegionHeadersPlaceTitlesAtTheSameOffset() {
composeRule.setContent {
MaterialTheme {
Surface(modifier = Modifier.fillMaxSize()) {
Box(modifier = Modifier.fillMaxSize()) {
// Fixed anchors make the expected offsets unambiguous:
// each title must sit exactly TITLE_OFFSET below its header.
Box(modifier = Modifier.offset(y = GRID_Y)) {
GridHeader()
}
Box(modifier = Modifier.offset(y = REGION_Y)) {
RegionHeader()
}
}
}
}
}
composeRule.waitForIdle()
composeRule.onNodeWithTag(GRID_TITLE_TAG)
.assertTopPositionInRootIsEqualTo(GRID_Y + TITLE_OFFSET)
composeRule.onNodeWithTag(REGION_TITLE_TAG)
.assertTopPositionInRootIsEqualTo(REGION_Y + TITLE_OFFSET)
}
/** Grid-dialog header replica: title + counts column plus the Match button. */
@Composable
private fun GridHeader() {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(start = 16.dp, end = 8.dp, top = 4.dp, bottom = 4.dp)
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = "OL62",
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.primary,
fontFamily = FontFamily.Monospace,
modifier = Modifier.testTag(GRID_TITLE_TAG)
)
Text(text = "3 callsigns · 5 QSOs", style = MaterialTheme.typography.bodySmall)
}
TextButton(onClick = {}) {
Text(text = "Match", style = MaterialTheme.typography.labelLarge)
}
}
HorizontalDivider()
}
/** Region-dialog header replica: identical, without the trailing button. */
@Composable
private fun RegionHeader() {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.padding(start = 16.dp, end = 8.dp, top = 4.dp, bottom = 4.dp)
.defaultMinSize(minHeight = 48.dp)
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = "CQ ZONE 24",
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.primary,
fontFamily = FontFamily.Monospace,
modifier = Modifier.testTag(REGION_TITLE_TAG)
)
Text(text = "3 callsigns · 5 QSOs", style = MaterialTheme.typography.bodySmall)
}
}
HorizontalDivider()
}
private companion object {
const val GRID_TITLE_TAG = "gridDialogTitle"
const val REGION_TITLE_TAG = "regionDialogTitle"
val GRID_Y = 100.dp
val REGION_Y = 300.dp
/** Title offset from the header's top edge — must be identical for both dialogs. */
val TITLE_OFFSET = 6.dp
}
}
@@ -0,0 +1,223 @@
/*
* Look4Sat. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2019-2026 Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.feature.map
import java.io.File
import org.json.JSONObject
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* Guards `assets/awards/dxcc.json`, the DXCC boundary asset behind the grid-mode
* award overlay (`AwardBoundaryOverlay` matches worked codes against
* `region.code`). The 2026-10 rebuild split Russia into European (54) /
* Asiatic (15), extracted sub-entities (Alaska, Hawaii, East Malaysia,
* French Guiana, Corsica, Crete, Sardinia, Kaliningrad, Franz Josef Land)
* and partitioned the UK into England/Scotland/Wales/Northern Ireland —
* and re-keyed Germany (81 -> 230) / Palestine (196 -> 510) to current ADIF
* codes — these tests pin the invariants that regeneration must keep.
*/
class DxccAssetTest {
private val assetRelPath = "src/main/assets/awards/dxcc.json"
private data class CityCheck(
val name: String, val lon: Double, val lat: Double,
val inside: Int, val outside: Int
)
private fun loadRegions(): List<JSONObject> {
// Unit-test working dir varies (module dir vs repo root) — walk upwards.
val file = generateSequence(File("").absoluteFile) { it.parentFile }
.mapNotNull { base ->
listOf(File(base, assetRelPath), File(base, "feature/map/$assetRelPath"))
.firstOrNull { it.isFile }
}
.firstOrNull()
?: error("dxcc.json not found starting from ${File("").absolutePath}")
val root = JSONObject(file.readText())
val arr = root.getJSONArray("regions")
return (0 until arr.length()).map { arr.getJSONObject(it) }
}
private fun List<JSONObject>.byCode(code: Int): JSONObject =
first { it.getInt("code") == code }
/** Even-odd ray casting across all rings of a region (rings are disjoint parts). */
private fun regionContains(region: JSONObject, lon: Double, lat: Double): Boolean {
val rings = region.getJSONArray("rings")
var inside = false
for (r in 0 until rings.length()) {
val ring = rings.getJSONArray(r)
val n = ring.length()
for (j in 0 until n) {
val a = ring.getJSONArray(j)
val b = ring.getJSONArray((j + 1) % n)
val y1 = a.getDouble(1)
val y2 = b.getDouble(1)
if ((y1 > lat) != (y2 > lat)) {
val x = a.getDouble(0) + (lat - y1) / (y2 - y1) * (b.getDouble(0) - a.getDouble(0))
if (x > lon) inside = !inside
}
}
}
return inside
}
@Test
fun `required ADIF entity codes are present exactly once`() {
val regions = loadRegions()
val codes = regions.map { it.getInt("code") }
assertEquals("duplicate codes", codes.size, codes.toSet().size)
val required = listOf(
15, 54, // Asiatic / European Russia
6, 110, 291, // Alaska / Hawaii / rest of USA
46, 299, // East / West Malaysia
63, 214, 227, // French Guiana / Corsica / rest of France
40, 236, // Crete / rest of Greece
225, 248, // Sardinia / rest of Italy
126, 61, // Kaliningrad / Franz Josef Land
223, 279, 294, 265, // England / Scotland / Wales / N. Ireland
230, 510 // Germany / Palestine (current ADIF codes)
)
required.forEach { code -> assertTrue("missing code $code", code in codes) }
}
@Test
fun `island and microstate batch is present`() {
// 2026-10 additions (Natural Earth 1:10m admin-0 map units): common
// island / microstate DXCC entities that 1:110m never carried, so a
// worked entity like Singapore (381) could not light up at all.
val regions = loadRegions()
val codes = regions.map { it.getInt("code") }
val batch = listOf(
381, 304, // Singapore / Bahrain
203, 260, 278, 251, 295, 233, // European microstates + Gibraltar
257, 21, 29, 149, 256, 222, 114, 122, 106, // Malta / Balearic / Canary / Azores / Madeira / Faroe / Man / Jersey / Guernsey
62, 64, 65, 285, 69, 89, 91, 517, 79, 84, // Caribbean
94, 95, 97, 77, 98, 249, 96,
103, 166, 9, 190, 160, 175, 191, 234 // Pacific
)
batch.forEach { code -> assertTrue("missing code $code", code in codes) }
assertEquals("Singapore", regions.byCode(381).getString("name"))
assertEquals("Canary Is.", regions.byCode(29).getString("name"))
}
@Test
fun `B-plan island batch is present`() {
// 2026-10 B plan: every remaining DXCC entity that has a REAL Natural
// Earth 1:10m polygon (own feature or a ring window split out of a
// parent: Rodrigues/Agalega of Mauritius, Ducie of Pitcairn, South
// Georgia/Sandwich, the St. Helena family). Atoll/reef entities
// without land polygons stay missing by design.
val regions = loadRegions()
val codes = regions.map { it.getInt("code") }
val batch = listOf(
4, 5, 12, 20, 22, 24, 35, 36, 38, 105, 111, 118, 123, 157, 159,
165, 168, 169, 172, 173, 174, 182, 188, 189, 197, 205, 207,
213, 219, 235, 240, 247, 250, 259, 270, 274, 277, 282, 297,
298, 379, 409, 411, 453, 506, 513, 516, 518, 522
)
batch.forEach { code -> assertTrue("missing code $code", code in codes) }
assertEquals("Wake I.", regions.byCode(297).getString("name"))
assertEquals("Rodrigues I.", regions.byCode(207).getString("name"))
}
@Test
fun `no region uses an ADIF-deleted entity code`() {
// ADIF 3.1.7 deleted DXCC codes. A polygon keyed to a deleted code
// never matches LoTW exports (they carry the active code, e.g.
// Germany 81 -> 230, Palestine 196 -> 510), so the award never fills.
val deleted = setOf(
2, 8, 19, 23, 25, 26, 28, 30, 39, 42, 44, 55, 57, 58, 59, 67, 68,
81, 85, 93, 101, 102, 113, 115, 119, 127, 128, 134, 139, 151, 154, 155,
164, 178, 183, 184, 186, 193, 194, 196, 198, 200, 208, 210, 218, 220,
226, 228, 229, 231, 243, 244, 255, 258, 261, 264, 267, 268, 271, 307,
488, 493
)
loadRegions().forEach { region ->
val code = region.getInt("code")
assertTrue("${region.getString("name")} uses deleted code $code", code !in deleted)
}
}
@Test
fun `entities carry their ADIF names`() {
val regions = loadRegions()
assertEquals("England", regions.byCode(223).getString("name"))
assertEquals("European Russia", regions.byCode(54).getString("name"))
assertEquals("Asiatic Russia", regions.byCode(15).getString("name"))
assertEquals("Scotland", regions.byCode(279).getString("name"))
assertEquals("Alaska", regions.byCode(6).getString("name"))
}
@Test
fun `every region has drawable rings within coordinate bounds`() {
loadRegions().forEach { region ->
val name = region.getString("name")
val rings = region.getJSONArray("rings")
assertTrue("$name: no rings", rings.length() >= 1)
for (r in 0 until rings.length()) {
val ring = rings.getJSONArray(r)
assertTrue("$name: ring $r has ${ring.length()} pts", ring.length() >= 3)
for (p in 0 until ring.length()) {
val pt = ring.getJSONArray(p)
val lon = pt.getDouble(0)
val lat = pt.getDouble(1)
assertTrue("$name: lon out of range $lon", lon in -180.0..180.0)
assertTrue("$name: lat out of range $lat", lat in -90.0..90.0)
}
}
}
}
@Test
fun `cities fall on the correct side of the rebuilt boundaries`() {
val regions = loadRegions()
val checks = listOf(
// European/Asiatic Russia split along the Urals (OSM ridge + Ural river)
CityCheck("Moscow", 37.61, 55.75, inside = 54, outside = 15),
CityCheck("Perm", 56.24, 58.01, inside = 54, outside = 15),
CityCheck("Yekaterinburg", 60.61, 56.84, inside = 15, outside = 54),
CityCheck("Novosibirsk", 82.93, 55.03, inside = 15, outside = 54),
CityCheck("Kaliningrad", 20.51, 54.71, inside = 126, outside = 54),
// UK four-entity partition
CityCheck("London", -0.13, 51.51, inside = 223, outside = 279),
CityCheck("Edinburgh", -3.19, 55.95, inside = 279, outside = 223),
CityCheck("Cardiff", -3.18, 51.48, inside = 294, outside = 223),
CityCheck("Belfast", -5.93, 54.60, inside = 265, outside = 223),
// Extracted sub-entities
CityCheck("Anchorage", -149.90, 61.22, inside = 6, outside = 291),
CityCheck("Central Oahu", -157.90, 21.45, inside = 110, outside = 291),
CityCheck("Los Angeles", -118.24, 34.05, inside = 291, outside = 6),
CityCheck("Kuching", 110.34, 1.55, inside = 46, outside = 299),
CityCheck("Kuala Lumpur", 101.69, 3.14, inside = 299, outside = 46),
CityCheck("Cayenne", -52.32, 4.94, inside = 63, outside = 227),
CityCheck("Ajaccio", 8.74, 41.93, inside = 214, outside = 227),
CityCheck("Heraklion", 25.14, 35.34, inside = 40, outside = 236),
CityCheck("Cagliari", 9.12, 39.22, inside = 225, outside = 248)
)
for (c in checks) {
assertTrue("${c.name} should be in ${c.inside}",
regionContains(regions.byCode(c.inside), c.lon, c.lat))
assertTrue("${c.name} should NOT be in ${c.outside}",
!regionContains(regions.byCode(c.outside), c.lon, c.lat))
}
}
}
@@ -27,7 +27,8 @@ 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.SatRadio import com.rtbishop.look4sat.core.domain.model.SatRadio
import com.rtbishop.look4sat.core.domain.model.WavelogSettings
import com.rtbishop.look4sat.core.domain.model.WavelogUploadSettings
import com.rtbishop.look4sat.core.domain.predict.GeoPos import com.rtbishop.look4sat.core.domain.predict.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
@@ -106,7 +107,9 @@ class FakeSettingsRepo(
override val radioControlSettings: StateFlow<RadioControlSettings> = MutableStateFlow( override val radioControlSettings: StateFlow<RadioControlSettings> = MutableStateFlow(
RadioControlSettings(false, RadioControlSettings.MODEL_YAESU_FT817, "", "", "", "", 9600) RadioControlSettings(false, RadioControlSettings.MODEL_YAESU_FT817, "", "", "", "", 9600)
) )
override val wavelogSettings: StateFlow<WavelogSettings> = MutableStateFlow(WavelogSettings())
override val wavelogUploadSettings: StateFlow<WavelogUploadSettings> = MutableStateFlow(WavelogUploadSettings())
override fun updateWavelogUploadSettings(settings: WavelogUploadSettings) = Unit
override val lotwSettings: StateFlow<LoTWSettings> = MutableStateFlow(LoTWSettings()) override val lotwSettings: StateFlow<LoTWSettings> = MutableStateFlow(LoTWSettings())
override fun setSelectedIds(ids: List<Int>) = TODO() override fun setSelectedIds(ids: List<Int>) = TODO()
@@ -115,6 +118,7 @@ class FakeSettingsRepo(
override fun setStationPosition(latitude: Double, longitude: Double, altitude: Double): Boolean = TODO() override fun setStationPosition(latitude: Double, longitude: Double, altitude: Double): Boolean = TODO()
override fun setStationPosition(): Boolean = TODO() override fun setStationPosition(): Boolean = TODO()
override fun setStationPosition(locator: String): Boolean = TODO() override fun setStationPosition(locator: String): Boolean = TODO()
override fun getCurrentGrid(): String? = TODO()
override fun getSatelliteTypesIds(types: List<String>): List<Int> = TODO() override fun getSatelliteTypesIds(types: List<String>): List<Int> = TODO()
override fun setSatelliteTypeIds(type: String, ids: List<Int>) = TODO() override fun setSatelliteTypeIds(type: String, ids: List<Int>) = TODO()
override fun updateDatabaseState(state: DatabaseState) = TODO() override fun updateDatabaseState(state: DatabaseState) = TODO()
@@ -133,7 +137,7 @@ class FakeSettingsRepo(
override fun setSatelliteMode(catnum: Int, mode: String) = TODO() override fun setSatelliteMode(catnum: Int, mode: String) = TODO()
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 getWorkedGrids(): Set<String> = TODO() override fun getWorkedGrids(): Set<String> = TODO()
override fun setWorkedGrids(grids: Set<String>) = TODO() override fun setWorkedGrids(grids: Set<String>) = TODO()
override fun getWorkedGridQsos(): Map<String, List<GridQso>> = TODO() override fun getWorkedGridQsos(): Map<String, List<GridQso>> = TODO()
@@ -147,4 +151,12 @@ class FakeSettingsRepo(
override fun setLastLotwSyncDate(date: String) = Unit override fun setLastLotwSyncDate(date: String) = Unit
override fun getLastLotwSyncCallsign(): String = "" override fun getLastLotwSyncCallsign(): String = ""
override fun setLastLotwSyncCallsign(callsign: String) = Unit override fun setLastLotwSyncCallsign(callsign: String) = Unit
override fun getWavelogStations(): List<com.rtbishop.look4sat.core.domain.repository.WavelogStationInfo> = emptyList()
override fun setWavelogStations(stations: List<com.rtbishop.look4sat.core.domain.repository.WavelogStationInfo>) = Unit
override fun getWavelogSyncCursors(): Map<String, Long> = emptyMap()
override fun setWavelogSyncCursors(cursors: Map<String, Long>) = Unit
override fun getWavelogSyncUrl(): String = ""
override fun setWavelogSyncUrl(url: String) = Unit
override fun getLastWavelogSyncEpochMs(): Long = 0L
override fun setLastWavelogSyncEpochMs(value: Long) = Unit
} }
@@ -16,6 +16,7 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
@@ -40,6 +41,7 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.text.input.KeyboardCapitalization
@@ -60,14 +62,19 @@ import com.rtbishop.look4sat.core.domain.utility.uplinkHz
import com.rtbishop.look4sat.core.domain.utility.voiceRepeater import com.rtbishop.look4sat.core.domain.utility.voiceRepeater
import com.rtbishop.look4sat.core.presentation.EmptyListCard import com.rtbishop.look4sat.core.presentation.EmptyListCard
import com.rtbishop.look4sat.core.presentation.QsoEditDialog import com.rtbishop.look4sat.core.presentation.QsoEditDialog
import com.rtbishop.look4sat.core.presentation.R
import com.rtbishop.look4sat.core.presentation.SheetDialogTitle import com.rtbishop.look4sat.core.presentation.SheetDialogTitle
import com.rtbishop.look4sat.core.presentation.sheetDialogContainerColor import com.rtbishop.look4sat.core.presentation.sheetDialogContainerColor
import com.rtbishop.look4sat.core.presentation.sheetDialogShape import com.rtbishop.look4sat.core.presentation.sheetDialogShape
import com.rtbishop.look4sat.core.presentation.gridsLabel
import com.rtbishop.look4sat.core.presentation.LoTWPositionWarningDialog
import com.rtbishop.look4sat.core.presentation.WavelogUploadPreviewDialog
@Composable @Composable
fun LogPage( fun LogPage(
uiState: RadarState, uiState: RadarState,
logViewModel: LogViewModel, logViewModel: LogViewModel,
onFixGrid: (List<String>) -> Unit = {},
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
val logUiState by logViewModel.uiState.collectAsStateWithLifecycle() val logUiState by logViewModel.uiState.collectAsStateWithLifecycle()
@@ -100,6 +107,12 @@ fun LogPage(
if (catnum != 0) logViewModel.selectSatellite(catnum) if (catnum != 0) logViewModel.selectSatellite(catnum)
} }
// Refresh the roaming hint whenever the page re-enters composition (e.g. returning
// from the station-location fix jump).
LaunchedEffect(Unit) {
logViewModel.refreshPositionHint()
}
val selectedTxHz = remember(selectedRadio) { selectedRadio?.uplinkHz() } val selectedTxHz = remember(selectedRadio) { selectedRadio?.uplinkHz() }
val selectedRxHz = remember(selectedRadio) { selectedRadio?.downlinkHz() } val selectedRxHz = remember(selectedRadio) { selectedRadio?.downlinkHz() }
val repeaterTxHz = voiceRepeater?.uplinkHz() val repeaterTxHz = voiceRepeater?.uplinkHz()
@@ -139,14 +152,28 @@ fun LogPage(
imeAction = ImeAction.Done imeAction = ImeAction.Done
), ),
keyboardActions = androidx.compose.foundation.text.KeyboardActions(onDone = { keyboardActions = androidx.compose.foundation.text.KeyboardActions(onDone = {
logViewModel.record(satName, mode, txHz, rxHz, logUiState.stationGrid) logViewModel.record(satName, mode, txHz, rxHz, logUiState.stationGrid, passWindow)
}) })
) )
Button( Button(
onClick = { logViewModel.record(satName, mode, txHz, rxHz, logUiState.stationGrid) }, onClick = { logViewModel.record(satName, mode, txHz, rxHz, logUiState.stationGrid, passWindow) },
enabled = logUiState.callsignInput.isNotBlank() && satName.isNotBlank() enabled = logUiState.callsignInput.isNotBlank() && satName.isNotBlank()
) { Text("Log") } ) { Text("Log") }
} }
logUiState.stationMismatch?.let { mismatch ->
Text(
text = "⚠ " + stringResource(
R.string.lotw_upload_pos_hint,
mismatch.currentGrid,
gridsLabel(mismatch.stationGrids)
),
fontSize = 12.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier
.fillMaxWidth()
.clickable { onFixGrid(listOf(mismatch.currentGrid)) }
)
}
if (isLinear) { if (isLinear) {
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
listOf("CW", "SSB", "FT4").forEach { candidate -> listOf("CW", "SSB", "FT4").forEach { candidate ->
@@ -186,7 +213,7 @@ fun LogPage(
onClick = logViewModel::prepareUpload, onClick = logViewModel::prepareUpload,
enabled = !logUiState.busy, enabled = !logUiState.busy,
modifier = Modifier.weight(1f) modifier = Modifier.weight(1f)
) { Text("上传 LoTW", fontSize = 13.sp) } ) { Text(if (logUiState.wavelogMode) "上传 Wavelog" else "上传 LoTW", fontSize = 13.sp) }
} }
if (logUiState.busy) { if (logUiState.busy) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
@@ -217,6 +244,19 @@ fun LogPage(
) )
} }
logUiState.uploadPositionWarning?.let { warning ->
LoTWPositionWarningDialog(
warning = warning,
onFixStation = {
logViewModel.abandonForGridFix()
onFixGrid(listOf(warning.currentGrid))
},
onIgnore = logViewModel::ignorePositionWarning
)
}
// The preview only opens once the position check is out of the way.
if (logUiState.uploadPositionWarning == null) {
logUiState.preview?.let { preview -> logUiState.preview?.let { preview ->
UploadPreviewDialog( UploadPreviewDialog(
preview = preview, preview = preview,
@@ -225,13 +265,23 @@ fun LogPage(
onDismiss = logViewModel::dismissPreview onDismiss = logViewModel::dismissPreview
) )
} }
}
logUiState.wavelogPreview?.let { preview ->
WavelogUploadPreviewDialog(
preview = preview,
busy = logUiState.busy,
onConfirm = logViewModel::confirmWavelogUpload,
onDismiss = logViewModel::dismissWavelogPreview
)
}
if (logUiState.message.isNotBlank()) { if (logUiState.message.isNotBlank()) {
AlertDialog( AlertDialog(
onDismissRequest = logViewModel::clearMessage, onDismissRequest = logViewModel::clearMessage,
shape = sheetDialogShape(), shape = sheetDialogShape(),
containerColor = sheetDialogContainerColor(), containerColor = sheetDialogContainerColor(),
title = { SheetDialogTitle("LoTW Upload") }, title = { SheetDialogTitle(if (logUiState.wavelogMode) "Wavelog Upload" else "LoTW Upload") },
text = { Text(logUiState.message) }, text = { Text(logUiState.message) },
confirmButton = { confirmButton = {
TextButton(onClick = logViewModel::clearMessage) { Text("OK") } TextButton(onClick = logViewModel::clearMessage) { Text("OK") }
@@ -239,6 +289,34 @@ fun LogPage(
) )
} }
// Out-of-window notice: the clock is outside the current pass [aos, los], so the record
// would never show in the window-filtered list. Confirm first, then store it at the pass
// midpoint (there is always an edit dialog afterwards for the exact time).
logUiState.outOfWindowLog?.let { pending ->
val midpointText = remember(pending.midpointUtcMillis) {
java.text.SimpleDateFormat("HH:mm'Z'", java.util.Locale.US).apply {
timeZone = java.util.TimeZone.getTimeZone("UTC")
}.format(java.util.Date(pending.midpointUtcMillis))
}
AlertDialog(
onDismissRequest = logViewModel::dismissOutOfWindowLog,
shape = sheetDialogShape(),
containerColor = sheetDialogContainerColor(),
title = { SheetDialogTitle(stringResource(R.string.log_out_window_title)) },
text = { Text(stringResource(R.string.log_out_window_message, midpointText)) },
confirmButton = {
TextButton(onClick = logViewModel::confirmOutOfWindowLog) {
Text(stringResource(R.string.log_out_window_confirm))
}
},
dismissButton = {
TextButton(onClick = logViewModel::dismissOutOfWindowLog) {
Text(stringResource(R.string.btn_cancel))
}
}
)
}
editTarget?.let { target -> editTarget?.let { target ->
QsoEditDialog( QsoEditDialog(
record = target, record = target,
@@ -264,6 +342,8 @@ private fun LogRecordRow(
timeZone = java.util.TimeZone.getTimeZone("UTC") timeZone = java.util.TimeZone.getTimeZone("UTC")
}.format(java.util.Date(record.startUtcMillis)) }.format(java.util.Date(record.startUtcMillis))
} }
// Own station grid: the upload-stamped set, else the grid the QSO was logged under.
val ownGrids = gridsLabel(record.vuccGrids.ifEmpty { listOf(record.myGrid) })
// 右划露出删除按钮(短信式);整行点击不再删除。 // 右划露出删除按钮(短信式);整行点击不再删除。
SwipeRevealRow( SwipeRevealRow(
key = record.id.toString(), key = record.id.toString(),
@@ -289,6 +369,14 @@ private fun LogRecordRow(
maxLines = 1 maxLines = 1
) )
Text(text = " ${record.displayMode}", fontSize = 14.sp, maxLines = 1) Text(text = " ${record.displayMode}", fontSize = 14.sp, maxLines = 1)
if (ownGrids.isNotBlank()) {
Text(
text = " $ownGrids",
fontSize = 14.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1
)
}
} }
when { when {
record.lotwConfirmed -> Text("QSL", fontSize = 12.sp, color = MaterialTheme.colorScheme.primary, fontFamily = FontFamily.Monospace) record.lotwConfirmed -> Text("QSL", fontSize = 12.sp, color = MaterialTheme.colorScheme.primary, fontFamily = FontFamily.Monospace)
@@ -21,12 +21,22 @@ 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.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 kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
@@ -38,6 +48,23 @@ import java.util.Date
import java.util.Locale import java.util.Locale
import java.util.TimeZone import java.util.TimeZone
/**
* A contact waiting for the operator to confirm logging while the clock is outside the
* current pass window (after LOS, before AOS, or against a pass card that is not the one
* being worked). Confirmed entries are stored at the pass midpoint — inside [aos, los] —
* so the window-filtered log page can show them; the exact time can be re-dated from the
* edit dialog afterwards.
*/
data class OutOfWindowLog(
val callsign: String,
val satName: String,
val mode: String,
val txHz: Long?,
val rxHz: Long?,
val myGrid: String,
val midpointUtcMillis: Long
)
data class LogUiState( data class LogUiState(
val callsignInput: String = "", val callsignInput: String = "",
/** Current logbook mode for the selected satellite (FM/CW/SSB/FT4). */ /** Current logbook mode for the selected satellite (FM/CW/SSB/FT4). */
@@ -49,15 +76,28 @@ data class LogUiState(
/** ARRL satellite names (config.tq6) — the only names a record may be signed with. */ /** ARRL satellite names (config.tq6) — the only names a record may be signed with. */
val satelliteCatalog: List<String> = emptyList(), val satelliteCatalog: List<String> = emptyList(),
val preview: LoTWUploadPreview? = null, val preview: LoTWUploadPreview? = null,
/** Roaming guard: the current position grid is outside the station grids the prepared
* batch would be signed with — shown as a dialog before the preview opens. */
val uploadPositionWarning: LoTWPositionWarning? = null,
/** Roaming hint on the log page: current position grid outside the station grids
* (null when covered or unknown). Tapping it jumps to the station location. */
val stationMismatch: LoTWPositionWarning? = null,
/** User-facing upload / record message ("" when none). */ /** User-facing upload / record message ("" when none). */
val message: String = "", val message: String = "",
val postText: String? = null, val postText: String? = null,
val busy: Boolean = false val busy: Boolean = false,
/** True when uploads route through Wavelog (Wavelog configured — LoTW paused). */
val wavelogMode: Boolean = false,
/** Wavelog mode: the prepared batch awaiting the operator's confirmation. */
val wavelogPreview: WavelogUploadPreview? = null,
/** Out-of-window contact waiting for the operator's confirmation (null when closed). */
val outOfWindowLog: OutOfWindowLog? = null
) )
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() {
@@ -85,6 +125,17 @@ class LogViewModel(
) )
} }
} }
// Upload routing follows the Wavelog configuration: once it is ready the log
// page uploads through Wavelog and skips LoTW entirely (the one-of-two rule).
viewModelScope.launch {
settingsRepo.wavelogUploadSettings.collect { settings ->
_uiState.update { it.copy(wavelogMode = settings.isReady) }
}
}
// The roaming hint tracks the freshest position data: refresh on init and every fix.
viewModelScope.launch {
settingsRepo.stationPosition.collect { refreshPositionHintNow() }
}
} }
fun updateCallsign(value: String) = fun updateCallsign(value: String) =
@@ -101,19 +152,64 @@ class LogViewModel(
settingsRepo.setSatelliteMode(catnum, mode) settingsRepo.setSatelliteMode(catnum, mode)
} }
fun record(satName: String, mode: String, txHz: Long?, rxHz: Long?, myGrid: String) { fun record(
satName: String,
mode: String,
txHz: Long?,
rxHz: Long?,
myGrid: String,
passWindow: ClosedRange<Long>?
) {
val call = _uiState.value.callsignInput.trim() val call = _uiState.value.callsignInput.trim()
if (call.isEmpty()) return if (call.isEmpty()) return
val normalizedMode = mode.ifBlank { "FM" }.uppercase(Locale.US)
// Whole minutes (seconds zeroed): matches the edit dialog's HH:mm granularity and the // Whole minutes (seconds zeroed): matches the edit dialog's HH:mm granularity and the
// minute-resolution of LoTW reports, so re-saving an unchanged record never looks edited. // minute-resolution of LoTW reports, so re-saving an unchanged record never looks edited.
val now = System.currentTimeMillis() / 60_000L * 60_000L val now = System.currentTimeMillis() / 60_000L * 60_000L
// Logging while the clock sits outside the current pass window (after LOS, before AOS,
// or against a pass card that is not the one being worked): the log page only shows
// records inside [aos, los], so a "now" timestamp could never appear there. Ask the
// operator first; the confirmed contact is stored at the pass midpoint (always inside
// the window) and can be re-dated later from the edit dialog.
if (passWindow != null && now !in passWindow) {
val midpoint = ((passWindow.start + passWindow.endInclusive) / 2L)
.let { it / 60_000L * 60_000L }
.coerceIn(passWindow.start, passWindow.endInclusive)
_uiState.update {
it.copy(outOfWindowLog = OutOfWindowLog(call, satName, mode, txHz, rxHz, myGrid, midpoint))
}
return
}
storeRecord(call, satName, mode, txHz, rxHz, myGrid, now)
}
/** The operator confirmed the out-of-window notice: store the contact at the pass midpoint. */
fun confirmOutOfWindowLog() {
val pending = _uiState.value.outOfWindowLog ?: return
storeRecord(
pending.callsign, pending.satName, pending.mode,
pending.txHz, pending.rxHz, pending.myGrid, pending.midpointUtcMillis
)
_uiState.update { it.copy(outOfWindowLog = null) }
}
fun dismissOutOfWindowLog() = _uiState.update { it.copy(outOfWindowLog = null) }
private fun storeRecord(
call: String,
satName: String,
mode: String,
txHz: Long?,
rxHz: Long?,
myGrid: String,
timeMillis: Long
) {
val normalizedMode = mode.ifBlank { "FM" }.uppercase(Locale.US)
// kHz granularity (3 decimals in MHz) — enough for operating and for the ADIF export. // kHz granularity (3 decimals in MHz) — enough for operating and for the ADIF export.
val tx = txHz?.let { (it + 500L) / 1000L * 1000L } val tx = txHz?.let { (it + 500L) / 1000L * 1000L }
val rx = rxHz?.let { (it + 500L) / 1000L * 1000L } val rx = rxHz?.let { (it + 500L) / 1000L * 1000L }
val record = QsoRecord( val record = QsoRecord(
startUtcMillis = now, startUtcMillis = timeMillis,
endUtcMillis = now, endUtcMillis = timeMillis,
theirCallsign = call, theirCallsign = call,
myCallsign = _uiState.value.certificateCallsign, myCallsign = _uiState.value.certificateCallsign,
myGrid = myGrid.take(6).uppercase(Locale.US), myGrid = myGrid.take(6).uppercase(Locale.US),
@@ -180,6 +276,12 @@ class LogViewModel(
_uiState.update { it.copy(busy = true, message = "") } _uiState.update { it.copy(busy = true, message = "") }
try { try {
val all = qsoRepository.records.first() val all = qsoRepository.records.first()
val wavelogSettings = settingsRepo.wavelogUploadSettings.value
if (wavelogSettings.isReady) {
// Wavelog mode: the upload goes to Wavelog only (LoTW stays out).
prepareWavelogUpload(all, wavelogSettings)
return@launch
}
// Only local (non-confirmed) records are candidates for upload; // Only local (non-confirmed) records are candidates for upload;
// 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 }
@@ -200,7 +302,13 @@ class LogViewModel(
// Only the records that actually made it into the TQ8 may be // Only the records that actually made it into the TQ8 may be
// marked uploaded later — never the whole candidate list. // marked uploaded later — never the whole candidate list.
lastUploadedIds = preview.submittedIds lastUploadedIds = preview.submittedIds
_uiState.update { it.copy(busy = false, preview = preview) } _uiState.update {
it.copy(
busy = false,
preview = preview,
uploadPositionWarning = positionWarning(settingsRepo.getCurrentGrid(), preview.grids)
)
}
} catch (e: LoTWOperationException) { } catch (e: LoTWOperationException) {
_uiState.update { it.copy(busy = false, message = "Upload unavailable: ${e.reason}") } _uiState.update { it.copy(busy = false, message = "Upload unavailable: ${e.reason}") }
} catch (_: Exception) { } catch (_: Exception) {
@@ -209,14 +317,33 @@ class LogViewModel(
} }
} }
/** Wavelog-mode branch of [prepareUpload]: prepare the batch and ask for confirmation. */
private suspend fun prepareWavelogUpload(all: List<QsoRecord>, settings: WavelogUploadSettings) {
val preview = runCatching {
wavelogUploadRepository.prepare(wavelogUploadCandidates(all), settings)
}.getOrNull()
if (preview == null) {
_uiState.update { it.copy(busy = false, message = "Wavelog upload failed to prepare — check the configuration") }
return
}
if (preview.count == 0) {
_uiState.update {
it.copy(busy = false, message = wavelogIdleSegment(preview) ?: "No pending QSOs to upload to Wavelog")
}
return
}
_uiState.update { it.copy(busy = false, wavelogPreview = preview) }
}
fun confirmUpload() { fun confirmUpload() {
val preview = _uiState.value.preview ?: return val preview = _uiState.value.preview ?: return
viewModelScope.launch { viewModelScope.launch {
_uiState.update { it.copy(busy = true) } _uiState.update { it.copy(busy = true) }
val result = lotwUploadRepository.upload(preview.id) val result = lotwUploadRepository.upload(preview.id)
if (result is LoTWUploadResult.Accepted && lastUploadedIds.isNotEmpty()) { if (result is LoTWUploadResult.Accepted && lastUploadedIds.isNotEmpty()) {
// Mark the submitted QSOs as uploaded (distinct from confirmed). // Mark the submitted QSOs as uploaded (distinct from confirmed) and stamp
qsoRepository.markUploaded(lastUploadedIds) // the station grids this batch went out under.
qsoRepository.markUploaded(lastUploadedIds, preview.grids)
lastUploadedIds = emptyList() lastUploadedIds = emptyList()
} }
val msg = when (result) { val msg = when (result) {
@@ -229,14 +356,75 @@ class LogViewModel(
} }
} }
/** Confirms the Wavelog-mode batch prepared by [prepareUpload]. */
fun confirmWavelogUpload() {
val preview = _uiState.value.wavelogPreview ?: return
viewModelScope.launch {
_uiState.update { it.copy(busy = true) }
val msg = uploadWavelogNow(preview, settingsRepo.wavelogUploadSettings.value)
_uiState.update { it.copy(busy = false, wavelogPreview = null, message = msg) }
}
}
fun dismissPreview() = _uiState.update { it.copy(preview = null) } fun dismissPreview() = _uiState.update { it.copy(preview = null) }
fun dismissWavelogPreview() = _uiState.update { it.copy(wavelogPreview = null) }
/**
* Uploads one prepared Wavelog batch and marks what the server accepted (stamped
* with the station profile the batch went out through). Returns the operator-facing
* message for this upload.
*/
private suspend fun uploadWavelogNow(
preview: WavelogUploadPreview,
settings: WavelogUploadSettings
): String = try {
val outcome = wavelogUploadRepository.upload(preview, settings)
if (outcome is WavelogUploadOutcome.Imported && outcome.markIds.isNotEmpty()) {
qsoRepository.markWavelogUploaded(outcome.markIds, settings.stationId)
}
wavelogConfirmedSegment(outcome, preview)
} catch (_: Exception) {
wavelogTransportFailureSegment()
}
/** Operator chose "ignore" on the position check: keep the prepared preview. */
fun ignorePositionWarning() = _uiState.update { it.copy(uploadPositionWarning = null) }
/** Operator chose to fix the station grid first: drop the prepared preview and leave. */
fun abandonForGridFix() {
lotwUploadRepository.discardPreview()
lastUploadedIds = emptyList()
_uiState.update { it.copy(uploadPositionWarning = null, preview = null) }
}
/** Recompute the log-page roaming hint from the freshest station/position data. */
fun refreshPositionHint() = viewModelScope.launch { refreshPositionHintNow() }
private suspend fun refreshPositionHintNow() {
val station = runCatching { lotwUploadRepository.station() }.getOrNull()
if (station == null) {
_uiState.update { it.copy(stationMismatch = null) }
return
}
val grids = station.grid.split(',').map(String::trim).filter(String::isNotBlank)
_uiState.update {
it.copy(
stationMismatch = positionWarning(settingsRepo.getCurrentGrid(), grids),
stationGrid = station.grid
)
}
}
fun clearMessage() = _uiState.update { it.copy(message = "") } fun clearMessage() = _uiState.update { it.copy(message = "") }
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
)
} }
} }
} }
@@ -175,7 +175,7 @@ internal fun radarFillSizes(maxWidth: Dp, maxHeight: Dp): RadarFillSizes {
} }
@Composable @Composable
fun RadarDestination(navigateUp: () -> Unit) { fun RadarDestination(navigateUp: () -> Unit, onOpenLoTWStation: () -> Unit = {}) {
val context = LocalContext.current val context = LocalContext.current
val container = (context.applicationContext as IContainerProvider).getMainContainer() val container = (context.applicationContext as IContainerProvider).getMainContainer()
val viewModel: RadarViewModel = viewModel(factory = RadarViewModel.factory(container)) val viewModel: RadarViewModel = viewModel(factory = RadarViewModel.factory(container))
@@ -216,9 +216,15 @@ fun RadarDestination(navigateUp: () -> Unit) {
viewModel.onAction(RadarAction.SstvPermissionResult(granted)) viewModel.onAction(RadarAction.SstvPermissionResult(granted))
viewModel.onAction(RadarAction.CwPermissionResult(granted)) viewModel.onAction(RadarAction.CwPermissionResult(granted))
} }
// Grid-check "fix it" jump from the log page: hand the affected records' grids to the
// station-location page as its prefill (mirrors the Grid Finder's hand-off).
val openLoTWStationForGridFix: (List<String>) -> Unit = { grids ->
if (grids.isNotEmpty()) container.setPendingLoTWStationGrid(grids.joinToString(","))
onOpenLoTWStation()
}
RadarScreen(uiState, viewModel::onAction, navigateUpAndClearMutual, mutualData, logViewModel, requestMicPermission = { RadarScreen(uiState, viewModel::onAction, navigateUpAndClearMutual, mutualData, logViewModel, requestMicPermission = {
permissionLauncher.launch(Manifest.permission.RECORD_AUDIO) permissionLauncher.launch(Manifest.permission.RECORD_AUDIO)
}) }, onFixGrid = openLoTWStationForGridFix)
} }
@Composable @Composable
@@ -228,7 +234,8 @@ private fun RadarScreen(
navigateUp: () -> Unit, navigateUp: () -> Unit,
mutualData: MutualPassData, mutualData: MutualPassData,
logViewModel: LogViewModel, logViewModel: LogViewModel,
requestMicPermission: () -> Unit requestMicPermission: () -> Unit,
onFixGrid: (List<String>) -> Unit
) { ) {
val upcomingPass = uiState.currentPass ?: getDefaultPass() val upcomingPass = uiState.currentPass ?: getDefaultPass()
// 日程功能: 把当前过境写入系统日历(原仓库的 addToCalendar, ic_calendar 按钮) // 日程功能: 把当前过境写入系统日历(原仓库的 addToCalendar, ic_calendar 按钮)
@@ -308,16 +315,17 @@ private fun RadarScreen(
onAction = onAction, onAction = onAction,
logViewModel = logViewModel, logViewModel = logViewModel,
requestMicPermission = requestMicPermission, requestMicPermission = requestMicPermission,
onFixGrid = onFixGrid,
modifier = Modifier.weight(1f) modifier = Modifier.weight(1f)
) )
} else { } else {
RadarCard(uiState, trackB, trackBPosition, Modifier.weight(1f)) RadarCard(uiState, trackB, trackBPosition, Modifier.weight(1f))
PagerCard(uiState, onAction, logViewModel, requestMicPermission, Modifier.weight(1f)) PagerCard(uiState, onAction, logViewModel, requestMicPermission, Modifier.weight(1f), onFixGrid = onFixGrid)
} }
} else { } else {
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
RadarCard(uiState, trackB, trackBPosition, Modifier.weight(1f)) RadarCard(uiState, trackB, trackBPosition, Modifier.weight(1f))
PagerCard(uiState, onAction, logViewModel, requestMicPermission, Modifier.weight(1f)) PagerCard(uiState, onAction, logViewModel, requestMicPermission, Modifier.weight(1f), onFixGrid = onFixGrid)
} }
} }
} }
@@ -338,6 +346,7 @@ private fun RadarFillArea(
onAction: (RadarAction) -> Unit, onAction: (RadarAction) -> Unit,
logViewModel: LogViewModel, logViewModel: LogViewModel,
requestMicPermission: () -> Unit, requestMicPermission: () -> Unit,
onFixGrid: (List<String>) -> Unit,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
BoxWithConstraints(modifier = modifier.fillMaxSize()) { BoxWithConstraints(modifier = modifier.fillMaxSize()) {
@@ -351,7 +360,7 @@ private fun RadarFillArea(
verticalArrangement = Arrangement.spacedBy(ROW_GAP) verticalArrangement = Arrangement.spacedBy(ROW_GAP)
) { ) {
RadarCard(uiState, trackB, trackBPosition, Modifier.height(sizes.radarSide).fillMaxWidth()) RadarCard(uiState, trackB, trackBPosition, Modifier.height(sizes.radarSide).fillMaxWidth())
PagerCard(uiState, onAction, logViewModel, requestMicPermission, Modifier.height(sizes.pagerSpace)) PagerCard(uiState, onAction, logViewModel, requestMicPermission, Modifier.height(sizes.pagerSpace), onFixGrid = onFixGrid)
} }
} else { } else {
Box(modifier = Modifier.fillMaxSize()) { Box(modifier = Modifier.fillMaxSize()) {
@@ -361,6 +370,7 @@ private fun RadarFillArea(
onAction = onAction, onAction = onAction,
logViewModel = logViewModel, logViewModel = logViewModel,
requestMicPermission = requestMicPermission, requestMicPermission = requestMicPermission,
onFixGrid = onFixGrid,
panelHeight = pagerPanelHeight, panelHeight = pagerPanelHeight,
modifier = Modifier.align(Alignment.BottomCenter) modifier = Modifier.align(Alignment.BottomCenter)
) )
@@ -380,6 +390,7 @@ private fun CompactPagerOverlay(
onAction: (RadarAction) -> Unit, onAction: (RadarAction) -> Unit,
logViewModel: LogViewModel, logViewModel: LogViewModel,
requestMicPermission: () -> Unit, requestMicPermission: () -> Unit,
onFixGrid: (List<String>) -> Unit,
panelHeight: Dp, panelHeight: Dp,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
@@ -420,6 +431,7 @@ private fun CompactPagerOverlay(
onAction = onAction, onAction = onAction,
logViewModel = logViewModel, logViewModel = logViewModel,
requestMicPermission = requestMicPermission, requestMicPermission = requestMicPermission,
onFixGrid = onFixGrid,
modifier = modifier modifier = modifier
.fillMaxWidth() .fillMaxWidth()
.height(panelHeight), .height(panelHeight),
@@ -453,7 +465,8 @@ private fun PagerCard(
requestMicPermission: () -> Unit, requestMicPermission: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
startPage: RadarPage? = null, startPage: RadarPage? = null,
onCollapse: (() -> Unit)? = null onCollapse: (() -> Unit)? = null,
onFixGrid: (List<String>) -> Unit = {}
) { ) {
val pages = rememberRadarPages(uiState) val pages = rememberRadarPages(uiState)
val pagerState = rememberPagerState( val pagerState = rememberPagerState(
@@ -515,7 +528,8 @@ private fun PagerCard(
) )
RadarPage.Log -> LogPage( RadarPage.Log -> LogPage(
uiState = uiState, uiState = uiState,
logViewModel = logViewModel logViewModel = logViewModel,
onFixGrid = onFixGrid
) )
RadarPage.Sstv -> SstvPage( RadarPage.Sstv -> SstvPage(
sstv = uiState.sstv, sstv = uiState.sstv,
@@ -132,5 +132,12 @@ sealed interface RadarAction {
// Calculator actions // Calculator actions
data class ChangeCalculatorOffset(val offsetKHz: String) : RadarAction data class ChangeCalculatorOffset(val offsetKHz: String) : RadarAction
/**
* The transponder currently driving the calculator (null when the satellite has
* no linear transponder). The per-satellite offset follows it instead of the
* transceiver selection, so it loads and saves even when nothing is selected.
*/
data class CalculatorTransponderChanged(val catnum: Int?) : RadarAction
data class UpdateCalculatorFrequency(val txHz: Long?, val rxHz: Long?) : RadarAction data class UpdateCalculatorFrequency(val txHz: Long?, val rxHz: Long?) : RadarAction
} }
@@ -69,6 +69,7 @@ class RadarViewModel(
private val stationPos = settingsRepo.stationPosition.value private val stationPos = settingsRepo.stationPosition.value
private val magDeclination = sensorsRepo.getMagDeclination(stationPos) private val magDeclination = sensorsRepo.getMagDeclination(stationPos)
private var transponders: List<SatRadio> = emptyList() private var transponders: List<SatRadio> = emptyList()
private var calculatorCatnum: Int? = null
private var sstvDecoder: SstvDecoder? = null private var sstvDecoder: SstvDecoder? = null
private var sstvRecordingJob: Job? = null private var sstvRecordingJob: Job? = null
private var cwDecoder: CwDecoder? = null private var cwDecoder: CwDecoder? = null
@@ -158,12 +159,8 @@ class RadarViewModel(
transponders = allRadios.filter { it.downlinkLow != null } transponders = allRadios.filter { it.downlinkLow != null }
if (allRadios.isNotEmpty()) { if (allRadios.isNotEmpty()) {
val firstUuid = allRadios.first().uuid val firstUuid = allRadios.first().uuid
val offsetKHz = allRadios.first().catnum?.let { settingsRepo.getSatelliteOffset(it) } ?: ""
_uiState.update { _uiState.update {
it.copy( it.copy(transceivers = it.transceivers.copy(selectedUuid = firstUuid))
transceivers = it.transceivers.copy(selectedUuid = firstUuid),
calculatorOffsetKHz = offsetKHz
)
} }
transponders.find { it.uuid == firstUuid }?.let { trackingService.setTransponder(it) } transponders.find { it.uuid == firstUuid }?.let { trackingService.setTransponder(it) }
} }
@@ -241,15 +238,8 @@ class RadarViewModel(
// Compute toggle state before the update so we don't read post-update value // Compute toggle state before the update so we don't read post-update value
val isTogglingOff = _uiState.value.transceivers.selectedUuid == action.uuid val isTogglingOff = _uiState.value.transceivers.selectedUuid == action.uuid
val newUuid = if (isTogglingOff) null else action.uuid val newUuid = if (isTogglingOff) null else action.uuid
val offsetKHz = if (!isTogglingOff) {
transponders.find { it.uuid == action.uuid }
?.catnum?.let { settingsRepo.getSatelliteOffset(it) } ?: ""
} else ""
_uiState.update { _uiState.update {
it.copy( it.copy(transceivers = it.transceivers.copy(selectedUuid = newUuid))
transceivers = it.transceivers.copy(selectedUuid = newUuid),
calculatorOffsetKHz = offsetKHz
)
} }
// Only update the tracking service when selecting a different transponder to // Only update the tracking service when selecting a different transponder to
// avoid resetting a user-adjusted TX base on re-expand // avoid resetting a user-adjusted TX base on re-expand
@@ -319,7 +309,9 @@ class RadarViewModel(
_uiState.update { it.copy(cw = it.cw.copy(isExpanded = action.expanded)) } _uiState.update { it.copy(cw = it.cw.copy(isExpanded = action.expanded)) }
} }
is RadarAction.ChangeCalculatorOffset -> { is RadarAction.ChangeCalculatorOffset -> {
val catnum = _uiState.value.transceivers.selectedUuid?.let { uuid -> // Keyed by the transponder driving the calculator, so the offset saves
// even when no transceiver is selected (or selection was toggled off)
val catnum = calculatorCatnum ?: _uiState.value.transceivers.selectedUuid?.let { uuid ->
transponders.find { it.uuid == uuid }?.catnum transponders.find { it.uuid == uuid }?.catnum
} }
if (catnum != null) { if (catnum != null) {
@@ -327,6 +319,11 @@ class RadarViewModel(
} }
_uiState.update { it.copy(calculatorOffsetKHz = action.offsetKHz) } _uiState.update { it.copy(calculatorOffsetKHz = action.offsetKHz) }
} }
is RadarAction.CalculatorTransponderChanged -> {
calculatorCatnum = action.catnum
val offsetKHz = action.catnum?.let { settingsRepo.getSatelliteOffset(it) } ?: ""
_uiState.update { it.copy(calculatorOffsetKHz = offsetKHz) }
}
is RadarAction.UpdateCalculatorFrequency -> { is RadarAction.UpdateCalculatorFrequency -> {
_uiState.update { _uiState.update {
if (it.calculatorTxHz == action.txHz && it.calculatorRxHz == action.rxHz) it if (it.calculatorTxHz == action.txHz && it.calculatorRxHz == action.rxHz) it
@@ -176,6 +176,13 @@ fun CalculatorPage(
?: calculatorTransceivers.firstOrNull() ?: calculatorTransceivers.firstOrNull()
} }
// Keep the per-satellite offset in sync with the transponder driving the
// calculator: load the saved value on open/satellite change, and remember
// its catnum so editing saves even when no transceiver is selected
LaunchedEffect(selectedTransceiver?.uuid) {
onAction(RadarAction.CalculatorTransponderChanged(selectedTransceiver?.catnum))
}
if (selectedTransceiver == null) { if (selectedTransceiver == null) {
EmptyTransceiversContent(modifier) EmptyTransceiversContent(modifier)
return return
@@ -44,6 +44,7 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.text.input.KeyboardCapitalization
@@ -58,15 +59,21 @@ import com.rtbishop.look4sat.core.presentation.CardButton
import com.rtbishop.look4sat.core.presentation.LocalSpacing import com.rtbishop.look4sat.core.presentation.LocalSpacing
import com.rtbishop.look4sat.core.presentation.R import com.rtbishop.look4sat.core.presentation.R
import com.rtbishop.look4sat.core.presentation.SharedDialog import com.rtbishop.look4sat.core.presentation.SharedDialog
import kotlinx.coroutines.delay
@Composable @Composable
fun LoTWUploadCard( fun LoTWUploadCard(
hasCertificate: Boolean, hasCertificate: Boolean,
stationGrid: String, stationGrid: String,
enabled: Boolean = true,
showUploadConfigDialog: () -> Unit showUploadConfigDialog: () -> Unit
) { ) {
ElevatedCard(modifier = Modifier.fillMaxWidth().clickable { showUploadConfigDialog() }) { ElevatedCard(modifier = Modifier.fillMaxWidth().clickable(enabled = enabled) { showUploadConfigDialog() }) {
Column(modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)) { Column(
modifier = Modifier
.padding(horizontal = 8.dp, vertical = 4.dp)
.alpha(if (enabled) 1f else 0.5f)
) {
Text( Text(
text = stringResource(R.string.prefs_lotw_upload_title), text = stringResource(R.string.prefs_lotw_upload_title),
color = MaterialTheme.colorScheme.primary color = MaterialTheme.colorScheme.primary
@@ -81,6 +88,15 @@ fun LoTWUploadCard(
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
maxLines = 2 maxLines = 2
) )
if (!enabled) {
Spacer(modifier = Modifier.height(2.dp))
Text(
text = stringResource(R.string.prefs_lotw_disabled_wavelog),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 2
)
}
} }
} }
} }
@@ -96,6 +112,7 @@ fun LoTWUploadConfigDialog(
initialGrid: String = "", initialGrid: String = "",
onDismiss: () -> Unit, onDismiss: () -> Unit,
onImport: (ByteArray, CharArray) -> Unit, onImport: (ByteArray, CharArray) -> Unit,
onPreview: (ByteArray, CharArray) -> Unit,
onRemove: () -> Unit, onRemove: () -> Unit,
onSaveStation: (LoTWStation) -> Unit onSaveStation: (LoTWStation) -> Unit
) { ) {
@@ -140,6 +157,19 @@ fun LoTWUploadConfigDialog(
} }
}.getOrNull() ?: uri.lastPathSegment.orEmpty() }.getOrNull() ?: uri.lastPathSegment.orEmpty()
// Surface the certificate's region field before the import is confirmed: re-read
// the picked .p12 whenever file or password changes (debounced) and hand it to a
// silent preview — a wrong password simply keeps the pending hint, a successful
// parse fills the Province/State dropdown in the station form below.
LaunchedEffect(selectedFile, password) {
val uri = selectedFile ?: return@LaunchedEffect
delay(400)
val bytes = runCatching {
context.contentResolver.openInputStream(uri)?.use { it.readBytes() }
}.getOrNull()
if (bytes != null && bytes.isNotEmpty()) onPreview(bytes, password.toCharArray())
}
SharedDialog( SharedDialog(
title = stringResource(R.string.prefs_lotw_upload_title), title = stringResource(R.string.prefs_lotw_upload_title),
onDismissRequest = onDismiss, onDismissRequest = onDismiss,
@@ -200,6 +230,11 @@ fun LoTWUploadConfigDialog(
visualTransformation = PasswordVisualTransformation(), visualTransformation = PasswordVisualTransformation(),
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()
) )
Text(
text = stringResource(R.string.prefs_lotw_upload_password_hint),
fontSize = 11.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
CardButton( CardButton(
onClick = { onClick = {
val bytes = runCatching { val bytes = runCatching {
@@ -212,7 +247,9 @@ fun LoTWUploadConfigDialog(
} }
}, },
text = stringResource(R.string.prefs_lotw_upload_import_confirm), text = stringResource(R.string.prefs_lotw_upload_import_confirm),
isEnabled = password.isNotBlank() && !busy, // An empty field is a legitimate attempt: certificates exported
// without a password import with a blank password.
isEnabled = !busy,
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()
) )
} }
@@ -232,12 +269,15 @@ fun LoTWUploadConfigDialog(
keyboardOptions = androidx.compose.foundation.text.KeyboardOptions(capitalization = KeyboardCapitalization.Characters), keyboardOptions = androidx.compose.foundation.text.KeyboardOptions(capitalization = KeyboardCapitalization.Characters),
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()
) )
// Country-specific region field (US_STATE, CN_PROVINCE, …) shown only when // Country-specific region field (US_STATE, CN_PROVINCE, …): the entry is part
// the certificate's DXCC entity defines one; selecting it fills CQZ/ITUZ. // of the form from the first frame (pending hint while no DXCC is known), gets
// its options as soon as a certificate is previewed/imported, and a selection
// fills CQZ/ITUZ. Entities without a region field keep it hidden.
// Drawn as a plain Box (not OutlinedTextField): a read-only text field's // Drawn as a plain Box (not OutlinedTextField): a read-only text field's
// internal gesture handler consumes the tap, so clickable never fires. // internal gesture handler consumes the tap, so clickable never fires.
// Options expand inline inside the sheet (no Popup/Dialog window stacking). // Options expand inline inside the sheet (no Popup/Dialog window stacking).
if (regionField != null) { val regionPending = certificate == null && stationMeta == null
if (regionField != null || regionPending) {
var regionExpanded by remember { mutableStateOf(false) } var regionExpanded by remember { mutableStateOf(false) }
val fieldShape = MaterialTheme.shapes.extraSmall val fieldShape = MaterialTheme.shapes.extraSmall
Box( Box(
@@ -246,21 +286,25 @@ fun LoTWUploadConfigDialog(
.clip(fieldShape) .clip(fieldShape)
.background(MaterialTheme.colorScheme.surface) .background(MaterialTheme.colorScheme.surface)
.border(1.dp, MaterialTheme.colorScheme.outline, fieldShape) .border(1.dp, MaterialTheme.colorScheme.outline, fieldShape)
.clickable { regionExpanded = !regionExpanded } .clickable(enabled = regionField != null) { regionExpanded = !regionExpanded }
.padding(horizontal = 12.dp, vertical = 8.dp) .padding(horizontal = 12.dp, vertical = 8.dp)
) { ) {
Column { Column {
Text(regionField.label, fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurfaceVariant) Text(
text = regionField?.label ?: stringResource(R.string.prefs_lotw_upload_region_label),
fontSize = 12.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(3.dp)) Spacer(modifier = Modifier.height(3.dp))
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
Text( Text(
text = if (region.isBlank()) { text = when {
stringResource(R.string.prefs_lotw_upload_region_hint) regionPending -> stringResource(R.string.prefs_lotw_upload_region_pending)
} else { region.isBlank() -> stringResource(R.string.prefs_lotw_upload_region_hint)
"$region — $selectedRegionName" else -> "$region — $selectedRegionName"
}, },
fontSize = 16.sp, fontSize = 16.sp,
color = if (region.isBlank()) { color = if (regionPending || region.isBlank()) {
MaterialTheme.colorScheme.onSurfaceVariant MaterialTheme.colorScheme.onSurfaceVariant
} else { } else {
MaterialTheme.colorScheme.onSurface MaterialTheme.colorScheme.onSurface
@@ -277,9 +321,9 @@ fun LoTWUploadConfigDialog(
} }
} }
} }
AnimatedVisibility(visible = regionExpanded) { AnimatedVisibility(visible = regionExpanded && regionField != null) {
LazyColumn(modifier = Modifier.fillMaxWidth().heightIn(max = 280.dp)) { LazyColumn(modifier = Modifier.fillMaxWidth().heightIn(max = 280.dp)) {
items(regionField.options, key = { it.code }) { option -> items(regionField?.options.orEmpty(), key = { it.code }) { option ->
DropdownMenuItem( DropdownMenuItem(
text = { Text("${option.code} — ${option.name}", fontSize = 13.sp) }, text = { Text("${option.code} — ${option.name}", fontSize = 13.sp) },
onClick = { onClick = {
@@ -64,6 +64,9 @@ fun LoTWUploadDestination(navigateUp: () -> Unit) {
onImport = { bytes, password -> onImport = { bytes, password ->
viewModel.onAction(SettingsAction.ImportLoTWCertificate(bytes, password)) viewModel.onAction(SettingsAction.ImportLoTWCertificate(bytes, password))
}, },
onPreview = { bytes, password ->
viewModel.onAction(SettingsAction.PreviewLoTWCertificate(bytes, password))
},
onRemove = { viewModel.onAction(SettingsAction.RemoveLoTWCertificate) }, onRemove = { viewModel.onAction(SettingsAction.RemoveLoTWCertificate) },
onSaveStation = { viewModel.onAction(SettingsAction.SaveLoTWStation(it)) } onSaveStation = { viewModel.onAction(SettingsAction.SaveLoTWStation(it)) }
) )
@@ -9,8 +9,11 @@
*/ */
package com.rtbishop.look4sat.feature.settings package com.rtbishop.look4sat.feature.settings
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
@@ -21,11 +24,14 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ElevatedCard import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
@@ -33,6 +39,8 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
@@ -47,6 +55,9 @@ import com.rtbishop.look4sat.core.presentation.QsoEditDialog
import com.rtbishop.look4sat.core.presentation.SheetDialogTitle import com.rtbishop.look4sat.core.presentation.SheetDialogTitle
import com.rtbishop.look4sat.core.presentation.sheetDialogContainerColor import com.rtbishop.look4sat.core.presentation.sheetDialogContainerColor
import com.rtbishop.look4sat.core.presentation.sheetDialogShape import com.rtbishop.look4sat.core.presentation.sheetDialogShape
import com.rtbishop.look4sat.core.presentation.gridsLabel
import com.rtbishop.look4sat.core.presentation.LoTWPositionWarningDialog
import com.rtbishop.look4sat.core.presentation.WavelogUploadPreviewDialog
import com.rtbishop.look4sat.core.presentation.R import com.rtbishop.look4sat.core.presentation.R
import com.rtbishop.look4sat.core.presentation.SharedDialog import com.rtbishop.look4sat.core.presentation.SharedDialog
import com.rtbishop.look4sat.core.presentation.SwipeController import com.rtbishop.look4sat.core.presentation.SwipeController
@@ -82,24 +93,47 @@ fun LogbookDialog(
uploadBusy: Boolean, uploadBusy: Boolean,
uploadMessage: String, uploadMessage: String,
preview: com.rtbishop.look4sat.core.domain.repository.LoTWUploadPreview?, preview: com.rtbishop.look4sat.core.domain.repository.LoTWUploadPreview?,
positionWarning: com.rtbishop.look4sat.core.domain.repository.LoTWPositionWarning? = null,
onDismiss: () -> Unit, onDismiss: () -> Unit,
onDelete: (Long) -> Unit, onDelete: (Long) -> Unit,
onEdit: (QsoRecord) -> Unit, onEdit: (QsoRecord) -> Unit,
onUpload: () -> Unit, onUpload: () -> Unit,
onConfirmUpload: () -> Unit, onConfirmUpload: () -> Unit,
onDismissPreview: () -> Unit, onDismissPreview: () -> Unit,
onDismissMessage: () -> Unit onDismissMessage: () -> Unit,
onIgnorePositionWarning: () -> Unit = {},
onFixGrid: (List<String>) -> Unit = {},
selectionMode: Boolean = false,
selectedIds: Set<Long> = emptySet(),
onStartSelection: (Long) -> Unit = {},
onToggleSelection: (Long) -> Unit = {},
onExitSelection: () -> Unit = {},
onResubmitSelected: () -> Unit = {},
/** Wavelog mode: the prepared batch awaiting confirmation (null otherwise). */
wavelogPreview: com.rtbishop.look4sat.core.domain.repository.WavelogUploadPreview? = null,
/** True when uploads route through Wavelog — titles and previews follow the mode. */
wavelogMode: Boolean = false,
/** Wavelog station profiles for the 台址 selector (empty hides the selector). */
stationOptions: List<com.rtbishop.look4sat.core.domain.repository.WavelogStationInfo> = emptyList(),
stationFilter: String? = null,
onStationFilterChange: (String?) -> Unit = {}
) { ) {
val swipeController = rememberSwipeController() val swipeController = rememberSwipeController()
// Entering selection mode closes any row left swiped open — reveals are off there.
LaunchedEffect(selectionMode) { if (selectionMode) swipeController.close() }
// Record currently open in the edit dialog (null when closed). // Record currently open in the edit dialog (null when closed).
var editTarget by remember { mutableStateOf<QsoRecord?>(null) } var editTarget by remember { mutableStateOf<QsoRecord?>(null) }
SharedDialog( SharedDialog(
title = stringResource(R.string.prefs_logbook_title), title = stringResource(R.string.prefs_logbook_title),
onDismissRequest = onDismiss, onDismissRequest = onDismiss,
onCancel = onDismiss, onCancel = if (selectionMode) onExitSelection else onDismiss,
onAccept = onUpload, onAccept = if (selectionMode) onResubmitSelected else onUpload,
acceptText = stringResource(R.string.prefs_logbook_upload), acceptText = if (selectionMode) {
acceptEnabled = !uploadBusy stringResource(R.string.prefs_logbook_resubmit, selectedIds.size)
} else {
stringResource(R.string.prefs_logbook_upload)
},
acceptEnabled = if (selectionMode) selectedIds.isNotEmpty() && !uploadBusy else !uploadBusy
) { ) {
Column( Column(
modifier = Modifier.fillMaxWidth().padding(horizontal = LocalSpacing.current.large), modifier = Modifier.fillMaxWidth().padding(horizontal = LocalSpacing.current.large),
@@ -108,6 +142,66 @@ fun LogbookDialog(
if (records.isEmpty()) { if (records.isEmpty()) {
Text(stringResource(R.string.prefs_logbook_empty), fontSize = 14.sp) Text(stringResource(R.string.prefs_logbook_empty), fontSize = 14.sp)
} else { } else {
// Selection mode shows the checked count; otherwise a hint that long-press
// opens it (the path to resubmitting already-uploaded records).
if (selectionMode) {
Text(
text = stringResource(R.string.prefs_logbook_selected, selectedIds.size),
fontSize = 12.sp,
color = MaterialTheme.colorScheme.primary
)
} else {
Text(
text = stringResource(R.string.prefs_logbook_select_hint),
fontSize = 11.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
if (stationOptions.isNotEmpty()) {
// 台址 selector: filters the list to one Wavelog station profile
// (its QSOs) — "All" keeps everything. The menu scrolls natively.
var stationMenuOpen by remember { mutableStateOf(false) }
val currentLabel = stationOptions.firstOrNull { it.id == stationFilter }?.let { station ->
listOf(station.name, station.callsign).filter { it.isNotBlank() }.joinToString(" · ")
} ?: stringResource(R.string.prefs_logbook_station_all)
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp)
) {
Text(stringResource(R.string.prefs_logbook_station_label), fontSize = 12.sp)
Box {
TextButton(onClick = { stationMenuOpen = true }) {
Text(currentLabel, fontSize = 12.sp)
}
DropdownMenu(
expanded = stationMenuOpen,
onDismissRequest = { stationMenuOpen = false }
) {
DropdownMenuItem(
text = { Text(stringResource(R.string.prefs_logbook_station_all)) },
onClick = {
onStationFilterChange(null)
stationMenuOpen = false
}
)
stationOptions.forEach { station ->
DropdownMenuItem(
text = {
Text(
listOf(station.name, station.callsign, station.grid)
.filter { it.isNotBlank() }.joinToString(" · ")
)
},
onClick = {
onStationFilterChange(station.id)
stationMenuOpen = false
}
)
}
}
}
}
}
LazyColumn( LazyColumn(
modifier = Modifier.fillMaxWidth().heightIn(max = 420.dp), modifier = Modifier.fillMaxWidth().heightIn(max = 420.dp),
verticalArrangement = Arrangement.spacedBy(2.dp) verticalArrangement = Arrangement.spacedBy(2.dp)
@@ -115,9 +209,16 @@ fun LogbookDialog(
items(records, key = { it.id }) { record -> items(records, key = { it.id }) { record ->
LogbookRow( LogbookRow(
record = record, record = record,
selectionMode = selectionMode,
selected = record.id in selectedIds,
swipeController = swipeController, swipeController = swipeController,
onDelete = { onDelete(record.id) }, onDelete = { onDelete(record.id) },
onClick = { editTarget = record } onClick = {
if (selectionMode) onToggleSelection(record.id) else editTarget = record
},
onLongClick = {
if (selectionMode) onToggleSelection(record.id) else onStartSelection(record.id)
}
) )
} }
} }
@@ -125,7 +226,22 @@ fun LogbookDialog(
} }
} }
if (preview != null) { if (positionWarning != null) {
// Roaming guard: the current position sits outside the station location this batch
// would be signed with — confirm the station location before the preview opens.
LoTWPositionWarningDialog(
warning = positionWarning,
onFixStation = { onFixGrid(listOf(positionWarning.currentGrid)) },
onIgnore = onIgnorePositionWarning
)
} else if (wavelogPreview != null) {
WavelogUploadPreviewDialog(
preview = wavelogPreview,
busy = uploadBusy,
onConfirm = onConfirmUpload,
onDismiss = onDismissPreview
)
} else if (preview != null) {
LogbookUploadPreviewDialog( LogbookUploadPreviewDialog(
preview = preview, preview = preview,
busy = uploadBusy, busy = uploadBusy,
@@ -138,7 +254,7 @@ fun LogbookDialog(
onDismissRequest = onDismissMessage, onDismissRequest = onDismissMessage,
shape = sheetDialogShape(), shape = sheetDialogShape(),
containerColor = sheetDialogContainerColor(), containerColor = sheetDialogContainerColor(),
title = { SheetDialogTitle("LoTW Upload") }, title = { SheetDialogTitle(if (wavelogMode) "Wavelog Upload" else "LoTW Upload") },
text = { Text(uploadMessage) }, text = { Text(uploadMessage) },
confirmButton = { confirmButton = {
TextButton(onClick = onDismissMessage) { Text("OK") } TextButton(onClick = onDismissMessage) { Text("OK") }
@@ -194,6 +310,15 @@ private fun LogbookUploadPreviewDialog(
} }
Text(parts.joinToString(" · "), fontSize = 12.sp, color = MaterialTheme.colorScheme.error) Text(parts.joinToString(" · "), fontSize = 12.sp, color = MaterialTheme.colorScheme.error)
} }
if (preview.resubmit) {
// LoTW treats an identical contact as an update, except grids already
// locked in by award credits — say so before the operator commits.
Text(
stringResource(R.string.prefs_logbook_resubmit_note),
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)
} }
}, },
@@ -208,12 +333,19 @@ private fun LogbookUploadPreviewDialog(
) )
} }
/** Confirmed-grid colour: the map's worked-grid green, so "confirmed" reads the same everywhere. */
private val ConfirmedGreen = Color(0xFF4CD964)
@OptIn(ExperimentalFoundationApi::class)
@Composable @Composable
private fun LogbookRow( private fun LogbookRow(
record: QsoRecord, record: QsoRecord,
selectionMode: Boolean,
selected: Boolean,
swipeController: SwipeController, swipeController: SwipeController,
onDelete: () -> Unit, onDelete: () -> Unit,
onClick: () -> Unit onClick: () -> Unit,
onLongClick: () -> Unit
) { ) {
val time = remember(record.startUtcMillis) { val time = remember(record.startUtcMillis) {
SimpleDateFormat("MM-dd HH:mm'Z'", Locale.US).apply { SimpleDateFormat("MM-dd HH:mm'Z'", Locale.US).apply {
@@ -222,11 +354,13 @@ private fun LogbookRow(
} }
val satShort = record.satelliteName.substringBefore('(').trim() val satShort = record.satelliteName.substringBefore('(').trim()
val band = record.band.ifBlank { frequencyBand(record.txFrequencyHz) } val band = record.band.ifBlank { frequencyBand(record.txFrequencyHz) }
// 右划露出删除按钮(短信式);整行点击不再删除。 val haptics = LocalHapticFeedback.current
// 右划露出删除按钮(短信式);整行点击不再删除。选择模式下右划禁用、长按进入多选。
SwipeRevealRow( SwipeRevealRow(
key = record.id.toString(), key = record.id.toString(),
controller = swipeController, controller = swipeController,
revealAction = onDelete, revealAction = onDelete,
gesturesEnabled = !selectionMode,
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()
) { ) {
// Row style mirrors the worked-grid QSO details on the map: callsign in // Row style mirrors the worked-grid QSO details on the map: callsign in
@@ -234,10 +368,30 @@ private fun LogbookRow(
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.clickable { onClick() } .combinedClickable(
onClick = onClick,
onLongClick = {
haptics.performHapticFeedback(HapticFeedbackType.LongPress)
onLongClick()
}
)
.padding(horizontal = 4.dp, vertical = 8.dp) .padding(horizontal = 4.dp, vertical = 8.dp)
) { ) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) { Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
// Checkbox slot: only while picking records for a resubmit.
if (selectionMode) {
Text(
text = if (selected) "✓" else "○",
fontSize = 15.sp,
fontFamily = FontFamily.Monospace,
color = if (selected) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f)
},
modifier = Modifier.padding(end = 6.dp)
)
}
Text( Text(
text = record.theirCallsign, text = record.theirCallsign,
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
@@ -247,14 +401,30 @@ private fun LogbookRow(
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f) modifier = Modifier.weight(1f)
) )
// Confirmed contacts show the opposite station's grid set from the LoTW
// report (single or multi-grid, abbreviated) in the worked-grid green
// instead of the word QSL; without any grid the QSL text stays.
if (record.lotwConfirmed) { if (record.lotwConfirmed) {
val oppositeGrids = gridsLabel(
record.theirVuccGrids.ifEmpty { listOf(record.theirGrid) }
)
if (oppositeGrids.isNotEmpty()) {
Text(text = oppositeGrids, fontSize = 13.sp, color = ConfirmedGreen, fontFamily = FontFamily.Monospace)
} else {
Text(text = "QSL", fontSize = 13.sp, color = MaterialTheme.colorScheme.primary, fontFamily = FontFamily.Monospace) Text(text = "QSL", fontSize = 13.sp, color = MaterialTheme.colorScheme.primary, fontFamily = FontFamily.Monospace)
}
} else if (record.lotwUploaded) { } else if (record.lotwUploaded) {
Text(text = "UP", fontSize = 13.sp, color = MaterialTheme.colorScheme.primary, fontFamily = FontFamily.Monospace) Text(text = "UP", fontSize = 13.sp, color = MaterialTheme.colorScheme.primary, fontFamily = FontFamily.Monospace)
} }
} }
Text( Text(
text = listOf(satShort, record.displayMode, band, time).filter { it.isNotBlank() }.joinToString(" · "), // Own station grid: the set the upload was stamped with, or — for rows from
// before the stamp existed — the grid the QSO was logged under, so every
// stored contact shows where the operator was.
text = listOf(
satShort, record.displayMode, band, time,
gridsLabel(record.vuccGrids.ifEmpty { listOf(record.myGrid) })
).filter { it.isNotBlank() }.joinToString(" · "),
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1, maxLines = 1,
@@ -28,6 +28,8 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress
import androidx.compose.foundation.gestures.scrollBy import androidx.compose.foundation.gestures.scrollBy
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
@@ -39,6 +41,7 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
@@ -46,6 +49,7 @@ import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Checkbox import androidx.compose.material3.Checkbox
import androidx.compose.material3.FilterChip import androidx.compose.material3.FilterChip
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
@@ -69,6 +73,7 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.runtime.toMutableStateList import androidx.compose.runtime.toMutableStateList
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@@ -158,23 +163,32 @@ fun LocatorDialog(qthLocator: String, dismiss: () -> Unit, save: (String) -> Uni
} }
} }
@Composable @Composable
fun WavelogDialog( fun WavelogUploadDialog(
initialSettings: com.rtbishop.look4sat.core.domain.model.WavelogSettings, initialSettings: com.rtbishop.look4sat.core.domain.model.WavelogUploadSettings,
workedGridsCount: Int, stations: List<com.rtbishop.look4sat.core.domain.repository.WavelogStationInfo>,
rights: String,
probeBusy: Boolean,
isSyncing: Boolean, isSyncing: Boolean,
lastSyncEpochMs: Long,
message: String?, message: String?,
dismiss: () -> Unit, dismiss: () -> Unit,
onSave: (com.rtbishop.look4sat.core.domain.model.WavelogSettings) -> Unit, onSave: (com.rtbishop.look4sat.core.domain.model.WavelogUploadSettings) -> Unit,
onSync: (com.rtbishop.look4sat.core.domain.model.WavelogSettings) -> Unit onFetchStations: (url: String, apiKey: String) -> Unit,
onSelectStation: (com.rtbishop.look4sat.core.domain.repository.WavelogStationInfo) -> Unit,
onSyncIncremental: () -> Unit,
onSyncFull: () -> Unit,
onClear: () -> Unit
) { ) {
val url = rememberSaveable { mutableStateOf(initialSettings.url) } val url = rememberSaveable { mutableStateOf(initialSettings.url) }
val token = rememberSaveable { mutableStateOf(initialSettings.token) } val apiKey = rememberSaveable { mutableStateOf(initialSettings.apiKey) }
var showClearConfirm by remember { mutableStateOf(false) }
SharedDialog( SharedDialog(
title = stringResource(R.string.prefs_wavelog_title), title = stringResource(R.string.prefs_wavelog_upload_title),
onCancel = dismiss, onCancel = dismiss,
onAccept = { onAccept = {
onSave(com.rtbishop.look4sat.core.domain.model.WavelogSettings(url.value, token.value)) onSave(initialSettings.copy(url = url.value, apiKey = apiKey.value))
dismiss() dismiss()
} }
) { ) {
@@ -182,23 +196,120 @@ fun WavelogDialog(
value = url.value, value = url.value,
onValueChange = { url.value = it }, onValueChange = { url.value = it },
label = { Text(text = stringResource(id = R.string.prefs_wavelog_url)) }, label = { Text(text = stringResource(id = R.string.prefs_wavelog_url)) },
placeholder = { Text(text = "http://192.168.1.10") }, placeholder = { Text(text = "http://100.x.x.x/wavelog") },
singleLine = true, singleLine = true,
modifier = Modifier.fillMaxWidth().padding(horizontal = LocalSpacing.current.large), modifier = Modifier.fillMaxWidth().padding(horizontal = LocalSpacing.current.large),
) )
OutlinedTextField( OutlinedTextField(
value = token.value, value = apiKey.value,
onValueChange = { token.value = it }, onValueChange = { apiKey.value = it },
label = { Text(text = stringResource(id = R.string.prefs_wavelog_token)) }, label = { Text(text = stringResource(id = R.string.prefs_wavelog_upload_apikey)) },
placeholder = { Text(text = "abcdef123456:1") }, placeholder = { Text(text = "wl…") },
singleLine = true, singleLine = true,
modifier = Modifier.fillMaxWidth().padding(horizontal = LocalSpacing.current.large), 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(
text = stringResource(R.string.prefs_wavelog_hint, workedGridsCount), 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, style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(horizontal = LocalSpacing.current.large) 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 {
// Many station profiles must stay reachable: the list scrolls inside a
// bounded height instead of stretching (or clipping) the dialog.
Column(
modifier = Modifier
.fillMaxWidth()
.heightIn(max = 240.dp)
.verticalScroll(rememberScrollState())
) {
stations.forEach { station ->
val selected = station.id == initialSettings.stationId
Text(
text = (if (selected) "● " else "○ ") + listOf(station.name, station.callsign, station.grid)
.filter { it.isNotBlank() }.joinToString(" · "),
color = if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface,
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier
.fillMaxWidth()
.clickable { onSelectStation(station) }
.padding(horizontal = LocalSpacing.current.large, vertical = 2.dp)
)
}
}
}
if (initialSettings.stationId.isNotBlank()) {
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 (lastSyncEpochMs != 0L) {
val timePattern = stringResource(R.string.prefs_updated_time)
val lastSyncText = remember(lastSyncEpochMs) {
java.text.SimpleDateFormat(timePattern, java.util.Locale.getDefault())
.format(java.util.Date(lastSyncEpochMs))
}
Text(
text = stringResource(R.string.prefs_updated_title, lastSyncText),
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(horizontal = LocalSpacing.current.large)
)
}
// Sync pulls the server's QSOs (and the grid data the map highlights) — the
// incremental button resumes from the stored cursor, the full one re-pulls.
Row(
horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier.fillMaxWidth().padding(horizontal = LocalSpacing.current.large)
) {
CardButton(
onClick = onSyncFull,
text = stringResource(R.string.prefs_wavelog_sync_full),
isEnabled = !isSyncing && !probeBusy
)
CardButton(
onClick = onSyncIncremental,
text = stringResource(R.string.prefs_wavelog_sync_incremental),
isEnabled = !isSyncing && !probeBusy
)
}
if (isSyncing) {
Text(
text = stringResource(R.string.prefs_wavelog_syncing),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = LocalSpacing.current.large)
)
}
if (message != null) { if (message != null) {
Text( Text(
text = message, text = message,
@@ -207,22 +318,54 @@ fun WavelogDialog(
modifier = Modifier.padding(horizontal = LocalSpacing.current.large) modifier = Modifier.padding(horizontal = LocalSpacing.current.large)
) )
} }
// Sync uses the values typed in the fields directly — saving and syncing Text(
// happen in one step, no need to close and reopen the dialog. text = stringResource(R.string.prefs_wavelog_upload_hint),
Row( style = MaterialTheme.typography.bodySmall,
horizontalArrangement = Arrangement.End, modifier = Modifier.padding(horizontal = LocalSpacing.current.large)
modifier = Modifier.fillMaxWidth().padding(horizontal = LocalSpacing.current.large) )
) {
TextButton( TextButton(
onClick = { onSync(com.rtbishop.look4sat.core.domain.model.WavelogSettings(url.value, token.value)) }, onClick = { showClearConfirm = true },
enabled = !isSyncing modifier = Modifier.padding(horizontal = LocalSpacing.current.large)
) { ) {
Text(text = if (isSyncing) stringResource(R.string.prefs_wavelog_syncing) Text(text = stringResource(R.string.prefs_wavelog_clear), color = MaterialTheme.colorScheme.error)
else stringResource(R.string.prefs_wavelog_sync))
}
} }
Spacer(modifier = Modifier.height(0.dp)) Spacer(modifier = Modifier.height(0.dp))
} }
if (showClearConfirm) {
AlertDialog(
onDismissRequest = { showClearConfirm = false },
title = { Text(stringResource(R.string.prefs_wavelog_clear_title)) },
text = { Text(stringResource(R.string.prefs_wavelog_clear_message)) },
confirmButton = {
TextButton(onClick = {
showClearConfirm = false
onClear()
}) { Text(stringResource(R.string.btn_accept)) }
},
dismissButton = {
TextButton(onClick = { showClearConfirm = false }) { Text(stringResource(R.string.btn_cancel)) }
}
)
}
}
/** LoTW→Wavelog hand-over guard: shown when completing the Wavelog config would switch the mode. */
@Composable
fun WavelogSwitchWarningDialog(
onConfirm: () -> Unit,
onCancel: () -> Unit
) {
AlertDialog(
onDismissRequest = onCancel,
title = { Text(stringResource(R.string.prefs_wavelog_switch_title)) },
text = { Text(stringResource(R.string.prefs_wavelog_switch_message)) },
confirmButton = {
TextButton(onClick = onConfirm) { Text(stringResource(R.string.prefs_wavelog_switch_continue)) }
},
dismissButton = {
TextButton(onClick = onCancel) { Text(stringResource(R.string.btn_cancel)) }
}
)
} }
@Composable @Composable
@@ -469,6 +612,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 +627,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 +646,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 +669,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
@@ -54,6 +54,7 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
@@ -69,11 +70,12 @@ import androidx.lifecycle.viewmodel.compose.viewModel
import com.rtbishop.look4sat.core.domain.model.DataSourcesSettings import com.rtbishop.look4sat.core.domain.model.DataSourcesSettings
import com.rtbishop.look4sat.core.domain.model.OtherSettings import com.rtbishop.look4sat.core.domain.model.OtherSettings
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.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.IContainerProvider import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
import com.rtbishop.look4sat.core.domain.repository.LoTWSyncMode import com.rtbishop.look4sat.core.domain.repository.LoTWSyncMode
import com.rtbishop.look4sat.core.domain.repository.WavelogSyncMode
import com.rtbishop.look4sat.core.presentation.CardButton import com.rtbishop.look4sat.core.presentation.CardButton
import com.rtbishop.look4sat.core.presentation.SharedDialog import com.rtbishop.look4sat.core.presentation.SharedDialog
import com.rtbishop.look4sat.core.presentation.IconCard import com.rtbishop.look4sat.core.presentation.IconCard
@@ -94,14 +96,29 @@ fun SettingsDestination(onOpenGridFinder: () -> Unit = {}) {
val container = (context.applicationContext as IContainerProvider).getMainContainer() val container = (context.applicationContext as IContainerProvider).getMainContainer()
val viewModel: SettingsViewModel = viewModel(factory = SettingsViewModel.factory(container, context)) val viewModel: SettingsViewModel = viewModel(factory = SettingsViewModel.factory(container, context))
val uiState by viewModel.uiState.collectAsStateWithLifecycle() val uiState by viewModel.uiState.collectAsStateWithLifecycle()
SettingsScreen(uiState, viewModel::onAction, onOpenGridFinder) // Station-grid prefill for the "fix it" jump from the logbook grid check (single-use,
// mirrors the Grid Finder's "Set as LoTW station" hand-off).
val pendingStationGrid by container.pendingLoTWStationGrid.collectAsStateWithLifecycle()
SettingsScreen(
uiState = uiState,
onAction = viewModel::onAction,
onOpenGridFinder = onOpenGridFinder,
pendingStationGrid = pendingStationGrid.orEmpty(),
onFixStationGrid = { grids ->
if (grids.isNotEmpty()) container.setPendingLoTWStationGrid(grids.joinToString(","))
},
onClearStationGridPrefill = { container.setPendingLoTWStationGrid(null) }
)
} }
@Composable @Composable
private fun SettingsScreen( private fun SettingsScreen(
uiState: SettingsState, uiState: SettingsState,
onAction: (SettingsAction) -> Unit, onAction: (SettingsAction) -> Unit,
onOpenGridFinder: () -> Unit onOpenGridFinder: () -> Unit,
pendingStationGrid: String = "",
onFixStationGrid: (List<String>) -> Unit = {},
onClearStationGridPrefill: () -> Unit = {}
) { ) {
var showUpdateChecker by rememberSaveable { mutableStateOf(false) } var showUpdateChecker by rememberSaveable { mutableStateOf(false) }
var showMapSettings by rememberSaveable { mutableStateOf(false) } var showMapSettings by rememberSaveable { mutableStateOf(false) }
@@ -271,19 +288,45 @@ private fun SettingsScreen(
) )
} }
if (dialogs.logbook) { if (dialogs.logbook) {
val stationFilter = uiState.logbookStationFilter
LogbookDialog( LogbookDialog(
records = uiState.logbookRecords, records = uiState.logbookRecords.filter { stationFilter == null || it.wavelogStation == stationFilter },
satelliteCandidates = uiState.satelliteCatalog, satelliteCandidates = uiState.satelliteCatalog,
uploadBusy = uiState.logbookUploadBusy, uploadBusy = uiState.logbookUploadBusy,
uploadMessage = uiState.logbookUploadMessage, uploadMessage = uiState.logbookUploadMessage,
preview = uiState.logbookPreview, preview = uiState.logbookPreview,
onDismiss = { dialogs.logbook = false }, positionWarning = uiState.logbookPositionWarning,
onDismiss = {
// Leaving the logbook resets the resubmit selection: re-entering starts
// clean — the operator long-presses again to pick records.
onAction(SettingsAction.ExitLogbookSelection)
dialogs.logbook = false
},
onDelete = { onAction(SettingsAction.DeleteLogbookRecord(it)) }, onDelete = { onAction(SettingsAction.DeleteLogbookRecord(it)) },
onEdit = { onAction(SettingsAction.UpdateLogbookRecord(it)) }, onEdit = { onAction(SettingsAction.UpdateLogbookRecord(it)) },
onUpload = { onAction(SettingsAction.PrepareLogbookUpload) }, onUpload = { onAction(SettingsAction.PrepareLogbookUpload) },
onConfirmUpload = { onAction(SettingsAction.ConfirmLogbookUpload) }, onConfirmUpload = { onAction(SettingsAction.ConfirmLogbookUpload) },
onDismissPreview = { onAction(SettingsAction.DismissLogbookPreview) }, onDismissPreview = { onAction(SettingsAction.DismissLogbookPreview) },
onDismissMessage = { onAction(SettingsAction.ClearLogbookMessage) } onDismissMessage = { onAction(SettingsAction.ClearLogbookMessage) },
onIgnorePositionWarning = { onAction(SettingsAction.IgnoreLogbookPositionWarning) },
onFixGrid = { grids ->
onAction(SettingsAction.AbandonLogbookForGridFix)
onAction(SettingsAction.ExitLogbookSelection)
dialogs.logbook = false
onFixStationGrid(grids)
dialogs.lotwUpload = true
},
selectionMode = uiState.logbookSelectionMode,
selectedIds = uiState.logbookSelectedIds,
onStartSelection = { onAction(SettingsAction.StartLogbookSelection(it)) },
onToggleSelection = { onAction(SettingsAction.ToggleLogbookSelection(it)) },
onExitSelection = { onAction(SettingsAction.ExitLogbookSelection) },
onResubmitSelected = { onAction(SettingsAction.ResubmitSelectedLogbook) },
wavelogPreview = uiState.logbookWavelogPreview,
wavelogMode = uiState.wavelogUploadSettings.isReady,
stationOptions = if (uiState.wavelogUploadSettings.isReady) uiState.wavelogUploadStations else emptyList(),
stationFilter = uiState.logbookStationFilter,
onStationFilterChange = { onAction(SettingsAction.SetLogbookStationFilter(it)) }
) )
} }
if (dialogs.lotwUpload) { if (dialogs.lotwUpload) {
@@ -294,13 +337,43 @@ private fun SettingsScreen(
busy = uiState.lotwUploadBusy, busy = uiState.lotwUploadBusy,
error = uiState.lotwUploadError, error = uiState.lotwUploadError,
errorDetail = uiState.lotwUploadErrorDetail, errorDetail = uiState.lotwUploadErrorDetail,
onDismiss = { dialogs.lotwUpload = false }, initialGrid = pendingStationGrid,
onDismiss = {
dialogs.lotwUpload = false
onClearStationGridPrefill()
},
onImport = { bytes, password -> onAction(SettingsAction.ImportLoTWCertificate(bytes, password)) }, onImport = { bytes, password -> onAction(SettingsAction.ImportLoTWCertificate(bytes, password)) },
onPreview = { bytes, password -> onAction(SettingsAction.PreviewLoTWCertificate(bytes, password)) },
onRemove = { onAction(SettingsAction.RemoveLoTWCertificate) }, onRemove = { onAction(SettingsAction.RemoveLoTWCertificate) },
onSaveStation = { onAction(SettingsAction.SaveLoTWStation(it)) } onSaveStation = { onAction(SettingsAction.SaveLoTWStation(it)) }
) )
} }
if (dialogs.wavelogUpload) {
WavelogUploadDialog(
initialSettings = uiState.wavelogUploadSettings,
stations = uiState.wavelogUploadStations,
rights = uiState.wavelogUploadRights,
probeBusy = uiState.wavelogUploadProbeBusy,
isSyncing = uiState.wavelogSyncing,
lastSyncEpochMs = uiState.wavelogLastSyncEpochMs,
message = uiState.wavelogUploadMessage,
dismiss = { dialogs.wavelogUpload = false },
onSave = { onAction(SettingsAction.UpdateWavelogUpload(it)) },
onFetchStations = { url, apiKey -> onAction(SettingsAction.FetchWavelogUploadStations(url, apiKey)) },
onSelectStation = { onAction(SettingsAction.SelectWavelogUploadStation(it)) },
onSyncIncremental = { onAction(SettingsAction.SyncWavelog(WavelogSyncMode.Incremental)) },
onSyncFull = { onAction(SettingsAction.SyncWavelog(WavelogSyncMode.Full)) },
onClear = { onAction(SettingsAction.ClearWavelogConfig) }
)
}
if (uiState.wavelogSwitchWarning) {
WavelogSwitchWarningDialog(
onConfirm = { onAction(SettingsAction.ConfirmWavelogSwitch) },
onCancel = { onAction(SettingsAction.CancelWavelogSwitch) }
)
}
// 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)
@@ -369,6 +442,7 @@ private fun SettingsScreen(
} }
) { _ -> ) { _ ->
val isVerticalLayout = isVerticalLayout() val isVerticalLayout = isVerticalLayout()
val wavelogMode = uiState.wavelogUploadSettings.isReady
LazyVerticalGrid( LazyVerticalGrid(
columns = GridCells.Fixed(if (isVerticalLayout) 1 else 2), columns = GridCells.Fixed(if (isVerticalLayout) 1 else 2),
horizontalArrangement = Arrangement.spacedBy(6.dp), horizontalArrangement = Arrangement.spacedBy(6.dp),
@@ -411,6 +485,7 @@ private fun SettingsScreen(
LoTWUploadCard( LoTWUploadCard(
hasCertificate = uiState.lotwCertificate != null, hasCertificate = uiState.lotwCertificate != null,
stationGrid = uiState.lotwStation?.grid.orEmpty(), stationGrid = uiState.lotwStation?.grid.orEmpty(),
enabled = !wavelogMode,
showUploadConfigDialog = { onAction(SettingsAction.LoadLoTWUploadStatus); dialogs.lotwUpload = true } showUploadConfigDialog = { onAction(SettingsAction.LoadLoTWUploadStatus); dialogs.lotwUpload = true }
) )
} }
@@ -419,13 +494,22 @@ private fun SettingsScreen(
settings = uiState.lotwSettings, settings = uiState.lotwSettings,
workedGridsCount = uiState.workedGridsCount, workedGridsCount = uiState.workedGridsCount,
lastSyncEpochMs = uiState.lotwLastSyncEpochMs, lastSyncEpochMs = uiState.lotwLastSyncEpochMs,
enabled = !wavelogMode,
showLoTWDialog = { dialogs.lotw = true } showLoTWDialog = { dialogs.lotw = true }
) )
} }
item {
WavelogUploadCard(
settings = uiState.wavelogUploadSettings,
lastSyncEpochMs = uiState.wavelogLastSyncEpochMs,
showDialog = { dialogs.wavelogUpload = true }
)
}
item { item {
OtherCard( OtherCard(
settings = uiState.otherSettings, settings = uiState.otherSettings,
onCompassCalibration = { dialogs.compassCalibration = true }, onCompassCalibration = { dialogs.compassCalibration = true },
loTWEnabled = !wavelogMode,
onAction = onAction onAction = onAction
) )
} }
@@ -650,6 +734,7 @@ private fun MapSettingsCard(onClick: () -> Unit) {
private fun OtherCard( private fun OtherCard(
settings: OtherSettings, settings: OtherSettings,
onCompassCalibration: () -> Unit, onCompassCalibration: () -> Unit,
loTWEnabled: Boolean = true,
onAction: (SettingsAction) -> Unit onAction: (SettingsAction) -> Unit
) { ) {
ElevatedCard( ElevatedCard(
@@ -668,7 +753,11 @@ private fun OtherCard(
SwitchRow(R.string.prefs_other_switch_update, settings.stateOfAutoUpdate) { SwitchRow(R.string.prefs_other_switch_update, settings.stateOfAutoUpdate) {
onAction(SettingsAction.ToggleUpdate(it)) onAction(SettingsAction.ToggleUpdate(it))
} }
SwitchRow(R.string.prefs_other_switch_lotw_sync, settings.stateOfAutoLotwSync) { SwitchRow(
R.string.prefs_other_switch_lotw_sync,
settings.stateOfAutoLotwSync,
enabled = loTWEnabled
) {
onAction(SettingsAction.ToggleAutoLotwSync(it)) onAction(SettingsAction.ToggleAutoLotwSync(it))
} }
SwitchRow(R.string.prefs_other_switch_sweep, settings.stateOfSweep) { SwitchRow(R.string.prefs_other_switch_sweep, settings.stateOfSweep) {
@@ -808,10 +897,15 @@ private fun LoTWCard(
settings: com.rtbishop.look4sat.core.domain.model.LoTWSettings, settings: com.rtbishop.look4sat.core.domain.model.LoTWSettings,
workedGridsCount: Int, workedGridsCount: Int,
lastSyncEpochMs: Long, lastSyncEpochMs: Long,
enabled: Boolean = true,
showLoTWDialog: () -> Unit showLoTWDialog: () -> Unit
) { ) {
ElevatedCard(modifier = Modifier.fillMaxWidth()) { ElevatedCard(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)) { Column(
modifier = Modifier
.padding(horizontal = 8.dp, vertical = 4.dp)
.alpha(if (enabled) 1f else 0.5f)
) {
Text( Text(
text = stringResource(id = R.string.prefs_lotw_title), text = stringResource(id = R.string.prefs_lotw_title),
color = MaterialTheme.colorScheme.primary color = MaterialTheme.colorScheme.primary
@@ -834,10 +928,70 @@ private fun LoTWCard(
style = MaterialTheme.typography.bodySmall style = MaterialTheme.typography.bodySmall
) )
} }
if (!enabled) {
Spacer(modifier = Modifier.height(2.dp))
Text(
text = stringResource(R.string.prefs_lotw_disabled_wavelog),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 2
)
}
Spacer(modifier = Modifier.height(4.dp)) Spacer(modifier = Modifier.height(4.dp))
CardButton( CardButton(
onClick = showLoTWDialog, onClick = showLoTWDialog,
text = stringResource(id = R.string.prefs_wavelog_configure), text = stringResource(id = R.string.prefs_wavelog_configure),
isEnabled = enabled,
modifier = Modifier.fillMaxWidth()
)
}
}
}
@Composable
private fun WavelogUploadCard(
settings: com.rtbishop.look4sat.core.domain.model.WavelogUploadSettings,
lastSyncEpochMs: Long,
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 = stringResource(R.string.prefs_wavelog_exclusive),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 2
)
Spacer(modifier = Modifier.height(2.dp))
Text(
text = if (settings.isReady) {
stringResource(
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
)
if (lastSyncEpochMs != 0L) {
Spacer(modifier = Modifier.height(2.dp))
Text(
text = formatUpdateTime(updateTime = lastSyncEpochMs),
style = MaterialTheme.typography.bodySmall
)
}
Spacer(modifier = Modifier.height(4.dp))
CardButton(
onClick = showDialog,
text = stringResource(id = R.string.prefs_wavelog_configure),
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()
) )
} }
@@ -990,6 +1144,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
@@ -997,7 +1152,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 {
@@ -1007,6 +1162,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 }
} }
} }
) )
@@ -22,7 +22,7 @@ import com.rtbishop.look4sat.core.domain.model.LatestRelease
import com.rtbishop.look4sat.core.domain.model.OtherSettings 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.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
@@ -57,10 +57,25 @@ data class SettingsState(
val radioControlSettings: RadioControlSettings, val radioControlSettings: RadioControlSettings,
val dataSourcesSettings: DataSourcesSettings, val dataSourcesSettings: DataSourcesSettings,
val dataSourcesStatus: Map<String, Int> = emptyMap(), val dataSourcesStatus: Map<String, Int> = emptyMap(),
val wavelogSettings: WavelogSettings = WavelogSettings(),
val workedGridsCount: Int = 0, val workedGridsCount: Int = 0,
/** Wavelog configuration (one block; LoTW and Wavelog are mutually exclusive). */
val wavelogUploadSettings: WavelogUploadSettings = WavelogUploadSettings(),
val wavelogUploadStations: List<com.rtbishop.look4sat.core.domain.repository.WavelogStationInfo> = emptyList(),
val wavelogUploadRights: String = "",
val wavelogUploadProbeBusy: Boolean = false,
/** Config-block feedback (probe failures, sync results); also shown inside the config dialog. */
val wavelogUploadMessage: String? = null,
/** Wavelog sync (download of QSOs + grids). */
val wavelogSyncing: Boolean = false, val wavelogSyncing: Boolean = false,
val wavelogMessage: String? = null, /** Epoch ms of the last successful Wavelog sync (0 = never). */
val wavelogLastSyncEpochMs: Long = 0L,
/** Mode-switch guard: a change that would hand the logbook over to Wavelog is
* waiting for the operator's confirmation (LoTW is configured). */
val wavelogSwitchWarning: Boolean = false,
/** Logbook 台址 selector: selected Wavelog station id (null = 全部). */
val logbookStationFilter: String? = null,
/** Wavelog-mode logbook upload: prepared batch awaiting confirmation. */
val logbookWavelogPreview: com.rtbishop.look4sat.core.domain.repository.WavelogUploadPreview? = null,
val lotwSettings: com.rtbishop.look4sat.core.domain.model.LoTWSettings = com.rtbishop.look4sat.core.domain.model.LoTWSettings(), val 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,
@@ -85,9 +100,16 @@ data class SettingsState(
val lotwUploadErrorDetail: String = "", val lotwUploadErrorDetail: String = "",
/** One-click logbook upload: prepared preview awaiting confirmation. */ /** One-click logbook upload: prepared preview awaiting confirmation. */
val logbookPreview: com.rtbishop.look4sat.core.domain.repository.LoTWUploadPreview? = null, val logbookPreview: com.rtbishop.look4sat.core.domain.repository.LoTWUploadPreview? = null,
/** Roaming guard: the current position grid is outside the station grids the prepared
* batch would be signed with — shown as a dialog before the preview opens. */
val logbookPositionWarning: com.rtbishop.look4sat.core.domain.repository.LoTWPositionWarning? = null,
val logbookUploadBusy: Boolean = false, val logbookUploadBusy: Boolean = false,
/** User-facing upload message shown inside the logbook dialog ("" = none). */ /** User-facing upload message shown inside the logbook dialog ("" = none). */
val logbookUploadMessage: String = "", val logbookUploadMessage: String = "",
/** Logbook multi-select mode (entered by long-pressing a row); checked records can be
* re-uploaded so a corrected station location updates them on LoTW (resubmit). */
val logbookSelectionMode: Boolean = false,
val logbookSelectedIds: Set<Long> = emptySet(),
/** 指南针校准精度等级 (校准对话框进度条). */ /** 指南针校准精度等级 (校准对话框进度条). */
val compassAccuracy: CompassAccuracy = CompassAccuracy.UNRELIABLE, val compassAccuracy: CompassAccuracy = CompassAccuracy.UNRELIABLE,
/** 校正后航向(度, 含磁偏角+手动偏置), 校准对话框实时显示. */ /** 校正后航向(度, 含磁偏角+手动偏置), 校准对话框实时显示. */
@@ -139,9 +161,20 @@ sealed interface SettingsAction {
// Data sources // Data sources
data class UpdateDataSources(val settings: DataSourcesSettings) : SettingsAction data class UpdateDataSources(val settings: DataSourcesSettings) : SettingsAction
// Wavelog worked grids // Wavelog configuration (single block; LoTW and Wavelog are mutually exclusive)
data class UpdateWavelog(val settings: WavelogSettings) : SettingsAction data class UpdateWavelogUpload(val settings: WavelogUploadSettings) : SettingsAction
data class SyncWorkedGrids(val settings: WavelogSettings) : SettingsAction data class FetchWavelogUploadStations(val url: String, val apiKey: String) : SettingsAction
data class SelectWavelogUploadStation(val station: com.rtbishop.look4sat.core.domain.repository.WavelogStationInfo) : SettingsAction
/** Download QSOs (+ grids) from Wavelog. */
data class SyncWavelog(val mode: com.rtbishop.look4sat.core.domain.repository.WavelogSyncMode) : SettingsAction
/** Confirmed the LoTW→Wavelog mode switch warning. */
data object ConfirmWavelogSwitch : SettingsAction
/** Declined the switch warning — nothing changes. */
data object CancelWavelogSwitch : SettingsAction
/** Wipe the Wavelog config (back to LoTW mode). */
data object ClearWavelogConfig : SettingsAction
/** Logbook 台址 selector choice (null = 全部). */
data class SetLogbookStationFilter(val stationId: String?) : SettingsAction
// LoTW confirmed grids // 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
@@ -160,11 +193,23 @@ sealed interface SettingsAction {
data object PrepareLogbookUpload : SettingsAction data object PrepareLogbookUpload : SettingsAction
data object ConfirmLogbookUpload : SettingsAction data object ConfirmLogbookUpload : SettingsAction
data object DismissLogbookPreview : SettingsAction data object DismissLogbookPreview : SettingsAction
/** The operator acknowledged the position mismatch and wants to upload anyway. */
data object IgnoreLogbookPositionWarning : SettingsAction
/** The operator chose to fix the station location first; the preview is discarded. */
data object AbandonLogbookForGridFix : SettingsAction
/** Long-press on a row: enter selection mode with that record checked. */
data class StartLogbookSelection(val id: Long) : SettingsAction
data class ToggleLogbookSelection(val id: Long) : SettingsAction
data object ExitLogbookSelection : SettingsAction
/** Re-upload the checked records — uploaded/confirmed rows included (resubmit). */
data object ResubmitSelectedLogbook : SettingsAction
data object ClearLogbookMessage : SettingsAction data object ClearLogbookMessage : SettingsAction
// LoTW upload configuration (certificate + station) // LoTW upload configuration (certificate + station)
data object LoadLoTWUploadStatus : SettingsAction data object LoadLoTWUploadStatus : SettingsAction
data class ImportLoTWCertificate(val bytes: ByteArray, val password: CharArray) : SettingsAction data class ImportLoTWCertificate(val bytes: ByteArray, val password: CharArray) : SettingsAction
/** Pre-import .p12 parse (not persisted) so the region field appears before confirming. */
data class PreviewLoTWCertificate(val bytes: ByteArray, val password: CharArray) : SettingsAction
data object RemoveLoTWCertificate : SettingsAction data object RemoveLoTWCertificate : SettingsAction
data class SaveLoTWStation(val station: com.rtbishop.look4sat.core.domain.repository.LoTWStation) : SettingsAction data class SaveLoTWStation(val station: com.rtbishop.look4sat.core.domain.repository.LoTWStation) : SettingsAction
@@ -23,7 +23,7 @@ import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.initializer 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.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
@@ -34,10 +34,25 @@ import com.rtbishop.look4sat.core.domain.repository.applyLoTWGridResult
import com.rtbishop.look4sat.core.domain.repository.lotwCursorApi import com.rtbishop.look4sat.core.domain.repository.lotwCursorApi
import com.rtbishop.look4sat.core.domain.repository.lotwCursorEpochMs 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.IWavelogRepository import com.rtbishop.look4sat.core.domain.repository.positionWarning
import com.rtbishop.look4sat.core.domain.repository.IWavelogUploadRepository
import com.rtbishop.look4sat.core.domain.repository.IWavelogSyncRepository
import com.rtbishop.look4sat.core.domain.repository.WavelogSyncFetch
import com.rtbishop.look4sat.core.domain.repository.WavelogSyncMode
import com.rtbishop.look4sat.core.domain.repository.applyWavelogSyncResult
import com.rtbishop.look4sat.core.domain.repository.resolveWavelogSyncMode
import com.rtbishop.look4sat.core.domain.repository.WavelogStationInfo
import com.rtbishop.look4sat.core.domain.repository.WavelogUploadOutcome
import com.rtbishop.look4sat.core.domain.repository.WavelogUploadPreview
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.toConfirmedRecord import com.rtbishop.look4sat.core.domain.logbook.toConfirmedRecord
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
@@ -50,7 +65,8 @@ class SettingsViewModel(
private val databaseRepo: IDatabaseRepo, private val databaseRepo: IDatabaseRepo,
private val settingsRepo: ISettingsRepo, private val settingsRepo: ISettingsRepo,
private val updateRepo: IUpdateRepository, private val updateRepo: IUpdateRepository,
private val wavelogRepo: IWavelogRepository, private val wavelogSyncRepository: IWavelogSyncRepository,
private val wavelogUploadRepository: IWavelogUploadRepository,
private val lotwRepo: com.rtbishop.look4sat.core.domain.repository.ILoTWRepository, private val 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,
@@ -75,7 +91,9 @@ class SettingsViewModel(
radioControlSettings = settingsRepo.radioControlSettings.value, radioControlSettings = settingsRepo.radioControlSettings.value,
dataSourcesSettings = settingsRepo.dataSourcesSettings.value, dataSourcesSettings = settingsRepo.dataSourcesSettings.value,
dataSourcesStatus = settingsRepo.dataSourcesStatus.value, dataSourcesStatus = settingsRepo.dataSourcesStatus.value,
wavelogSettings = settingsRepo.wavelogSettings.value, wavelogUploadSettings = settingsRepo.wavelogUploadSettings.value,
wavelogUploadStations = settingsRepo.getWavelogStations(),
wavelogLastSyncEpochMs = settingsRepo.getLastWavelogSyncEpochMs(),
workedGridsCount = settingsRepo.getWorkedGrids().size, workedGridsCount = settingsRepo.getWorkedGrids().size,
compassAccuracy = sensorsRepo.compassAccuracy.value, compassAccuracy = sensorsRepo.compassAccuracy.value,
compassHeadingDegrees = correctedCompassHeading(), compassHeadingDegrees = correctedCompassHeading(),
@@ -149,8 +167,8 @@ class SettingsViewModel(
} }
} }
viewModelScope.launch { viewModelScope.launch {
settingsRepo.wavelogSettings.collect { settings -> settingsRepo.wavelogUploadSettings.collect { settings ->
_uiState.update { it.copy(wavelogSettings = settings) } _uiState.update { it.copy(wavelogUploadSettings = settings) }
} }
} }
viewModelScope.launch { viewModelScope.launch {
@@ -219,9 +237,15 @@ class SettingsViewModel(
is SettingsAction.UpdateRC -> settingsRepo.updateRCSettings(action.settings) is SettingsAction.UpdateRC -> settingsRepo.updateRCSettings(action.settings)
is SettingsAction.UpdateRadioControl -> settingsRepo.updateRadioControlSettings(action.settings) is SettingsAction.UpdateRadioControl -> settingsRepo.updateRadioControlSettings(action.settings)
is SettingsAction.UpdateDataSources -> settingsRepo.updateDataSourcesSettings(action.settings) is SettingsAction.UpdateDataSources -> settingsRepo.updateDataSourcesSettings(action.settings)
// Wavelog worked grids // Wavelog configuration (single block; LoTW and Wavelog are mutually exclusive)
is SettingsAction.UpdateWavelog -> settingsRepo.updateWavelogSettings(action.settings) is SettingsAction.UpdateWavelogUpload -> applyWavelogUploadSettings(action.settings)
is SettingsAction.SyncWorkedGrids -> syncWorkedGrids(action.settings) is SettingsAction.FetchWavelogUploadStations -> fetchWavelogUploadStations(action.url, action.apiKey)
is SettingsAction.SelectWavelogUploadStation -> applyWavelogUploadStation(action.station)
is SettingsAction.SyncWavelog -> syncWavelog(action.mode)
SettingsAction.ConfirmWavelogSwitch -> confirmWavelogSwitch()
SettingsAction.CancelWavelogSwitch -> cancelWavelogSwitch()
SettingsAction.ClearWavelogConfig -> clearWavelogConfig()
is SettingsAction.SetLogbookStationFilter -> _uiState.update { it.copy(logbookStationFilter = action.stationId) }
// LoTW confirmed grids // 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)
@@ -233,10 +257,17 @@ class SettingsViewModel(
SettingsAction.PrepareLogbookUpload -> prepareLogbookUpload() SettingsAction.PrepareLogbookUpload -> prepareLogbookUpload()
SettingsAction.ConfirmLogbookUpload -> confirmLogbookUpload() SettingsAction.ConfirmLogbookUpload -> confirmLogbookUpload()
SettingsAction.DismissLogbookPreview -> dismissLogbookPreview() SettingsAction.DismissLogbookPreview -> dismissLogbookPreview()
SettingsAction.IgnoreLogbookPositionWarning -> ignoreLogbookPositionWarning()
SettingsAction.AbandonLogbookForGridFix -> abandonLogbookForGridFix()
is SettingsAction.StartLogbookSelection -> startLogbookSelection(action.id)
is SettingsAction.ToggleLogbookSelection -> toggleLogbookSelection(action.id)
SettingsAction.ExitLogbookSelection -> exitLogbookSelection()
SettingsAction.ResubmitSelectedLogbook -> resubmitSelectedLogbook()
SettingsAction.ClearLogbookMessage -> clearLogbookMessage() SettingsAction.ClearLogbookMessage -> clearLogbookMessage()
// LoTW upload configuration // LoTW upload configuration
SettingsAction.LoadLoTWUploadStatus -> loadLoTWUploadStatus() SettingsAction.LoadLoTWUploadStatus -> loadLoTWUploadStatus()
is SettingsAction.ImportLoTWCertificate -> importLoTWCertificate(action.bytes, action.password) is SettingsAction.ImportLoTWCertificate -> importLoTWCertificate(action.bytes, action.password)
is SettingsAction.PreviewLoTWCertificate -> previewLoTWCertificate(action.bytes, action.password)
SettingsAction.RemoveLoTWCertificate -> removeLoTWCertificate() SettingsAction.RemoveLoTWCertificate -> removeLoTWCertificate()
is SettingsAction.SaveLoTWStation -> saveLoTWStation(action.station) is SettingsAction.SaveLoTWStation -> saveLoTWStation(action.station)
// Update checker // Update checker
@@ -248,29 +279,187 @@ class SettingsViewModel(
} }
} }
// region Wavelog worked grids // Wavelog configuration + sync
private fun syncWorkedGrids(settings: WavelogSettings) { /** A switch that needs the LoTW→Wavelog hand-over confirmation before it applies. */
if (!settings.isConfigured) { private var pendingWavelogSwitch: (() -> Unit)? = null
_uiState.update { it.copy(wavelogMessage = "Wavelog URL/token not configured") }
private fun currentLoTWConfigured(): Boolean =
settingsRepo.lotwSettings.value.isConfigured || _uiState.value.lotwCertificate != null
/**
* Applies a Wavelog config change. Completing the configuration (the block turning
* ready) while LoTW is configured pauses for the operator's confirmation: from then
* on uploads and syncs go through Wavelog only and the LoTW sides stay unavailable
* (the one-of-two rule). A half-filled config changes nothing about the routing.
*/
private fun applyWavelogUploadSettings(settings: WavelogUploadSettings) {
val current = settingsRepo.wavelogUploadSettings.value
if (!current.isReady && settings.isReady && currentLoTWConfigured()) {
pendingWavelogSwitch = { settingsRepo.updateWavelogUploadSettings(settings) }
_uiState.update { it.copy(wavelogSwitchWarning = true) }
return return
} }
// Persist the credentials first, then sync with the freshly-typed values. settingsRepo.updateWavelogUploadSettings(settings)
settingsRepo.updateWavelogSettings(settings) }
_uiState.update { it.copy(wavelogSyncing = true, wavelogMessage = null) }
private fun applyWavelogUploadStation(station: WavelogStationInfo) {
val current = settingsRepo.wavelogUploadSettings.value
val updated = current.copy(
stationId = station.id,
stationName = station.name,
stationCallsign = station.callsign,
stationGrid = station.grid
)
if (!current.isReady && updated.isReady && currentLoTWConfigured()) {
pendingWavelogSwitch = { settingsRepo.updateWavelogUploadSettings(updated) }
_uiState.update { it.copy(wavelogSwitchWarning = true) }
return
}
settingsRepo.updateWavelogUploadSettings(updated)
}
private fun confirmWavelogSwitch() {
pendingWavelogSwitch?.invoke()
pendingWavelogSwitch = null
_uiState.update { it.copy(wavelogSwitchWarning = false) }
}
private fun cancelWavelogSwitch() {
pendingWavelogSwitch = null
_uiState.update { it.copy(wavelogSwitchWarning = false) }
}
/** Wipe the configuration — routing falls back to LoTW on the next check. */
private fun clearWavelogConfig() {
settingsRepo.updateWavelogUploadSettings(WavelogUploadSettings())
_uiState.update { it.copy(logbookStationFilter = null, logbookWavelogPreview = null) }
}
private fun fetchWavelogUploadStations(url: String, apiKey: String) {
if (_uiState.value.wavelogUploadProbeBusy) return
_uiState.update { it.copy(wavelogUploadProbeBusy = true, wavelogUploadMessage = null) }
viewModelScope.launch { viewModelScope.launch {
val grids = wavelogRepo.fetchWorkedGrids(settings.url, settings.token) val probe = wavelogUploadRepository.probe(url.trim(), apiKey.trim())
if (probe == null) {
_uiState.update { _uiState.update {
if (grids != null) { it.copy(
settingsRepo.setWorkedGrids(grids) wavelogUploadProbeBusy = false,
it.copy(wavelogSyncing = false, workedGridsCount = grids.size, wavelogMessage = null) wavelogUploadMessage = "Wavelog probe failed — check URL/key/network"
)
}
return@launch
}
// Persist the full list: the logbook's 台址 selector and the sync use it,
// not just the dialog that fetched it.
settingsRepo.setWavelogStations(probe.stations)
_uiState.update {
it.copy(
wavelogUploadProbeBusy = false,
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) applyWavelogUploadStation(auto)
}
}
/**
* Downloads QSOs from Wavelog and folds them into the logbook + map grid data.
* Incremental resumes from the per-station cursors; the full mode re-pulls each
* station from the start (healing anything a cursor missed) — neither ever deletes
* data the app already has.
*/
private fun syncWavelog(requested: WavelogSyncMode) {
if (_uiState.value.wavelogSyncing) return
val settings = settingsRepo.wavelogUploadSettings.value
if (!settings.isConfigured) {
_uiState.update { it.copy(wavelogUploadMessage = "Wavelog URL/key not configured") }
return
}
val stations = settingsRepo.getWavelogStations()
if (stations.isEmpty()) {
_uiState.update { it.copy(wavelogUploadMessage = "Fetch stations first — no station profile known") }
return
}
val mode = resolveWavelogSyncMode(settingsRepo.getWavelogSyncUrl(), settings.url, requested)
_uiState.update { it.copy(wavelogSyncing = true, wavelogUploadMessage = null) }
viewModelScope.launch {
try {
val fetch = wavelogSyncRepository.fetchNewRecords(
settings.url,
settings.apiKey,
stations,
settingsRepo.getWavelogSyncCursors(),
full = mode == WavelogSyncMode.Full
)
if (fetch == null) {
_uiState.update {
it.copy(wavelogSyncing = false, wavelogUploadMessage = "Wavelog sync failed — check URL/key/network")
}
return@launch
}
val merge = if (fetch.records.isEmpty()) {
com.rtbishop.look4sat.core.domain.logbook.AdifImportResult(0, 0, 0)
} else { } else {
it.copy(wavelogSyncing = false, wavelogMessage = "Sync failed — check URL/token/network") qsoRepository.mergeWavelog(fetch.records)
}
// Stations that failed keep their previous cursor; fetched ones advance.
val cursors = settingsRepo.getWavelogSyncCursors() + fetch.cursors
applyWavelogSyncResult(settingsRepo, fetch.records, cursors, settings.url)
_uiState.update {
it.copy(
wavelogSyncing = false,
workedGridsCount = settingsRepo.getWorkedGrids().size,
wavelogLastSyncEpochMs = settingsRepo.getLastWavelogSyncEpochMs(),
wavelogUploadMessage = wavelogSyncSummary(mode, fetch, merge, stations.size)
)
}
} catch (e: kotlinx.coroutines.CancellationException) {
throw e
} catch (_: Exception) {
_uiState.update {
it.copy(wavelogSyncing = false, wavelogUploadMessage = "Wavelog sync failed — check URL/key/network")
} }
} }
} }
} }
private fun wavelogSyncSummary(
mode: WavelogSyncMode,
fetch: WavelogSyncFetch,
merge: com.rtbishop.look4sat.core.domain.logbook.AdifImportResult,
totalStations: Int
): String {
val head = if (mode == WavelogSyncMode.Full) "Full Wavelog sync" else "Incremental Wavelog sync"
val base = "$head — ${merge.imported} new, ${merge.skipped} already there"
return if (fetch.failedStations.isEmpty()) base
else "$base · ${fetch.failedStations.size}/$totalStations station(s) failed"
}
/**
* Uploads one prepared Wavelog batch and marks what the server accepted (stamped
* with the station profile the batch went out through). Returns the operator-facing
* message for this upload.
*/
private suspend fun uploadWavelogNow(
preview: WavelogUploadPreview,
settings: WavelogUploadSettings
): String = try {
val outcome = wavelogUploadRepository.upload(preview, settings)
if (outcome is WavelogUploadOutcome.Imported && outcome.markIds.isNotEmpty()) {
qsoRepository.markWavelogUploaded(outcome.markIds, settings.stationId)
}
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
@@ -376,6 +565,12 @@ class SettingsViewModel(
_uiState.update { it.copy(logbookUploadBusy = true, logbookUploadMessage = "") } _uiState.update { it.copy(logbookUploadBusy = true, logbookUploadMessage = "") }
try { try {
val all = qsoRepository.records.first() val all = qsoRepository.records.first()
val wavelogSettings = settingsRepo.wavelogUploadSettings.value
if (wavelogSettings.isReady) {
// Wavelog mode: the logbook upload goes to Wavelog only.
prepareLogbookWavelogUpload(all, wavelogSettings)
return@launch
}
// Only local (non-confirmed) records are candidates for upload; // Only local (non-confirmed) records are candidates for upload;
// 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 }
@@ -396,7 +591,13 @@ class SettingsViewModel(
// Only the records that actually made it into the TQ8 may be // Only the records that actually made it into the TQ8 may be
// marked uploaded later — never the whole candidate list. // marked uploaded later — never the whole candidate list.
lastLogbookUploadIds = preview.submittedIds lastLogbookUploadIds = preview.submittedIds
_uiState.update { it.copy(logbookUploadBusy = false, logbookPreview = preview) } _uiState.update {
it.copy(
logbookUploadBusy = false,
logbookPreview = preview,
logbookPositionWarning = positionWarning(settingsRepo.getCurrentGrid(), preview.grids)
)
}
} catch (e: com.rtbishop.look4sat.core.domain.repository.LoTWOperationException) { } catch (e: com.rtbishop.look4sat.core.domain.repository.LoTWOperationException) {
_uiState.update { it.copy(logbookUploadBusy = false, logbookUploadMessage = "Upload unavailable: ${e.reason}") } _uiState.update { it.copy(logbookUploadBusy = false, logbookUploadMessage = "Upload unavailable: ${e.reason}") }
} catch (_: Exception) { } catch (_: Exception) {
@@ -405,13 +606,49 @@ class SettingsViewModel(
} }
} }
/** Wavelog-mode branch of [prepareLogbookUpload]: prepare the batch, then confirm. */
private suspend fun prepareLogbookWavelogUpload(
all: List<com.rtbishop.look4sat.core.domain.logbook.QsoRecord>,
settings: WavelogUploadSettings
) {
val preview = runCatching {
wavelogUploadRepository.prepare(wavelogUploadCandidates(all), settings)
}.getOrNull()
if (preview == null) {
_uiState.update {
it.copy(logbookUploadBusy = false, logbookUploadMessage = "Wavelog upload failed to prepare — check the configuration")
}
return
}
if (preview.count == 0) {
_uiState.update {
it.copy(
logbookUploadBusy = false,
logbookUploadMessage = wavelogIdleSegment(preview) ?: "No pending QSOs to upload to Wavelog"
)
}
return
}
_uiState.update { it.copy(logbookUploadBusy = false, logbookWavelogPreview = preview) }
}
private fun confirmLogbookUpload() { private fun confirmLogbookUpload() {
val wavelogPreview = _uiState.value.logbookWavelogPreview
if (wavelogPreview != null) {
viewModelScope.launch {
_uiState.update { it.copy(logbookUploadBusy = true) }
val msg = uploadWavelogNow(wavelogPreview, settingsRepo.wavelogUploadSettings.value)
_uiState.update { it.copy(logbookUploadBusy = false, logbookWavelogPreview = null, logbookUploadMessage = msg) }
}
return
}
val preview = _uiState.value.logbookPreview ?: return val preview = _uiState.value.logbookPreview ?: return
viewModelScope.launch { viewModelScope.launch {
_uiState.update { it.copy(logbookUploadBusy = true) } _uiState.update { it.copy(logbookUploadBusy = true) }
val result = lotwUploadRepository.upload(preview.id) val result = lotwUploadRepository.upload(preview.id)
if (result is com.rtbishop.look4sat.core.domain.repository.LoTWUploadResult.Accepted && lastLogbookUploadIds.isNotEmpty()) { val accepted = result is com.rtbishop.look4sat.core.domain.repository.LoTWUploadResult.Accepted
qsoRepository.markUploaded(lastLogbookUploadIds) if (accepted && lastLogbookUploadIds.isNotEmpty()) {
qsoRepository.markUploaded(lastLogbookUploadIds, preview.grids)
lastLogbookUploadIds = emptyList() lastLogbookUploadIds = emptyList()
} }
val msg = when (result) { val msg = when (result) {
@@ -420,11 +657,120 @@ 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"
} }
_uiState.update { it.copy(logbookUploadBusy = false, logbookPreview = null, logbookUploadMessage = msg) } _uiState.update {
it.copy(
logbookUploadBusy = false,
logbookPreview = null,
logbookUploadMessage = msg,
// An accepted resubmit is done — drop the checks; a failed one keeps
// them so retrying stays one tap away.
logbookSelectionMode = if (accepted) false else it.logbookSelectionMode,
logbookSelectedIds = if (accepted) emptySet() else it.logbookSelectedIds
)
}
} }
} }
private fun dismissLogbookPreview() = _uiState.update { it.copy(logbookPreview = null) } private fun dismissLogbookPreview() = _uiState.update { it.copy(logbookPreview = null, logbookWavelogPreview = null) }
/** Operator chose "ignore" on the position check: keep the prepared preview. */
private fun ignoreLogbookPositionWarning() = _uiState.update { it.copy(logbookPositionWarning = null) }
/** Operator chose to fix the station grid first: drop the prepared preview and leave. */
private fun abandonLogbookForGridFix() {
lotwUploadRepository.discardPreview()
lastLogbookUploadIds = emptyList()
_uiState.update { it.copy(logbookPositionWarning = null, logbookPreview = null) }
}
/** Long-press entry: selection mode with the pressed record checked. */
private fun startLogbookSelection(id: Long) = _uiState.update {
it.copy(logbookSelectionMode = true, logbookSelectedIds = setOf(id))
}
private fun toggleLogbookSelection(id: Long) = _uiState.update {
val selection = it.logbookSelectedIds.toMutableSet().apply { if (!add(id)) remove(id) }
it.copy(logbookSelectedIds = selection)
}
private fun exitLogbookSelection() = _uiState.update {
it.copy(logbookSelectionMode = false, logbookSelectedIds = emptySet())
}
/**
* Re-upload the checked records — already-uploaded and confirmed rows included — so a
* corrected station location reaches LoTW and the server updates the existing contacts.
* The rest is the normal prepare → preview → upload cycle, resubmit flag included.
*/
private fun resubmitSelectedLogbook() {
if (_uiState.value.logbookUploadBusy) return
viewModelScope.launch {
_uiState.update { it.copy(logbookUploadBusy = true, logbookUploadMessage = "") }
try {
val all = qsoRepository.records.first()
val wavelogSettings = settingsRepo.wavelogUploadSettings.value
if (wavelogSettings.isReady) {
// Wavelog mode: re-send the checked records through Wavelog (the
// server dedupes what is already there).
val selected = all.filter {
it.id in _uiState.value.logbookSelectedIds &&
it.status == com.rtbishop.look4sat.core.domain.logbook.QsoStatus.COMPLETE
}
if (selected.isEmpty()) {
_uiState.update { it.copy(logbookUploadBusy = false, logbookUploadMessage = "No complete QSOs in the selection") }
return@launch
}
val preview = runCatching {
wavelogUploadRepository.prepare(selected, wavelogSettings)
}.getOrNull()
if (preview == null) {
_uiState.update {
it.copy(logbookUploadBusy = false, logbookUploadMessage = "Wavelog upload failed to prepare — check the configuration")
}
return@launch
}
if (preview.count == 0) {
_uiState.update {
it.copy(
logbookUploadBusy = false,
logbookUploadMessage = wavelogIdleSegment(preview) ?: "No QSOs to resubmit"
)
}
return@launch
}
_uiState.update { it.copy(logbookUploadBusy = false, logbookWavelogPreview = preview) }
return@launch
}
val selected = resubmitCandidates(all, _uiState.value.logbookSelectedIds)
if (selected.isEmpty()) {
_uiState.update { it.copy(logbookUploadBusy = false, logbookUploadMessage = "No complete QSOs in the selection") }
return@launch
}
val preview = lotwUploadRepository.prepare(selected, true)
if (preview.count == 0) {
// Every checked record proved un-signable — say why instead of opening
// a dead preview whose POST could only expire.
val message = unavailableUploadSummary(
preview.unavailableSkipped, preview.unavailableReasons, duplicates = preview.duplicateSkipped
).ifBlank { "No QSOs to resubmit" }
_uiState.update { it.copy(logbookUploadBusy = false, logbookUploadMessage = message) }
return@launch
}
lastLogbookUploadIds = preview.submittedIds
_uiState.update {
it.copy(
logbookUploadBusy = false,
logbookPreview = preview,
logbookPositionWarning = positionWarning(settingsRepo.getCurrentGrid(), preview.grids)
)
}
} catch (e: com.rtbishop.look4sat.core.domain.repository.LoTWOperationException) {
_uiState.update { it.copy(logbookUploadBusy = false, logbookUploadMessage = "Upload unavailable: ${e.reason}") }
} catch (_: Exception) {
_uiState.update { it.copy(logbookUploadBusy = false, logbookUploadMessage = "Upload failed") }
}
}
}
private fun clearLogbookMessage() = _uiState.update { it.copy(logbookUploadMessage = "") } private fun clearLogbookMessage() = _uiState.update { it.copy(logbookUploadMessage = "") }
@@ -443,7 +789,10 @@ class SettingsViewModel(
_uiState.update { it.copy(lotwUploadBusy = true, lotwUploadError = null) } _uiState.update { it.copy(lotwUploadBusy = true, lotwUploadError = null) }
try { try {
val cert = lotwUploadRepository.importCertificate(bytes, password) val cert = lotwUploadRepository.importCertificate(bytes, password)
_uiState.update { it.copy(lotwCertificate = cert, lotwUploadBusy = false, lotwUploadError = null) } // Publish the region field for the fresh certificate immediately —
// previously it only appeared after closing and reopening the dialog.
val meta = runCatching { lotwUploadRepository.stationMeta(cert.dxcc) }.getOrNull()
_uiState.update { it.copy(lotwCertificate = cert, lotwStationMeta = meta, lotwUploadBusy = false, lotwUploadError = null) }
} catch (e: com.rtbishop.look4sat.core.domain.repository.LoTWOperationException) { } catch (e: com.rtbishop.look4sat.core.domain.repository.LoTWOperationException) {
val error = when (e.reason) { val error = when (e.reason) {
com.rtbishop.look4sat.core.domain.repository.LoTWProblem.CERTIFICATE_PASSWORD -> LoTWUploadError.PASSWORD com.rtbishop.look4sat.core.domain.repository.LoTWProblem.CERTIFICATE_PASSWORD -> LoTWUploadError.PASSWORD
@@ -458,6 +807,24 @@ class SettingsViewModel(
} }
} }
// Monotonic token so a slow stale preview never overwrites a newer one's meta.
private var certificatePreviewSeq = 0
private fun previewLoTWCertificate(bytes: ByteArray, password: CharArray) {
val seq = ++certificatePreviewSeq
viewModelScope.launch {
// Silent by design: a wrong password or a non-p12 file must not disturb
// the dialog — only a successful parse publishes the region field.
val meta = runCatching { lotwUploadRepository.previewCertificate(bytes, password) }.fold(
onSuccess = { cert -> runCatching { lotwUploadRepository.stationMeta(cert.dxcc) }.getOrNull() },
onFailure = { null }
)
if (meta != null && seq == certificatePreviewSeq) {
_uiState.update { it.copy(lotwStationMeta = meta) }
}
}
}
private fun removeLoTWCertificate() { private fun removeLoTWCertificate() {
viewModelScope.launch { viewModelScope.launch {
lotwUploadRepository.removeCertificate() lotwUploadRepository.removeCertificate()
@@ -612,7 +979,8 @@ class SettingsViewModel(
databaseRepo = container.databaseRepo, databaseRepo = container.databaseRepo,
settingsRepo = container.settingsRepo, settingsRepo = container.settingsRepo,
updateRepo = container.updateRepo, updateRepo = container.updateRepo,
wavelogRepo = container.wavelogRepo, wavelogSyncRepository = container.wavelogSyncRepository,
wavelogUploadRepository = container.wavelogUploadRepository,
lotwRepo = container.lotwRepo, lotwRepo = container.lotwRepo,
qsoRepository = container.qsoRepository, qsoRepository = container.qsoRepository,
lotwUploadRepository = container.lotwUploadRepository, lotwUploadRepository = container.lotwUploadRepository,
@@ -81,7 +81,10 @@ import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
import com.rtbishop.look4sat.core.presentation.CardButton import com.rtbishop.look4sat.core.presentation.CardButton
import com.rtbishop.look4sat.core.presentation.InfoDialog import com.rtbishop.look4sat.core.presentation.InfoDialog
import com.rtbishop.look4sat.core.presentation.R import com.rtbishop.look4sat.core.presentation.R
import com.rtbishop.look4sat.core.presentation.SheetDialogTitle
import com.rtbishop.look4sat.core.presentation.layoutPadding import com.rtbishop.look4sat.core.presentation.layoutPadding
import com.rtbishop.look4sat.core.presentation.sheetDialogContainerColor
import com.rtbishop.look4sat.core.presentation.sheetDialogShape
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.Calendar import java.util.Calendar
import java.util.Date import java.util.Date
@@ -397,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,
@@ -445,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}" +
@@ -460,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,
@@ -591,7 +616,9 @@ private fun AmSatUploadConfirmDialog(
val grid = upload.gridSquare.ifBlank { stringResource(R.string.amsat_upload_grid_none) } val grid = upload.gridSquare.ifBlank { stringResource(R.string.amsat_upload_grid_none) }
AlertDialog( AlertDialog(
onDismissRequest = onDismiss, onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.amsat_upload_confirm_title)) }, shape = sheetDialogShape(),
containerColor = sheetDialogContainerColor(),
title = { SheetDialogTitle(stringResource(R.string.amsat_upload_confirm_title)) },
text = { text = {
Text( Text(
text = stringResource( text = stringResource(
Loaded 100 of 102 files, more files were not shown because too many files have changed in this diff. Show more