Compare commits

...
Author SHA1 Message Date
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
atsunatsu d13af978af build: release version 4.4.8-ba7opf.2.4 (549) 2026-09-29 17:58:10 +08:00
atsunatsu b48e23804f feat(radar): 生成通联记录帖子只含当前过境窗口
generatePost 的筛选从最近 24 小时改为当前过境窗口(aos..los),
与日志列表 recent 同一套过滤逻辑,不再混入其他过境的 QSO
2026-09-29 17:58:07 +08:00
atsunatsu a2b3ce5d69 docs: 补充第三方声明(MIT 合规)
- GridGeometry.kt 头部注明移植自 OrbitDeckiOS(Paul Stoetzer, N8HM, MIT)
- 新增 THIRD_PARTY_NOTICES.md:OrbitDeckiOS (MIT) + Material Design Icons (Apache-2.0)
2026-09-29 15:45:55 +08:00
atsunatsu 6ef994e4cd build: release version 4.4.8-ba7opf.2.3 (548) 2026-09-29 12:12:50 +08:00
atsunatsu 4c3fdeaa9c feat(gridfinder): VUCC 网格寻线实地导航 + LoTW 上传台址预填联动 + 移除地图入口
- GridGeometry 网格线/点几何 + QthConverter grid4/grid8;golden vectors 单测
- 独立定位流 ILocationRepo/LocationRepo(stop 时 removeUpdates),不自动写台址
- feature/gridfinder 模块:罗盘表盘、极坐标近距图、线上/点上精度门控(连续 3 次 ≤6.1m)
- 「设为 LoTW 上传台址」:跳证书配置页并预填当前网格(1/2/4 格),只改台站 grid 字段
- 地图页入口移除,设置页单入口;顶部间距改 ScreenColumn 结构(与设置页同构)
- 术语统一「最近网格点」(中英)
2026-09-29 12:12:47 +08:00
atsunatsu 33a7f23d7a fix(settings): Project Wiki 入口整行可点击; build: release version 4.4.8-ba7opf.1.1 (544) 2026-09-29 01:35:55 +08:00
atsunatsu fb4f14f75d feat(wiki): 更新后的 What's New 卡片改为展示 Project Wiki(与设置页入口共用内容) 2026-09-29 01:25:50 +08:00
atsunatsu 473c3bf228 build: release version 4.4.8-ba7opf.1 (543) 2026-09-29 01:11:20 +08:00
atsunatsu 2110bf0cd6 feat(settings): 设置页新增 Project Wiki 入口(fork 增量功能与用法说明) 2026-09-29 01:11:16 +08:00
atsunatsu 95d2d792cc build: release version 4.4.8-ba7opf.0 (542) 2026-09-28 23:49:10 +08:00
atsunatsu dd29c6681c feat(v448): IC-705 读频、昼夜分界极性、AMSAT/更新检查适配、许可证头批量(v4.4.8 移植) 2026-09-28 23:49:07 +08:00
atsunatsu 45cd72faf7 feat(settings): 致谢与声明卡、版本卡打开检查更新、浅色主题、品牌与 GPL 头 2026-09-28 23:46:52 +08:00
atsunatsu 5acf71b2a6 feat(logbook): LoTW 日志本官方名统一、上传候选集、编辑已传记录新建、记录分钟/kHz 规范化 2026-09-28 23:46:47 +08:00
atsunatsu 1871c05002 build: release version 4.4.7-ba7opf.17.8 (531) 2026-09-27 21:26:16 +08:00
atsunatsu e9f58f76f6 feat(radar): keep the full-screen circle width share in small windows
The radar circle is a square, so it is limited by the shorter side of
the card it is drawn in: the small-window layout gave the radar card a
height of ~156dp in a 411x450 window, which shrank the circle to ~36% of
the page width while the full-screen layout shows ~84%.

- measure the real window with BoxWithConstraints instead of trusting the
  window size class, which can still report the full screen while the app
  runs in a split/floating window
- when the classic 1:1 split would give the radar less than 75% of the
  page width, use a fill layout: the radar takes the largest square that
  fits, a usable pager block is reserved under it while that keeps the
  circle at its full-screen share, otherwise the pager collapses into a
  48dp translucent tab strip with a tappable panel
- keep the normal two-row top area in the vertical layout: TimerRow and
  NextPassRow both carry weight(1f) internally, so putting them in a
  single row splits that row into two 50% blocks
- extract the geometry into pure Dp functions (useFillRadarLayout,
  radarFillSizes) covered by unit tests
2026-09-27 21:26:12 +08:00
atsunatsu 800494b978 feat(radar): fill radar in split-screen via measured window size
The previous compact-mode trigger used currentWindowAdaptiveInfo(),
which reports the full-screen size class in split-screen / multi-window,
so the radar never enlarged there. Measure the actual window bounds with
BoxWithConstraints: compact (radar weight 1f fills all space, pager fixed
132dp strip) when maxWidth < 600dp && maxHeight < 480dp.

build: release version 4.4.7-ba7opf.17.6 (529)
2026-09-27 20:13:13 +08:00
atsunatsu f1091acbbd build: release version 4.4.7-ba7opf.17.4 (527) 2026-09-27 18:32:28 +08:00
atsunatsu 99bf3d34a9 feat(radar): enlarge radar in split-screen/small windows
When the window is vertical (narrow) and too short for split-screen or a
small floating window, give the radar card 1.6/0.6 weight instead of the
usual 1:1 so the square radar plot stays large. The pager card shrinks
but all its pages are scrollable, so nothing is clipped. Full-screen
vertical layout (enough height) is unchanged.
2026-09-27 18:32:24 +08:00
atsunatsu 1dd185971f fix(lotw): precise uploaded marking; surface skipped/unavailable counts
P2 — markUploaded now only marks the records that actually made it
into the accepted TQ8 batch (preview.submittedIds), never the whole
candidate list. Previously, un-signable records skipped by prepare()
were still marked UP after any accepted upload, falsely showing them
as uploaded.

P4 — surface why records did not upload:
- When audit finds nothing pending, distinguish unavailable records
  (invalid call/date — check logbook) from unknown-result records
  (not retried automatically) instead of a blanket 'no pending'.
- Upload preview dialogs (Log tab + logbook) show a breakdown line:
  N already uploaded/duplicate · N unknown result · N un-uploadable.
2026-09-27 17:46:21 +08:00
atsunatsu fe85703e56 fix(lotw): decouple QSO records from certificate station; skip un-signable records
- Remove LOCATION_MISMATCH check: the recorded myGrid never enters the
  TQ8 (the tSTATION grid is signed from the current certificate station),
  so a portable/ADIF-imported record with a different grid must not block
  upload. Logging and uploading are now fully decoupled from the
  certificate location.
- Fingerprint now covers the CONTACT only (call, date, band, mode,
  satellite, freq), excluding station fields: changing grid/zones/county/
  IOTA no longer invalidates ledger entries, so already-uploaded contacts
  are not re-queued and rejected as duplicates.
- prepare(): catch un-signable contacts per-record instead of aborting
  the whole batch; count them as skipped and upload the rest.
- audit(): count lotwUploaded records as uploaded (ledger-loss fallback).
2026-09-27 17:35:34 +08:00
atsunatsu caddba5b81 fix(settings): load LoTW certificate status at startup
The LoTW upload card read uiState.lotwCertificate which was only
populated when the config dialog was opened (LoadLoTWUploadStatus).
On first entry to the settings screen it stayed null, showing
'certificate not imported' even though a certificate was stored.
Load certificate + station once in init so the card is correct
from the first frame.
2026-09-27 17:07:02 +08:00
138 changed files with 8989 additions and 423 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.
- 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
- Default to code changes only. Provide explanations in chat only.
+2
View File
@@ -4,6 +4,8 @@
**BA7OPF 定制版** — 基于 [rt-bishop/Look4Sat](https://github.com/rt-bishop/Look4Sat) 的业余无线电卫星追踪器,增加了线性卫星频率计算器等功能。
非官方修改版(fork),独立维护;首次发布 2026-09,持续修改中。
## 本仓库特色功能
- **网格地图模式(Grid Mode)** — 地图页内置 VUCC 网格覆盖图:已通联网格绿色填充、漫游网格蓝色 45° 斜纹(疏朗半透明样式)、当前所在方格淡黄加粗框;点击已通联网格弹出该网格内通联明细弹窗;进入网格模式默认 VUCC 视图
+46
View File
@@ -0,0 +1,46 @@
# Third-Party Notices
Look4Sat-BA7OPF (this fork) bundles or adapts the following third-party
components:
## OrbitDeckiOS
- Source: https://github.com/prstoetzer/OrbitDeckiOS
- Copyright (c) 2025 Paul Stoetzer, N8HM
- License: MIT
- Used for: the Grid Finder feature — the VUCC grid line / grid point
geometry in `core/domain/.../utility/GridGeometry.kt` is ported from
OrbitDeckiOS' GridGeometry.
### MIT License
```
MIT License
Copyright (c) 2025 Paul Stoetzer, N8HM
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
```
## Material Design Icons
- Source: https://materialdesignicons.com (Pictogrammers)
- License: Apache-2.0
- Used for: `core/presentation/src/main/res/drawable/ic_grid_finder.xml`
(crosshairs-gps icon). Attribution is also noted in the icon file header.
+1 -1
View File
@@ -44,7 +44,7 @@
<meta-data
android:name="android.telephony.PROPERTY_SATELLITE_DATA_OPTIMIZED"
android:value="com.rtbishop.look4sat" />
android:value="cn.ba7opf.look4sat" />
<provider
android:name="androidx.core.content.FileProvider"
@@ -27,7 +27,9 @@ import android.view.View
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.runtime.getValue
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.lifecycleScope
import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
import com.rtbishop.look4sat.core.presentation.MainTheme
@@ -47,9 +49,12 @@ class MainActivity : ComponentActivity() {
installSplashScreen()
enableEdgeToEdge()
super.onCreate(savedInstanceState)
val settingsRepo = (applicationContext as IContainerProvider).getMainContainer().settingsRepo
observeNightFilterState()
setContent {
MainTheme(isDarkTheme = true) { NavRoot() }
val otherSettings by settingsRepo.otherSettings.collectAsStateWithLifecycle()
// Light theme is opt-in: the app stays dark until the user turns it on.
MainTheme(isDarkTheme = !otherSettings.stateOfLightTheme) { NavRoot() }
}
}
@@ -51,6 +51,9 @@ class MainApplication : Application(), IContainerProvider {
container.appScope.launch { checkAutoUpdate() }
// automatic LoTW grid sync on every app start (gated, see checkLoTWAutoSync)
container.appScope.launch { checkLoTWAutoSync() }
// repair logbooks split by the old name/band identity mismatch, independent of
// whether a sync is due (cheap read + in-memory match on every start)
container.appScope.launch { runCatching { container.qsoRepository.consolidateConfirmations() } }
// load satellite data on every app start
container.appScope.launch { container.satelliteRepo.initRepository() }
}
@@ -75,11 +75,14 @@ import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
import com.rtbishop.look4sat.core.domain.repository.MutualPassData
import com.rtbishop.look4sat.core.presentation.DeeplinkResolver
import com.rtbishop.look4sat.core.presentation.ElevationThresholds
import com.rtbishop.look4sat.core.presentation.GridFinderDestination
import com.rtbishop.look4sat.core.presentation.LocalElevationThresholds
import com.rtbishop.look4sat.core.presentation.LoTWUploadDestination
import com.rtbishop.look4sat.core.presentation.RadarDestination
import com.rtbishop.look4sat.core.presentation.Screen
import com.rtbishop.look4sat.core.presentation.hasEnoughHeight
import com.rtbishop.look4sat.core.presentation.hasEnoughWidth
import com.rtbishop.look4sat.feature.gridfinder.GridFinderDestination
import com.rtbishop.look4sat.feature.map.MapDestination
import com.rtbishop.look4sat.feature.map.MapFilterViewModel
import com.rtbishop.look4sat.feature.mutual.MutualScreen
@@ -88,6 +91,7 @@ import com.rtbishop.look4sat.feature.passes.PassesDestination
import com.rtbishop.look4sat.feature.radar.RadarDestination
import com.rtbishop.look4sat.feature.satellites.SatellitesDestination
import com.rtbishop.look4sat.feature.settings.SettingsDestination
import com.rtbishop.look4sat.feature.settings.LoTWUploadDestination
import com.rtbishop.look4sat.feature.status.SatStatusDestination
@Composable
@@ -99,6 +103,8 @@ fun NavRoot(deeplink: String? = null) {
}
val navigateBack: () -> Unit = { rootBackStack.removeLastOrNull() }
val navigateToRadar: () -> Unit = { rootBackStack.add(RadarDestination) }
val navigateToGridFinder: () -> Unit = { rootBackStack.add(GridFinderDestination) }
val navigateToLoTWStation: () -> Unit = { rootBackStack.add(LoTWUploadDestination) }
// Incoming screen slides in from the right, outgoing drifts left at 1/3 speed (API35+ style)
val pushTransition = slideInHorizontally(tween(300)) { it } togetherWith
slideOutHorizontally(tween(300)) { -it / 3 }
@@ -132,7 +138,8 @@ fun NavRoot(deeplink: String? = null) {
entryProvider = entryProvider {
entry<Screen.Passes> {
MainScreen(
navigateToRadar = navigateToRadar
navigateToRadar = navigateToRadar,
navigateToGridFinder = navigateToGridFinder
)
}
entry<RadarDestination> {
@@ -140,7 +147,26 @@ fun NavRoot(deeplink: String? = null) {
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background
) {
RadarDestination(navigateUp = navigateBack)
RadarDestination(navigateUp = navigateBack, onOpenLoTWStation = navigateToLoTWStation)
}
}
entry<GridFinderDestination> {
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background
) {
GridFinderDestination(
navigateUp = navigateBack,
onOpenLoTWStation = navigateToLoTWStation
)
}
}
entry<LoTWUploadDestination> {
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background
) {
LoTWUploadDestination(navigateUp = navigateBack)
}
}
}
@@ -150,7 +176,8 @@ fun NavRoot(deeplink: String? = null) {
@Composable
fun MainScreen(
navigateToRadar: () -> Unit = {}
navigateToRadar: () -> Unit = {},
navigateToGridFinder: () -> Unit = {}
) {
val backStack = rememberNavBackStack(Screen.Passes)
val currentKey = backStack.lastOrNull()
@@ -291,7 +318,7 @@ fun MainScreen(
}
entry<Screen.AMSAT> { SatStatusDestination() }
entry<Screen.Settings> {
SettingsDestination()
SettingsDestination(onOpenGridFinder = navigateToGridFinder)
}
}
)
@@ -32,6 +32,7 @@ internal class ApplicationPlugin : Plugin<Project> {
implementation(project(":core:domain"))
implementation(project(":core:presentation"))
implementation(project(":feature:map"))
implementation(project(":feature:gridfinder"))
implementation(project(":feature:mutual"))
implementation(project(":feature:passes"))
implementation(project(":feature:radar"))
@@ -19,6 +19,7 @@ package com.rtbishop.look4sat.core.data.database
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.MapColumn
import androidx.room.OnConflictStrategy
import androidx.room.Query
import androidx.room.Transaction
@@ -39,6 +40,18 @@ interface Look4SatDao {
@Query("SELECT * FROM entries WHERE catnum IN (:selectedIds)")
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)
suspend fun insertEntries(entries: List<SatEntry>)
@@ -47,4 +47,7 @@ interface QsoDao {
@Query("UPDATE qso_records SET lotwUploaded = 1 WHERE id IN (:ids)")
suspend fun markUploaded(ids: List<Long>)
@Query("UPDATE qso_records SET wavelogUploaded = 1 WHERE id IN (:ids)")
suspend fun markWavelogUploaded(ids: List<Long>)
}
@@ -20,7 +20,7 @@ import com.rtbishop.look4sat.core.data.database.entity.QsoEntity
* upload receipts). Kept apart from [Look4SatDb] so existing installations
* never need a migration of the satellite database.
*/
@Database(entities = [QsoEntity::class], version = 2, exportSchema = false)
@Database(entities = [QsoEntity::class], version = 4, exportSchema = false)
abstract class QsoDatabase : RoomDatabase() {
abstract fun qsoDao(): QsoDao
}
@@ -31,3 +31,17 @@ val MIGRATION_QSO_1_2 = object : Migration(1, 2) {
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")
}
}
@@ -44,9 +44,11 @@ data class QsoEntity(
@ColumnInfo(defaultValue = "''") val propagationMode: String = "",
@ColumnInfo(defaultValue = "0") val lotwConfirmed: Boolean = false,
@ColumnInfo(defaultValue = "0") val lotwUploaded: Boolean = false,
@ColumnInfo(defaultValue = "0") val wavelogUploaded: Boolean = false,
@ColumnInfo(defaultValue = "0") val lotwReceived: Boolean = false,
@ColumnInfo(defaultValue = "''") val lotwQslDate: String = "",
@ColumnInfo(defaultValue = "''") val vuccGrids: String = "",
@ColumnInfo(defaultValue = "''") val theirVuccGrids: String = "",
val dxcc: Int? = null,
@ColumnInfo(defaultValue = "''") val country: String = "",
val cqZone: Int? = null,
@@ -330,12 +330,13 @@ object IcomCivProtocol {
/**
* Parse frequency + mode from a CMD_READ_FREQ reply payload.
* Payload layout after stripping command byte: [5 freq bytes] [mode byte] [filter byte]
* Payload layout after stripping command byte: [5 freq bytes]
* The IC-705 does not include a mode byte; if one is present it is parsed, otherwise mode is empty.
*/
fun parseFreqModePayload(payload: ByteArray): Pair<Long, String>? {
if (payload.size < 6) return null
if (payload.size < 5) return null
val freqHz = decodeFrequencyBcd(payload.copyOfRange(0, 5))
val mode = BYTE_TO_MODE[payload[5]] ?: return null
val mode = if (payload.size > 5) BYTE_TO_MODE[payload[5]] ?: return null else ""
return freqHz to mode
}
@@ -17,16 +17,21 @@
*/
package com.rtbishop.look4sat.core.data.injection
import android.Manifest
import android.bluetooth.BluetoothManager
import android.content.Context
import android.content.pm.PackageManager
import android.hardware.SensorManager
import android.hardware.display.DisplayManager
import android.location.LocationManager
import androidx.core.content.ContextCompat
import androidx.room.Room
import com.rtbishop.look4sat.core.data.database.Look4SatDb
import com.rtbishop.look4sat.core.data.database.MIGRATION_1_2
import com.rtbishop.look4sat.core.data.database.MIGRATION_2_3
import com.rtbishop.look4sat.core.data.database.MIGRATION_QSO_1_2
import com.rtbishop.look4sat.core.data.database.MIGRATION_QSO_2_3
import com.rtbishop.look4sat.core.data.database.MIGRATION_QSO_3_4
import com.rtbishop.look4sat.core.data.database.QsoDatabase
import com.rtbishop.look4sat.core.data.framework.BluetoothReporter
import com.rtbishop.look4sat.core.data.framework.Ft817Controller
@@ -43,7 +48,9 @@ import com.rtbishop.look4sat.core.data.repository.SensorsRepo
import com.rtbishop.look4sat.core.data.repository.SettingsRepo
import com.rtbishop.look4sat.core.data.repository.UpdateRepository
import com.rtbishop.look4sat.core.data.repository.LoTWRepository
import com.rtbishop.look4sat.core.data.repository.LocationRepo
import com.rtbishop.look4sat.core.data.repository.WavelogRepository
import com.rtbishop.look4sat.core.data.repository.WavelogUploadRepository
import com.rtbishop.look4sat.core.data.source.LocalSource
import com.rtbishop.look4sat.core.data.source.RemoteSource
import com.rtbishop.look4sat.core.data.usecase.AddToCalendar
@@ -59,11 +66,13 @@ import com.rtbishop.look4sat.core.domain.repository.IRadioController
import com.rtbishop.look4sat.core.domain.repository.IRadioTrackingService
import com.rtbishop.look4sat.core.domain.repository.IReporter
import com.rtbishop.look4sat.core.domain.repository.ILoTWRepository
import com.rtbishop.look4sat.core.domain.repository.ILocationRepo
import com.rtbishop.look4sat.core.domain.repository.ISatelliteRepo
import com.rtbishop.look4sat.core.domain.repository.ISelectionRepo
import com.rtbishop.look4sat.core.domain.repository.ISensorsRepo
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
import com.rtbishop.look4sat.core.domain.repository.IWavelogRepository
import com.rtbishop.look4sat.core.domain.repository.IWavelogUploadRepository
import com.rtbishop.look4sat.core.domain.repository.MutualPassData
import com.rtbishop.look4sat.core.domain.source.ILocalSource
import com.rtbishop.look4sat.core.domain.source.IRemoteSource
@@ -95,10 +104,11 @@ class MainContainer(private val context: Context) : IMainContainer {
override val amSatRepo by lazy { AmSatRepository(remoteSource, appScope) }
override val updateRepo by lazy { UpdateRepository(remoteSource) }
override val wavelogRepo: IWavelogRepository by lazy { WavelogRepository() }
override val wavelogUploadRepository: IWavelogUploadRepository by lazy { WavelogUploadRepository() }
override val lotwRepo: ILoTWRepository by lazy { LoTWRepository() }
override val qsoRepository: IQsoRepository by lazy {
val database = Room.databaseBuilder(context, QsoDatabase::class.java, "Look4SatQsoDB")
.addMigrations(MIGRATION_QSO_1_2)
.addMigrations(MIGRATION_QSO_1_2, MIGRATION_QSO_2_3, MIGRATION_QSO_3_4)
.build()
QsoRepository(database.qsoDao(), Dispatchers.IO)
}
@@ -107,13 +117,25 @@ class MainContainer(private val context: Context) : IMainContainer {
val manager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
RadioTrackingService(appScope, manager, satelliteRepo, settingsRepo)
}
override val locationRepo: ILocationRepo by lazy {
val manager = context.getSystemService(Context.LOCATION_SERVICE) as LocationManager
LocationRepo(manager) {
ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) ==
PackageManager.PERMISSION_GRANTED
}
}
private val _mutualPassData = MutableStateFlow(MutualPassData())
override val mutualPassData: StateFlow<MutualPassData> = _mutualPassData.asStateFlow()
private val _pendingLoTWStationGrid = MutableStateFlow<String?>(null)
override val pendingLoTWStationGrid: StateFlow<String?> = _pendingLoTWStationGrid.asStateFlow()
override fun setMutualPassData(data: MutualPassData) {
_mutualPassData.value = data
}
override fun setPendingLoTWStationGrid(grid: String?) {
_pendingLoTWStationGrid.value = grid
}
override fun provideAddToCalendar(): IAddToCalendar = AddToCalendar(context)
@@ -1,6 +1,25 @@
/*
* 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.lotw
import com.rtbishop.look4sat.core.domain.logbook.QsoRecord
import com.rtbishop.look4sat.core.domain.logbook.LoTWSatelliteAliases
import com.rtbishop.look4sat.core.domain.logbook.displayMode
import com.rtbishop.look4sat.core.domain.repository.LoTWProblem
import com.rtbishop.look4sat.core.domain.repository.LoTWRegionField
@@ -58,18 +77,23 @@ internal class LoTWConfig(input: InputStream) {
return value
}
/** Every satellite name ARRL's config.tq6 knows, sorted: the only names LoTW accepts. */
fun satelliteNames(): List<String> = satellites.keys.sorted()
/**
* Resolve a tracker name to the ARRL name, or null when it is not an ARRL satellite.
* Exact official name first, then the alias table ("SAUDISAT 1C" -> "SO-50",
* "ISS (ZARYA)" -> "ARISS"), then an official name contained in the tracker name.
*/
fun resolveSatellite(name: String): String? = LoTWSatelliteAliases.resolve(name, satellites.keys)
fun satellite(name: String, date: String): String {
val normalized = name.trim().uppercase(Locale.US)
// Look4Sat satellite names come from SatNOGS (e.g. "SO-50 (SaudiOSCAR 50)");
// the bundled config only knows the official LoTW name ("SO-50"). Exact match
// first, then accept any satellite whose official name is a substring of the
// Look4Sat name (covers SO-50, AO-91, IO-86, CAS-7B …). Failing that the
// contact is unavailable for upload.
val sat = satellites[normalized]
?: satellites.entries.firstOrNull { (official, _) ->
official.length > 1 && normalized.contains(official)
}?.value
?: fail(LoTWProblem.SATELLITE, normalized)
// Tracker names differ from ARRL's: SatNOGS stores "SAUDISAT 1C" for "SO-50" and
// "ISS (ZARYA)" for "ARISS". resolveSatellite() checks the exact name, the alias
// table and finally substring containment; anything else is not uploadable.
val official = resolveSatellite(name) ?: fail(LoTWProblem.SATELLITE, normalized)
val sat = satellites.getValue(official)
val first = sat.getAttribute("startDate")
val last = sat.getAttribute("endDate")
if ((first.isNotBlank() && date < first) || (last.isNotBlank() && date > last)) fail(LoTWProblem.SATELLITE, normalized)
@@ -1,3 +1,21 @@
/*
* 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.lotw
import com.rtbishop.look4sat.core.domain.repository.LoTWCertificate
@@ -1,3 +1,21 @@
/*
* 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.lotw
import com.rtbishop.look4sat.core.domain.logbook.QsoRecord
@@ -21,17 +39,16 @@ internal class LoTWSigner(private val config: LoTWConfig) {
!call.any(Char::isLetter) || !call.any(Char::isDigit)) fail(LoTWProblem.INVALID_CONTACT, call)
if (!record.myCallsign.trim().equals(key.info.callsign, true)) fail(LoTWProblem.CALLSIGN_MISMATCH, call)
val date = utc(record.startUtcMillis, "yyyy-MM-dd")
if (date < key.info.firstQsoDate || (key.info.lastQsoDate.isNotBlank() && date > key.info.lastQsoDate) || record.startUtcMillis > now) {
// 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)
}
val grids = buildList {
addAll(station.getValue("GRIDSQUARE").split(',').map(String::trim))
station["MY_VUCC_GRIDS"]?.split(',')?.map(String::trim)?.let(::addAll)
}.filter(String::isNotBlank)
if (record.myGrid.isNotBlank() && grids.none { grid ->
val local = record.myGrid.trim().uppercase(Locale.US)
grid.startsWith(local) || local.startsWith(grid)
}) fail(LoTWProblem.LOCATION_MISMATCH, call)
fun mhz(hz: Long?): String = hz?.let { BigDecimal.valueOf(it, 6).stripTrailingZeros().toPlainString() }.orEmpty()
val fields = linkedMapOf(
"BAND" to config.band(record.band, record.txFrequencyHz, true),
@@ -46,8 +63,12 @@ internal class LoTWSigner(private val config: LoTWConfig) {
"SAT_NAME" to if (record.isSatellite) config.satellite(record.satelliteName, date) else ""
).filterValues { it.isNotBlank() }
val signData = (config.stationOrder.map { station[it].orEmpty() } + config.contactOrder.map { fields[it].orEmpty() }).joinToString("")
// Fingerprint identifies the CONTACT only (call, date/time, band, mode,
// satellite, frequency). Station fields (grid, zones, county, IOTA) are
// deliberately excluded: changing the station location must not change
// the fingerprint, otherwise previously-uploaded contacts would lose
// their ledger entry and be re-uploaded (and rejected as duplicates).
val identity = field("CALL", key.info.callsign) + field("DXCC", key.info.dxcc.toString()) +
station.toSortedMap().entries.joinToString("") { field(it.key, it.value) } +
fields.entries.joinToString("") { field(it.key, it.value) }
val hash = MessageDigest.getInstance("SHA-256").digest(identity.toByteArray(Charsets.UTF_8)).joinToString("") { "%02x".format(it) }
return LoTWContact(record, fields, signData, hash)
@@ -1,3 +1,21 @@
/*
* 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.lotw
import android.content.Context
@@ -1,7 +1,26 @@
/*
* 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.lotw
import android.content.Context
import com.rtbishop.look4sat.core.domain.logbook.QsoRecord
import com.rtbishop.look4sat.core.domain.logbook.satelliteIdentity
import com.rtbishop.look4sat.core.domain.logbook.QsoStatus
import com.rtbishop.look4sat.core.domain.repository.ILoTWUploadRepository
import com.rtbishop.look4sat.core.domain.repository.LoTWCertificate
@@ -154,6 +173,24 @@ class LoTWUploadRepository internal constructor(
config().stationMeta(dxcc)
}
/** The ARRL satellite catalogue is static for the lifetime of the bundled config.tq6. */
private val satelliteNames: List<String> by lazy { config().satelliteNames() }
override suspend fun satelliteCatalog(): List<String> = withContext(Dispatchers.IO) { satelliteNames }
/**
* What makes two records the same contact for batch de-duplication: the opposite station,
* the satellite (resolved to its ARRL identity, so the tracker spelling and the official
* name are one) and the minute the QSO started. Frequency and mode are deliberately not
* part of the key — the same contact logged twice with slightly different tuning (or with
* the frequency fields empty) must still be caught before LoTW rejects the duplicate.
*/
private fun duplicateKey(record: QsoRecord): String = listOf(
record.theirCallsign.trim().uppercase(Locale.US),
satelliteIdentity(record.satelliteName),
(record.startUtcMillis / 60_000L).toString()
).joinToString("|")
override suspend fun audit(records: List<QsoRecord>): LoTWUploadAudit = withContext(Dispatchers.IO) {
mutex.withLock {
val signing = signingContext()
@@ -163,20 +200,34 @@ class LoTWUploadRepository internal constructor(
var uploaded = 0
var unknown = 0
var unavailable = 0
var duplicates = 0
var incomplete = 0
val reasons = mutableMapOf<LoTWProblem, Int>()
val details = mutableMapOf<LoTWProblem, String>()
records.sortedBy { it.startUtcMillis }.forEach { record ->
coroutineContext.ensureActive()
when {
record.status != QsoStatus.COMPLETE -> unavailable++
record.lotwReceived -> uploaded++
record.status != QsoStatus.COMPLETE -> {
unavailable++
incomplete++
}
record.lotwReceived || record.lotwUploaded -> uploaded++
else -> {
val contact = try {
signing.signer.contact(record, signing.key, signing.location, now())
} catch (_: LoTWOperationException) {
} catch (e: LoTWOperationException) {
unavailable++
reasons[e.reason] = (reasons[e.reason] ?: 0) + 1
if (e.detail.isNotBlank()) details.putIfAbsent(e.reason, e.detail)
null
}
if (contact != null) when {
!unique.add(contact.fingerprint) -> unavailable++
!unique.add(duplicateKey(contact.record)) -> {
unavailable++
duplicates++
}
ledger[contact.fingerprint] == "accepted" -> uploaded++
ledger[contact.fingerprint] == "unknown" -> unknown++
else -> pendingCount++
@@ -184,7 +235,10 @@ class LoTWUploadRepository internal constructor(
}
}
}
LoTWUploadAudit(records.size, pendingCount, uploaded, unknown, unavailable)
LoTWUploadAudit(
records.size, pendingCount, uploaded, unknown, unavailable,
reasons, details, duplicates, incomplete
)
}
}
@@ -196,25 +250,55 @@ class LoTWUploadRepository internal constructor(
val ledger = ledger()
var skipped = 0
var unknown = 0
var unavailable = 0
var duplicates = 0
val unique = hashSetOf<String>()
val reasons = mutableMapOf<LoTWProblem, Int>()
val contacts = records.sortedBy { it.startUtcMillis }.mapNotNull { record ->
coroutineContext.ensureActive()
if (record.status != QsoStatus.COMPLETE || (record.lotwReceived && !resubmit)) { skipped++; return@mapNotNull null }
val contact = signing.signer.contact(record, signing.key, signing.location, now())
if (record.status != QsoStatus.COMPLETE || ((record.lotwReceived || record.lotwUploaded) && !resubmit)) { skipped++; return@mapNotNull null }
val contact = try {
signing.signer.contact(record, signing.key, signing.location, now())
} catch (e: LoTWOperationException) {
// One un-signable record must not abort the whole batch:
// skip it, count the real reason, and let the rest upload.
unavailable++
reasons[e.reason] = (reasons[e.reason] ?: 0) + 1
return@mapNotNull null
}
val previous = ledger[contact.fingerprint]
when {
(previous == "accepted" && !resubmit) || !unique.add(contact.fingerprint) -> { skipped++; null }
(previous == "accepted" && !resubmit) -> { skipped++; null }
!unique.add(duplicateKey(contact.record)) -> {
skipped++
duplicates++
null
}
previous == "unknown" && !resubmit -> { skipped++; unknown++; null }
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(
UUID.randomUUID().toString(), signing.key.info.callsign, signing.key.info.dxcc, signing.location.getValue("GRIDSQUARE"),
contacts.size, skipped,
contacts.firstOrNull()?.record?.let { utc(it.startUtcMillis, "yyyy-MM-dd HH:mm:ss") }.orEmpty(),
contacts.lastOrNull()?.record?.let { utc(it.startUtcMillis, "yyyy-MM-dd HH:mm:ss") }.orEmpty(),
contacts.map { "${utc(it.record.startUtcMillis, "MM-dd HH:mm")} ${it.record.theirCallsign} ${it.fields["MODE"]} ${it.fields["SAT_NAME"].orEmpty()}" },
unknown
unknown,
unavailable,
reasons.toMap(),
duplicates,
contacts.map { it.record.id },
locationGrids,
resubmit
)
if (contacts.isNotEmpty()) pending = Pending(
preview,
@@ -1,3 +1,21 @@
/*
* 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.model.AmSatReportSubmission
@@ -46,8 +46,8 @@ class DatabaseRepo(
override suspend fun updateTLEFromFile(uri: String): Int = withContext(dispatcher) {
var importedCount = 0
remoteSource.getFileStream(uri)?.let { stream ->
val entries = parseSatelliteStream(uri, unwrapIfZipped(uri, stream))
localSource.insertEntries(entries)
val entries = mergeEntries(listOf(parseSatelliteStream(uri, unwrapIfZipped(uri, stream))))
insertFresherEntries(entries)
settingsRepo.setSatelliteTypeIds(customSourceType, entries.map { it.catnum })
importedCount = entries.size
}
@@ -93,14 +93,15 @@ class DatabaseRepo(
settingsRepo.updateDataSourcesStatus(
(tleResults + radioResults).associate { (url, result) -> url to result.code }
)
// parse fetched data concurrently and associate known built-in URLs with existing type filters.
val importedEntries = tleResults.flatMap { (rawUrl, result) ->
// parse each source on its own: the type filters need every source's catnums, while the
// stored elements are merged across sources below by freshness.
val parsedPerSource = tleResults.map { (rawUrl, result) ->
val normUrl = normalizeUrl(rawUrl)
val entries = result.stream?.let { parseSatelliteStream(normUrl, unwrapIfZipped(normUrl, it)) }.orEmpty()
val type = builtinTypesByUrl[normUrl] ?: customSourceType
importedTypeIds.getOrPut(type) { mutableListOf() }.addAll(entries.map { it.catnum })
entries
}.distinctBy { it.catnum }
}
importedTypeIds.forEach { (type, ids) -> settingsRepo.setSatelliteTypeIds(type, ids.distinct()) }
val importedRadios = radioResults.flatMap { (rawUrl, result) ->
val normUrl = normalizeUrl(rawUrl)
@@ -115,8 +116,14 @@ class DatabaseRepo(
localSource.deleteManagedRadios()
localSource.insertRadios(importedRadios)
}
if (importedEntries.isNotEmpty()) localSource.insertEntries(importedEntries)
setUpdateSuccessful(System.currentTimeMillis())
// Elements always come from the freshest source for every satellite; only
// 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) {
@@ -155,6 +162,62 @@ class DatabaseRepo(
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) {
settingsRepo.updateDatabaseState(
DatabaseState(localSource.getRadiosTotal(), localSource.getEntriesTotal(), timestamp)
@@ -285,7 +285,9 @@ class LoTWRepository : ILoTWRepository {
myGrid = myGrids.firstOrNull(),
myGrids = myGrids,
myCallsign = myCallsign,
stationKey = stationKey
stationKey = stationKey,
// Copy, never the live buffer — resetRecord() clears it.
theirGrids = gridsInRecord.distinct().toList()
)
for (grid in gridsInRecord) {
result.getOrPut(grid) { mutableListOf() }.add(qso)
@@ -322,9 +324,9 @@ class LoTWRepository : ILoTWRepository {
line.startsWith("<MODE:") ->
mode = adifValue(line)
line.startsWith("<BAND_RX:") ->
bandUp = adifValue(line).uppercase()
line.startsWith("<BAND:") && !line.startsWith("<BAND_RX:") ->
bandDown = adifValue(line).uppercase()
line.startsWith("<BAND:") && !line.startsWith("<BAND_RX:") ->
bandUp = adifValue(line).uppercase()
line.startsWith("<DXCC:") ->
dxcc = adifValue(line).toIntOrNull()
line.startsWith("<COUNTRY:") ->
@@ -0,0 +1,134 @@
/*
* 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 android.location.Location
import android.location.LocationListener
import android.location.LocationManager
import androidx.core.location.LocationManagerCompat
import com.rtbishop.look4sat.core.domain.repository.ILocationRepo
import com.rtbishop.look4sat.core.domain.repository.LocationFix
import com.rtbishop.look4sat.core.domain.repository.LocationState
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
private const val UPDATE_INTERVAL_MS = 1_000L
private const val UPDATE_MIN_DISTANCE_M = 0f
/**
* Live-location adapter for the field tools. Deliberately independent of
* [SettingsRepo]'s station position: a walk around a grid corner must not
* rewrite the station that every pass prediction is built on.
*
* Unlike [SettingsRepo.setStationPosition], this class always calls
* [LocationManager.removeUpdates] in [stopUpdates], so the listener is only
* alive while a screen is actually collecting fixes.
*/
class LocationRepo(
private val locationManager: LocationManager,
private val permissionGranted: () -> Boolean
) : ILocationRepo, LocationListener {
private val _state = MutableStateFlow(initialState())
override val state: StateFlow<LocationState> = _state.asStateFlow()
private var isRegistered = false
override fun hasPermission(): Boolean = permissionGranted()
override fun startUpdates() {
if (isRegistered) return
val request = describeProvider() ?: return
if (!request.permissionGranted || !request.providerEnabled) return
try {
// A last-known fix is better than an empty screen while GPS warms up.
locationManager.getLastKnownLocation(request.provider)?.let(::publish)
locationManager.requestLocationUpdates(
request.provider, UPDATE_INTERVAL_MS, UPDATE_MIN_DISTANCE_M, this
)
isRegistered = true
} catch (exception: SecurityException) {
println("LocationRepo: no permission - $exception")
} catch (exception: IllegalArgumentException) {
println("LocationRepo: provider unavailable - $exception")
}
}
override fun stopUpdates() {
if (!isRegistered) return
isRegistered = false
try {
locationManager.removeUpdates(this)
} catch (exception: SecurityException) {
println("LocationRepo: removing updates without permission - $exception")
}
}
override fun onLocationChanged(location: Location) = publish(location)
override fun onProviderDisabled(provider: String) = refreshWaitingState()
override fun onProviderEnabled(provider: String) = refreshWaitingState()
private fun publish(location: Location) {
_state.value = LocationState.Fix(
LocationFix(
latitude = location.latitude,
longitude = location.longitude,
altitudeMeters = location.altitude.takeIf { location.hasAltitude() } ?: 0.0,
// Providers that cannot report accuracy use -1; treat that as
// useless rather than perfect.
accuracyMeters = location.accuracy.takeIf { location.hasAccuracy() } ?: Float.MAX_VALUE,
epochMs = location.time.takeIf { it > 0L } ?: System.currentTimeMillis()
)
)
}
private fun initialState(): LocationState {
val request = describeProvider()
return LocationState.Waiting(
permissionGranted = request?.permissionGranted ?: false,
providerEnabled = request?.providerEnabled ?: false
)
}
private fun refreshWaitingState() {
if (_state.value is LocationState.Fix) return
_state.value = initialState()
}
private fun describeProvider(): ProviderRequest? {
if (!permissionGranted()) {
return ProviderRequest(LocationManager.GPS_PROVIDER, false, false)
}
val enabled = LocationManagerCompat.isLocationEnabled(locationManager)
val provider = when {
locationManager.allProviders.contains(LocationManager.GPS_PROVIDER) -> LocationManager.GPS_PROVIDER
locationManager.allProviders.contains(LocationManager.NETWORK_PROVIDER) -> LocationManager.NETWORK_PROVIDER
else -> null
} ?: return null
return ProviderRequest(provider, true, enabled)
}
private data class ProviderRequest(
val provider: String,
val permissionGranted: Boolean,
val providerEnabled: Boolean
)
}
@@ -16,9 +16,16 @@ import com.rtbishop.look4sat.core.domain.logbook.AdifImportResult
import com.rtbishop.look4sat.core.domain.logbook.IQsoRepository
import com.rtbishop.look4sat.core.domain.logbook.QsoRecord
import com.rtbishop.look4sat.core.domain.logbook.QsoStatus
import com.rtbishop.look4sat.core.domain.logbook.bandsMatchOrMirror
import com.rtbishop.look4sat.core.domain.logbook.contentScore
import com.rtbishop.look4sat.core.domain.logbook.displayMode
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.splitConfirmationPairs
import com.rtbishop.look4sat.core.domain.logbook.withConfirmation
import com.rtbishop.look4sat.core.domain.logbook.confirmationLookupKey
import com.rtbishop.look4sat.core.domain.logbook.officialSatelliteName
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
@@ -40,26 +47,57 @@ class QsoRepository(
override suspend fun save(record: QsoRecord): Long = withContext(dispatcher) {
importMutex.withLock {
val previous = if (record.id != 0L) dao.find(record.id)?.toDomain() else null
val saved = if (previous?.lotwConfirmed == true && sameConfirmedContact(record, previous)) {
record.withConfirmation(previous)
} else if (previous?.lotwConfirmed == true) record.copy(
lotwConfirmed = false, lotwQslDate = "", vuccGrids = emptyList(),
dxcc = null, country = "", cqZone = null, region = ""
) else record
val received = if (previous != null && (
stableQsoKey(record) != stableQsoKey(previous) || record.myGrid != previous.myGrid ||
record.rxFrequencyHz != previous.rxFrequencyHz || record.band != previous.band ||
record.rxBand != previous.rxBand || record.propagationMode != previous.propagationMode
)) false else saved.lotwReceived
dao.save(saved.copy(lotwReceived = received).toEntity())
// Editing a record that was already uploaded or confirmed must not rewrite it in
// 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
// record (without any LoTW state, so it can be uploaded again).
if (previous != null &&
(previous.lotwUploaded || previous.lotwConfirmed || previous.wavelogUploaded) &&
!record.sameEditableContentAs(previous)
) {
dao.save(record.copy(
id = 0L,
lotwUploaded = false, lotwConfirmed = false, lotwReceived = false,
wavelogUploaded = false,
lotwQslDate = "", vuccGrids = emptyList(), theirVuccGrids = emptyList(),
dxcc = null, country = "", cqZone = null, region = ""
).toEntity())
} else {
val saved = if (previous?.lotwConfirmed == true && sameConfirmedContact(record, previous)) {
record.withConfirmation(previous)
} else if (previous?.lotwConfirmed == true) record.copy(
lotwConfirmed = false, lotwQslDate = "", vuccGrids = emptyList(), theirVuccGrids = emptyList(),
dxcc = null, country = "", cqZone = null, region = ""
) else record
val received = if (previous != null && (
stableQsoKey(record) != stableQsoKey(previous) || record.myGrid != previous.myGrid ||
record.rxFrequencyHz != previous.rxFrequencyHz || record.band != previous.band ||
record.rxBand != previous.rxBand || record.propagationMode != previous.propagationMode
)) false else saved.lotwReceived
dao.save(saved.copy(lotwReceived = received).toEntity())
}
}
}
override suspend fun delete(id: Long) = withContext(dispatcher) { importMutex.withLock { dao.delete(id) } }
override suspend fun markUploaded(ids: List<Long>) = withContext(dispatcher) {
override suspend fun markUploaded(ids: List<Long>, grids: List<String>) = withContext(dispatcher) {
if (ids.isEmpty()) return@withContext
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>) = withContext(dispatcher) {
if (ids.isEmpty()) return@withContext
importMutex.withLock { dao.markWavelogUploaded(ids) }
}
override suspend fun exportAdi(ids: Set<Long>?, includeIncomplete: Boolean): String = withContext(dispatcher) {
@@ -86,14 +124,40 @@ class QsoRepository(
importMutex.withLock { mergeRecords(records, fromLoTW = true) }
}
override suspend fun consolidateConfirmations(): Int = withContext(dispatcher) {
importMutex.withLock {
val working = dao.getAll().map(QsoEntity::toDomain).toMutableList()
val consolidation = consolidateSplitRows(working)
val renames = rewriteOfficialNames(working)
if (consolidation.merged.isEmpty() && consolidation.redundant.isEmpty() && renames.isEmpty()) {
return@withLock 0
}
val toSave = LinkedHashMap(consolidation.merged).apply { putAll(renames) }
dao.saveBatch(toSave.values.map(QsoRecord::toEntity))
consolidation.redundant.forEach { dao.delete(it.id) }
consolidation.redundant.size
}
}
private suspend fun mergeRecords(records: List<QsoRecord>, fromLoTW: Boolean = false): AdifImportResult {
val working = dao.getAll().map(QsoEntity::toDomain).toMutableList()
// Consolidate first: confirmations synced before the identity fix sit as their own
// rows (the tracker name and a mirrored band direction both failed the comparison),
// and left in place they would make the lookup below ambiguous between the operator's
// own row and the stale duplicate.
val consolidation = consolidateSplitRows(working)
// Rows imported under a tracking-source name join the ARRL name here, so what is stored
// is the identity both sides of a match resolve to (the lookup below is built on it).
val renames = rewriteOfficialNames(working)
val lookup = working.indices.groupBy { working[it].confirmationLookupKey() }
.mapValues { it.value.toMutableList() }.toMutableMap()
val knownKeys = working.mapTo(mutableSetOf(), ::stableQsoKey)
val changes = linkedMapOf<Int, QsoRecord>()
val knownKeys = working.map { stableQsoKey(it) }.toMutableSet()
val changes = linkedMapOf<Int, QsoRecord>().apply {
putAll(consolidation.merged)
putAll(renames)
}
var imported = 0
var updated = 0
var updated = consolidation.merged.size
var skipped = 0
records.forEach { remote ->
if (!fromLoTW && !remote.lotwConfirmed && stableQsoKey(remote) in knownKeys) { skipped++; return@forEach }
@@ -102,8 +166,36 @@ class QsoRepository(
val exact = candidates.filter { working[it].startUtcMillis == remote.startUtcMillis }
val match = exact.singleOrNull() ?: candidates.singleOrNull()
if (match == null) {
// 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). The
// strict lookup above cannot see it: its bands fail the comparison, and its
// dedupe key can differ too (fields written by older releases — timestamp
// precision, FT4 sub-mode spelling, missing callsign — so it is not treated
// as known either). Fold the report into the richest mirrored row instead of
// inserting a second row next to it; left as two rows, neither consolidation
// nor a later sync could reliably heal them.
val mirroredRows = if (fromLoTW) lookup[remote.confirmationLookupKey()].orEmpty().filter { index ->
working[index].band.isNotBlank() && remote.band.isNotBlank() &&
!working[index].band.equals(remote.band, true) &&
sameContactIdentity(working[index], remote) &&
bandsMatchOrMirror(working[index], remote)
} 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
}
if (stableQsoKey(remote) in knownKeys) { skipped++; return@forEach }
val added = remote.copy(id = 0L)
val added = remote.copy(id = 0L, satelliteName = officialSatelliteName(remote.satelliteName))
changes[working.size] = added
lookup.getOrPut(added.confirmationLookupKey()) { mutableListOf() }.add(working.size)
knownKeys += stableQsoKey(added)
@@ -120,8 +212,60 @@ class QsoRepository(
}
}
dao.saveBatch(changes.values.map(QsoRecord::toEntity))
consolidation.redundant.forEach { dao.delete(it.id) }
return AdifImportResult(imported, skipped, updated)
}
/** Result of folding already-split rows back together. */
private data class Consolidation(
/** index in the stored list -> the record to save (local row + its confirmation). */
val merged: Map<Int, QsoRecord>,
/** imported confirmation rows that are now part of a local record. */
val redundant: List<QsoRecord>
)
/**
* Rewrites the names records were imported under into their ARRL names ("SAUDISAT 1C" ->
* "SO-50"), so the logbook, the ADIF export and the signed record all carry one name.
* Names the alias table does not know (recycled placeholders, satellites ARRL does not
* list) are kept exactly as they are. [working] is updated in place; the rewritten records
* are returned for the caller to save.
*/
private fun rewriteOfficialNames(working: MutableList<QsoRecord>): Map<Int, QsoRecord> {
val renamed = mutableMapOf<Int, QsoRecord>()
working.indices.forEach { index ->
val record = working[index]
val official = officialSatelliteName(record.satelliteName)
if (official != record.satelliteName) {
val updated = record.copy(satelliteName = official)
working[index] = updated
renamed[index] = updated
}
}
return renamed
}
/**
* Folds confirmation rows that were imported as separate QSOs back into the local
* record they belong to (see [splitConfirmationPairs]). [working] is updated in
* place; the redundant rows are returned for the caller to delete.
*/
private fun consolidateSplitRows(working: MutableList<QsoRecord>): Consolidation {
val merged = mutableMapOf<Int, QsoRecord>()
val redundant = mutableListOf<QsoRecord>()
// Snapshot the pairs first: the list is rewritten as pairs are applied.
splitConfirmationPairs(working.toList()).forEach { pair ->
val local = working[pair.localIndex]
val confirmation = working[pair.confirmationIndex]
val folded = local.withConfirmation(confirmation)
if (folded != local) {
working[pair.localIndex] = folded
merged[pair.localIndex] = folded
}
redundant += confirmation
}
return Consolidation(merged, redundant)
}
}
private fun QsoEntity.toDomain() = QsoRecord(
@@ -149,9 +293,11 @@ private fun QsoEntity.toDomain() = QsoRecord(
propagationMode = propagationMode,
lotwConfirmed = lotwConfirmed,
lotwUploaded = lotwUploaded,
wavelogUploaded = wavelogUploaded,
lotwReceived = lotwReceived,
lotwQslDate = lotwQslDate,
vuccGrids = vuccGrids.split(',').filter(String::isNotBlank),
theirVuccGrids = theirVuccGrids.split(',').filter(String::isNotBlank),
dxcc = dxcc,
country = country,
cqZone = cqZone,
@@ -185,9 +331,11 @@ private fun QsoRecord.toEntity() = QsoEntity(
propagationMode = propagationMode.ifBlank { if (satelliteName.isNotBlank()) "SAT" else "" },
lotwConfirmed = lotwConfirmed,
lotwUploaded = lotwUploaded,
wavelogUploaded = wavelogUploaded,
lotwReceived = lotwReceived,
lotwQslDate = lotwQslDate,
vuccGrids = vuccGrids.joinToString(","),
theirVuccGrids = theirVuccGrids.joinToString(","),
dxcc = dxcc,
country = country,
cqZone = cqZone,
@@ -202,5 +350,29 @@ internal fun stableQsoKey(record: QsoRecord): String = listOf(
record.txFrequencyHz?.toString().orEmpty(),
record.mode.trim().uppercase(Locale.US),
record.submode.trim().uppercase(Locale.US),
record.satelliteName.trim().uppercase(Locale.US)
// The identity, not the spelling: a row stored as "SO-50" is the same QSO as one imported
// as "SAUDISAT 1C", so re-importing an older export does not duplicate it.
satelliteIdentity(record.satelliteName)
).joinToString("|")
/**
* Whether two rows carry the same operator-editable content.
*
* Minutes are the granularity for time: the edit dialog edits whole minutes (and rounds the
* stored seconds away when saving), so a seconds-only difference must not count as an edit.
* The satellite is compared by its ARRL identity, the mode by its display label — both sides
* of the same contact written differently (tracker vs official name, MFSK vs FT4) are the same
* content. Used to decide whether saving an already-uploaded/confirmed row should create a new
* record or leave the row alone.
*/
private fun QsoRecord.sameEditableContentAs(other: QsoRecord): Boolean =
theirCallsign.trim().equals(other.theirCallsign.trim(), true) &&
startUtcMillis / 60_000L == other.startUtcMillis / 60_000L &&
txFrequencyHz == other.txFrequencyHz &&
rxFrequencyHz == other.rxFrequencyHz &&
displayMode == other.displayMode &&
satelliteIdentity(satelliteName) == satelliteIdentity(other.satelliteName) &&
sentReport.trim() == other.sentReport.trim() &&
receivedReport.trim() == other.receivedReport.trim() &&
theirGrid.trim().uppercase(Locale.US) == other.theirGrid.trim().uppercase(Locale.US) &&
comment == other.comment
@@ -51,7 +51,15 @@ class SelectionRepo(
null // null = no filtering
} else {
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 ->
if (catnumSet == null) items else items.filter { it.catnum in catnumSet }
@@ -31,6 +31,7 @@ import com.rtbishop.look4sat.core.domain.model.PassesSettings
import com.rtbishop.look4sat.core.domain.model.RCSettings
import com.rtbishop.look4sat.core.domain.model.RadioControlSettings
import com.rtbishop.look4sat.core.domain.model.WavelogSettings
import com.rtbishop.look4sat.core.domain.model.WavelogUploadSettings
import com.rtbishop.look4sat.core.domain.predict.GeoPos
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
import com.rtbishop.look4sat.core.domain.utility.positionToQth
@@ -386,6 +387,40 @@ class SettingsRepo(
preferences.edit { putString(keyLastLotwSyncCallsign, callsign.trim().uppercase()) }
//endregion
//region # Wavelog upload settings
private val keyWavelogUploadUrl = "wavelogUploadUrl"
private val keyWavelogUploadApiKey = "wavelogUploadApiKey"
private val keyWavelogUploadStationId = "wavelogUploadStationId"
private val keyWavelogUploadStationName = "wavelogUploadStationName"
private val keyWavelogUploadStationCallsign = "wavelogUploadStationCallsign"
private val keyWavelogUploadStationGrid = "wavelogUploadStationGrid"
private val _wavelogUploadSettings = MutableStateFlow(getWavelogUploadSettings())
override val wavelogUploadSettings: StateFlow<WavelogUploadSettings> = _wavelogUploadSettings
override fun updateWavelogUploadSettings(settings: WavelogUploadSettings) {
val trimmed = settings.copy(url = settings.url.trim(), apiKey = settings.apiKey.trim())
preferences.edit {
putString(keyWavelogUploadUrl, trimmed.url)
putString(keyWavelogUploadApiKey, trimmed.apiKey)
putString(keyWavelogUploadStationId, trimmed.stationId)
putString(keyWavelogUploadStationName, trimmed.stationName)
putString(keyWavelogUploadStationCallsign, trimmed.stationCallsign)
putString(keyWavelogUploadStationGrid, trimmed.stationGrid)
}
_wavelogUploadSettings.value = trimmed
}
private fun getWavelogUploadSettings(): WavelogUploadSettings = WavelogUploadSettings(
url = preferences.getString(keyWavelogUploadUrl, null).orEmpty(),
apiKey = preferences.getString(keyWavelogUploadApiKey, null).orEmpty(),
stationId = preferences.getString(keyWavelogUploadStationId, null).orEmpty(),
stationName = preferences.getString(keyWavelogUploadStationName, null).orEmpty(),
stationCallsign = preferences.getString(keyWavelogUploadStationCallsign, null).orEmpty(),
stationGrid = preferences.getString(keyWavelogUploadStationGrid, null).orEmpty()
)
//endregion
//region # Transceivers settings
private val _passesSettings = MutableStateFlow(getPassesSettings())
override val passesSettings: StateFlow<PassesSettings> = _passesSettings
@@ -468,6 +503,34 @@ class SettingsRepo(
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 {
val latitude = (preferences.getString(keyStationLatitude, null) ?: "0.0").toDouble()
val longitude = (preferences.getString(keyStationLongitude, null) ?: "0.0").toDouble()
@@ -1,3 +1,21 @@
/*
* 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.model.LatestRelease
@@ -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
}
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 deleteEntries() = look4SatDao.deleteEntries()
@@ -29,7 +29,7 @@ class AddToCalendar(private val context: Context) : IAddToCalendar {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
setData(CalendarContract.Events.CONTENT_URI)
putExtra(CalendarContract.Events.TITLE, name)
putExtra(CalendarContract.Events.DESCRIPTION, "Look4Sat Pass")
putExtra(CalendarContract.Events.DESCRIPTION, "Look4Sat-BA7OPF Pass")
putExtra(CalendarContract.EXTRA_EVENT_BEGIN_TIME, aosTime)
putExtra(CalendarContract.EXTRA_EVENT_END_TIME, losTime)
}
@@ -1,3 +1,21 @@
/*
* 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.framework
import org.junit.Assert.assertArrayEquals
@@ -0,0 +1,51 @@
/*
* 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.framework
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Test
class IcomCivProtocolTest {
private fun bytes(vararg values: Int) = ByteArray(values.size) { values[it].toByte() }
@Test
fun parseFreqModePayload_ic705ReadFreqReply() {
// Reply captured from an IC-705 to CMD 0x03: frequency only, no mode byte
val reply = bytes(0xFE, 0xFE, 0xE0, 0xA4, 0x03, 0x60, 0x74, 0x95, 0x45, 0x01, 0xFD)
val response = IcomCivProtocol.parseResponse(reply, IcomCivProtocol.CMD_READ_FREQ)
assertNotNull(response)
assertEquals(145957460L to "", IcomCivProtocol.parseFreqModePayload(response!!.payload))
}
@Test
fun parseFreqModePayload_withModeByte() {
assertEquals(
435611000L to "USB",
IcomCivProtocol.parseFreqModePayload(bytes(0x00, 0x10, 0x61, 0x35, 0x04, 0x01, 0x01))
)
}
@Test
fun parseFreqModePayload_tooShort() {
assertNull(IcomCivProtocol.parseFreqModePayload(bytes(0x60, 0x74, 0x95, 0x45)))
}
}
@@ -9,6 +9,8 @@
*/
package com.rtbishop.look4sat.core.data.lotw
import com.rtbishop.look4sat.core.domain.logbook.LoTWSatelliteAliases
import com.rtbishop.look4sat.core.domain.repository.LoTWOperationException
import com.rtbishop.look4sat.core.domain.repository.LoTWZonePair
import java.io.File
import org.junit.Assert.assertEquals
@@ -75,4 +77,50 @@ class LoTWConfigTest {
assertNull(meta.regionField)
assertTrue(meta.countryZones.isEmpty())
}
@Test
fun `tracker catalogue names resolve to the ARRL satellite`() {
// The names the TLE sources store vs the single name ARRL knows for that satellite.
assertEquals("SO-50", config.resolveSatellite("SAUDISAT 1C"))
assertEquals("ARISS", config.resolveSatellite("ISS (ZARYA)"))
assertEquals("PO-101", config.resolveSatellite("DIWATA 2B"))
assertEquals("AO-91", config.resolveSatellite("FOX-1B"))
assertEquals("IO-86", config.resolveSatellite("LAPAN-A2"))
assertEquals("RS-44", config.resolveSatellite("DOSAAF-85"))
assertEquals("BO-102", config.resolveSatellite("CAS-7B"))
assertEquals("SO-50", config.resolveSatellite("SO-50"))
}
@Test
fun `satellite signing accepts tracker names and keeps ARRL spelling`() {
assertEquals("SO-50", config.satellite("SAUDISAT 1C", "2026-09-27"))
assertEquals("ARISS", config.satellite("ISS (ZARYA)", "2026-09-27"))
assertEquals("SO-50", config.satellite("SO-50 (SaudiOSCAR 50)", "2026-09-27"))
}
@Test
fun `satellite signing rejects names ARRL does not know`() {
// Placeholder designations are deliberately unmapped: signing them would name the
// wrong object, so the record must stay un-uploadable.
listOf("OBJECT AY", "MARINA", "NOT A SATELLITE").forEach { name ->
val error = runCatching { config.satellite(name, "2026-09-27") }.exceptionOrNull()
assertTrue("$name should be rejected", error is LoTWOperationException)
}
}
@Test
fun `satellite signing enforces the ARRL service dates`() {
// SO-50 is listed from 2002-12-20: a QSO before that cannot be signed.
val error = runCatching { config.satellite("SAUDISAT 1C", "2002-01-01") }.exceptionOrNull()
assertTrue(error is LoTWOperationException)
assertEquals("SO-50", config.satellite("SAUDISAT 1C", "2003-01-01"))
}
@Test
fun `every alias target is a real ARRL satellite name`() {
val catalogue = config.satelliteNames().toSet()
assertTrue("ARRL catalogue is unexpectedly small", catalogue.size > 100)
val unknown = LoTWSatelliteAliases.table.filterValues { it !in catalogue }
assertEquals("alias targets missing from config.tq6: $unknown", emptyMap<String, String>(), unknown)
}
}
@@ -1,3 +1,21 @@
/*
* 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.model.SatStatus
@@ -44,6 +44,7 @@ import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import java.io.InputStream
import java.time.LocalDate
@OptIn(ExperimentalCoroutinesApi::class)
class DatabaseRepoTest {
@@ -51,6 +52,10 @@ class DatabaseRepoTest {
private val dispatcher = StandardTestDispatcher()
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
fun `manual satellite import parses csv stream from content uri`() = runTest(dispatcher) {
val uri = "content://look4sat/import/satellites"
@@ -150,20 +155,144 @@ class DatabaseRepoTest {
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 = """
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()
private fun validTleStream(): InputStream = """
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
""".trimIndent().byteInputStream()
private fun jamxTleStream(): InputStream = """
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
""".trimIndent().byteInputStream()
}
@@ -195,6 +324,21 @@ private class FakeLocalSource : ILocalSource {
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>) {
insertedEntries += entries
}
@@ -271,6 +415,8 @@ private class FakeSettingsRepo(dataSources: DataSourcesSettings = defaultDataSou
override fun setStationPosition(locator: String): Boolean = true
override fun getCurrentGrid(): String? = null
override fun getSatelliteTypesIds(types: List<String>): List<Int> = emptyList()
override fun setSatelliteTypeIds(type: String, ids: List<Int>) {
@@ -312,6 +458,11 @@ private class FakeSettingsRepo(dataSources: DataSourcesSettings = defaultDataSou
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()
private val workedGrids = MutableStateFlow(emptySet<String>())
@@ -1,3 +1,21 @@
/*
* 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.repository.LoTWResult
@@ -112,9 +130,12 @@ class LoTWRepositoryTest {
assertEquals("A50QO", qso.call)
assertEquals("FO-29", qso.satName)
assertEquals("CW", qso.mode)
assertEquals("2M", qso.bandUp)
assertEquals("70CM", qso.bandDown)
assertEquals("V/U", qso.bandLabel)
// ADIF BAND is the transmitted (uplink) band, BAND_RX the received (downlink) one.
// Confirmed against the live report of the own uploads: SO-50 QSOs arrive as
// BAND=2M/FREQ=145.85000 + BAND_RX=70CM/FREQ_RX=436.80500, i.e. BAND=uplink.
assertEquals("70CM", qso.bandUp)
assertEquals("2M", qso.bandDown)
assertEquals("U/V", qso.bandLabel)
assertEquals(2026, java.time.Instant.ofEpochMilli(qso.epochMs).atZone(java.time.ZoneOffset.UTC).year)
}
@@ -146,6 +167,29 @@ class LoTWRepositoryTest {
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
fun parseQsosRejectsBodyWithoutEoh() {
assertNull(repo.parseConfirmedGridQsos("<HTML>Username/password incorrect</HTML>"))
@@ -0,0 +1,423 @@
/*
* Look4Sat. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2019-2026 Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*/
package com.rtbishop.look4sat.core.data.repository
import com.rtbishop.look4sat.core.data.database.QsoDao
import com.rtbishop.look4sat.core.data.database.entity.QsoEntity
import com.rtbishop.look4sat.core.domain.logbook.QsoRecord
import com.rtbishop.look4sat.core.domain.logbook.QsoStatus
import com.rtbishop.look4sat.core.domain.logbook.toConfirmedRecord
import com.rtbishop.look4sat.core.domain.model.GridQso
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.runBlocking
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotEquals
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* The logbook split reported 2026-09-28: a QSO uploaded from the app and the confirmation LoTW
* reported for it ended up as two rows, because the record keeps the tracking-source name
* ("SAUDISAT 1C") and the uplink band while the report carries the ARRL name ("SO-50") — and,
* before the direction fix, the mirrored band pair.
*/
class QsoRepositoryTest {
private val dao = FakeQsoDao()
private val repository = QsoRepository(dao, Dispatchers.Unconfined)
/** 2026-09-16 07:45Z, an SO-50 contact of the user's own report. */
private val qsoStart = 1_789_544_700_000L
@Test
fun mergeLoTW_confirmsTheUploadedRecordInsteadOfAddingASecondRow() = runBlocking {
val uploadedId = dao.save(loggedInApp("SAUDISAT 1C", lotwUploaded = true).toEntity()) // 145.850 -> 2M
repository.mergeLoTW(listOf(reportConfirmation(satName = "SO-50")))
val rows = dao.getAll()
assertEquals(1, rows.size)
assertEquals(uploadedId, rows.first().id)
assertTrue(rows.first().lotwConfirmed)
// The row is also renamed to the ARRL spelling while it is merged.
assertEquals("SO-50", rows.first().satelliteName)
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
fun mergeLoTW_foldsRowsSplitByTheOldNameAndBandMismatch() = runBlocking {
val uploadedId = dao.save(loggedInApp("SAUDISAT 1C", lotwUploaded = true).toEntity())
// Row imported before the fix: ARRL name and the mirrored band pair (BAND_RX read as
// the uplink), so it never folded into the local record.
dao.save(
reportConfirmation(satName = "SO-50").copy(band = "70CM", rxBand = "2M").toEntity()
)
repository.mergeLoTW(listOf(reportConfirmation(satName = "SO-50")))
val rows = dao.getAll()
assertEquals(1, rows.size)
assertEquals(uploadedId, rows.first().id)
assertTrue(rows.first().lotwConfirmed)
// The operator's own frequencies survive the consolidation.
assertEquals(145_850_000L, rows.first().txFrequencyHz)
assertEquals(436_795_000L, rows.first().rxFrequencyHz)
}
@Test
fun mergeLoTW_keepsAnotherContactOfTheSamePassApart() = runBlocking {
dao.save(loggedInApp("SAUDISAT 1C", lotwUploaded = true).toEntity())
val otherOperator = reportConfirmation(satName = "SO-50").copy(theirCallsign = "BG5JVM")
repository.mergeLoTW(listOf(reportConfirmation(satName = "SO-50"), otherOperator))
val rows = dao.getAll()
assertEquals(2, rows.size)
// The local row is confirmed by its own report entry; the second operator's contact
// is a separate QSO and stays a separate, separately confirmed row.
assertEquals(setOf("BG5JSB", "BG5JVM"), rows.map { it.theirCallsign }.toSet())
assertTrue(rows.all { it.lotwConfirmed })
}
@Test
fun consolidateConfirmations_repairsAnAlreadySplitLogbook() = runBlocking {
val uploadedId = dao.save(loggedInApp("SAUDISAT 1C", lotwUploaded = true).toEntity())
// Row imported before the fix: ARRL name + mirrored band pair.
dao.save(reportConfirmation(satName = "SO-50").copy(band = "70CM", rxBand = "2M").toEntity())
assertEquals(1, repository.consolidateConfirmations())
val rows = dao.getAll()
assertEquals(1, rows.size)
assertEquals(uploadedId, rows.first().id)
assertTrue(rows.first().lotwConfirmed)
// Nothing left to fold on a second pass.
assertEquals(0, repository.consolidateConfirmations())
}
@Test
fun consolidateConfirmations_renamesRowsImportedUnderATrackerName() = runBlocking {
// A record logged before names were normalised at write time.
val id = dao.save(loggedInApp("SAUDISAT 1C", lotwUploaded = true).toEntity())
repository.consolidateConfirmations()
val row = dao.getAll().single()
assertEquals(id, row.id)
assertEquals("SO-50", row.satelliteName)
// Upload state and the operator's own frequencies survive the rename.
assertTrue(row.lotwUploaded)
assertEquals(145_850_000L, row.txFrequencyHz)
// A satellite ARRL does not list keeps whatever the tracker published.
dao.save(loggedInApp("FOO-1", lotwUploaded = false).toEntity())
repository.consolidateConfirmations()
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
fun mergeLoTW_fixesBandDirectionOfAStaleConfirmationRow() = runBlocking {
// A confirmation downloaded by the pre-fix parser: ARRL name but the band direction
// mirrored (downlink stored as the uplink), and no local row to fold it into, so it
// sits as its own QSL row.
val staleId = dao.save(reportConfirmation(satName = "SO-50").copy(band = "70CM", rxBand = "2M").toEntity())
repository.mergeLoTW(listOf(reportConfirmation(satName = "SO-50")))
val rows = dao.getAll()
assertEquals(1, rows.size)
assertEquals(staleId, rows.first().id)
assertTrue(rows.first().lotwConfirmed)
// The stale row adopts the report's (uplink-first) direction instead of being skipped
// forever by the dedupe key.
assertEquals("2M", rows.first().band)
assertEquals("70CM", rows.first().rxBand)
}
@Test
fun save_editingAnUploadedRecordCreatesANewRecord() = runBlocking {
val originalId = repository.save(loggedInApp("SAUDISAT 1C", lotwUploaded = true))
val edited = loggedInApp("SAUDISAT 1C", lotwUploaded = true).copy(id = originalId, theirCallsign = "BG5JVM")
val newId = repository.save(edited)
assertNotEquals(originalId, newId)
val rows = dao.getAll()
assertEquals(2, rows.size)
// The original keeps its identity and upload state, untouched.
val original = rows.first { it.id == originalId }
assertEquals("BG5JSB", original.theirCallsign)
assertTrue(original.lotwUploaded)
// The edited content is a fresh, uploadable record.
val created = rows.first { it.id == newId }
assertEquals("BG5JVM", created.theirCallsign)
assertFalse(created.lotwUploaded)
assertFalse(created.lotwConfirmed)
}
@Test
fun save_editingAnUnuploadedRecordUpdatesInPlace() = runBlocking {
val id = repository.save(loggedInApp("SAUDISAT 1C", lotwUploaded = false))
repository.save(loggedInApp("SAUDISAT 1C", lotwUploaded = false).copy(id = id, theirCallsign = "BG5JVM"))
val rows = dao.getAll()
assertEquals(1, rows.size)
assertEquals("BG5JVM", rows.first().theirCallsign)
}
@Test
fun save_editingAnUploadedRecordWithoutChangesKeepsOneRow() = runBlocking {
val id = repository.save(loggedInApp("SAUDISAT 1C", lotwUploaded = true))
repository.save(loggedInApp("SAUDISAT 1C", lotwUploaded = true).copy(id = id))
assertEquals(1, dao.getAll().size)
}
@Test
fun save_editingAnUploadedRecordWithOnlySecondsChangedKeepsOneRow() = runBlocking {
val id = repository.save(loggedInApp("SAUDISAT 1C", lotwUploaded = true))
// The edit dialog edits whole minutes and rounds the seconds away on save, so a
// seconds-only difference must not be treated as an edit (no new record).
repository.save(loggedInApp("SAUDISAT 1C", lotwUploaded = true).copy(id = id, startUtcMillis = qsoStart + 10_000L))
assertEquals(1, dao.getAll().size)
}
@Test
fun markWavelogUploaded_setsOnlyTheWavelogFlag() = runBlocking {
val id = dao.save(loggedInApp("SO-50", lotwUploaded = false).toEntity())
repository.markWavelogUploaded(listOf(id))
val row = dao.getAll().single()
assertTrue(row.wavelogUploaded)
assertFalse(row.lotwUploaded)
}
@Test
fun save_editingAWavelogUploadedRecordCreatesANewRecord() = runBlocking {
val id = dao.save(loggedInApp("SO-50", lotwUploaded = false).copy(wavelogUploaded = true).toEntity())
repository.save(loggedInApp("SO-50", lotwUploaded = false).copy(id = id, theirCallsign = "BG5JVM"))
assertEquals(2, dao.getAll().size)
assertEquals(1, dao.getAll().count { it.wavelogUploaded })
}
/** A record as the log page creates it: tracker name, repeater pair, uplink band. */
private fun loggedInApp(satelliteName: String, lotwUploaded: Boolean) = QsoRecord(
startUtcMillis = qsoStart,
theirCallsign = "BG5JSB",
myCallsign = "BA7OPF",
txFrequencyHz = 145_850_000L,
rxFrequencyHz = 436_795_000L,
band = "2M",
rxBand = "70CM",
mode = "FM",
satelliteName = satelliteName,
propagationMode = "SAT",
status = QsoStatus.COMPLETE,
lotwUploaded = lotwUploaded
)
/** A confirmation as the report parser + [toConfirmedRecord] build it (BAND carries the uplink). */
private fun reportConfirmation(satName: String) = GridQso(
call = "BG5JSB",
epochMs = qsoStart,
satName = satName,
mode = "FM",
bandUp = "2M",
bandDown = "70CM",
dxcc = 318,
country = "China",
cqz = 24,
state = "GD",
myGrid = "OL62",
myGrids = setOf("OL62", "OL63"),
theirGrids = listOf("EN52", "EN53")
).toConfirmedRecord("BA7OPF")
private fun QsoRecord.toEntity() = QsoEntity(
id = id,
startUtcMillis = startUtcMillis,
endUtcMillis = endUtcMillis,
theirCallsign = theirCallsign,
myCallsign = myCallsign,
theirGrid = theirGrid,
myGrid = myGrid,
sentReport = sentReport,
receivedReport = receivedReport,
txFrequencyHz = txFrequencyHz,
rxFrequencyHz = rxFrequencyHz,
band = band,
rxBand = rxBand,
mode = mode,
submode = submode,
satelliteName = satelliteName,
transponderName = transponderName,
satelliteMode = satelliteMode,
passAosUtcMillis = passAosUtcMillis,
automatic = automatic,
status = status.name,
dedupeKey = listOf(startUtcMillis, theirCallsign, myCallsign, txFrequencyHz, mode, submode, satelliteName)
.joinToString("|"),
propagationMode = propagationMode,
lotwConfirmed = lotwConfirmed,
lotwUploaded = lotwUploaded,
wavelogUploaded = wavelogUploaded,
lotwReceived = lotwReceived,
lotwQslDate = lotwQslDate,
vuccGrids = vuccGrids.joinToString(","),
theirVuccGrids = theirVuccGrids.joinToString(","),
dxcc = dxcc,
country = country,
cqZone = cqZone,
region = region,
comment = comment
)
private class FakeQsoDao : QsoDao {
private val rows = linkedMapOf<Long, QsoEntity>()
private var nextId = 1L
override fun observeAll(): Flow<List<QsoEntity>> = flowOf(rows.values.toList())
override suspend fun find(id: Long): QsoEntity? = rows[id]
override suspend fun getAll(): List<QsoEntity> = rows.values.toList()
override suspend fun getDedupeKeys(): List<String> = rows.values.map { it.dedupeKey }
override suspend fun save(record: QsoEntity): Long {
val id = if (record.id == 0L) nextId++ else record.id
rows[id] = record.copy(id = id)
return id
}
override suspend fun importRecords(records: List<QsoEntity>): List<Long> = records.map { save(it) }
override suspend fun delete(id: Long) {
rows.remove(id)
}
override suspend fun markUploaded(ids: List<Long>) {
ids.forEach { id -> rows[id]?.let { rows[id] = it.copy(lotwUploaded = true) } }
}
override suspend fun markWavelogUploaded(ids: List<Long>) {
ids.forEach { id -> rows[id]?.let { rows[id] = it.copy(wavelogUploaded = true) } }
}
}
}
@@ -23,6 +23,7 @@ import com.rtbishop.look4sat.core.domain.predict.OrbitalObject
import com.rtbishop.look4sat.core.domain.repository.ISelectionRepo
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
import com.rtbishop.look4sat.core.domain.source.ILocalSource
import com.rtbishop.look4sat.core.domain.source.Sources
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
@@ -241,6 +242,26 @@ class SelectionRepoSearchTest {
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
fun `getTypesList excludes only the Other placeholder`() = runTest {
val repo = createRepo(sampleItems)
@@ -282,6 +303,10 @@ private class FakeLocalSourceForSearch(
override suspend fun getEntriesTotal(): Int = items.size
override suspend fun getEntriesList(): List<SatItem> = items
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 deleteEntries() = Unit
override suspend fun getIdsWithModes(modes: List<String>): List<Int> = sstvIds
@@ -321,6 +346,9 @@ private class FakeSettingsRepoForSearch : ISettingsRepo {
)
override val wavelogSettings: StateFlow<com.rtbishop.look4sat.core.domain.model.WavelogSettings> =
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> =
MutableStateFlow(com.rtbishop.look4sat.core.domain.model.LoTWSettings())
@@ -330,6 +358,7 @@ private class FakeSettingsRepoForSearch : ISettingsRepo {
override fun setStationPosition(latitude: Double, longitude: Double, altitude: Double): Boolean = true
override fun setStationPosition(): Boolean = true
override fun setStationPosition(locator: String): Boolean = true
override fun getCurrentGrid(): String? = null
override fun getSatelliteTypesIds(types: List<String>): List<Int> =
types.flatMap { typeIds[it].orEmpty() }.distinct()
@@ -0,0 +1,197 @@
/*
* Look4Sat-BA7OPF. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2026 BA7OPF.
* Based on Look4Sat by Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*/
package com.rtbishop.look4sat.core.data.repository
import com.rtbishop.look4sat.core.domain.logbook.QsoRecord
import com.rtbishop.look4sat.core.domain.logbook.QsoStatus
import com.rtbishop.look4sat.core.domain.model.WavelogUploadSettings
import com.rtbishop.look4sat.core.domain.repository.WavelogProblem
import com.rtbishop.look4sat.core.domain.repository.WavelogUploadOutcome
import kotlinx.coroutines.runBlocking
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class WavelogUploadRepositoryTest {
// region # gridCompatible (mirror of the server's adif_grid_check_location prefix rule)
@Test
fun grid_sameGridsPass() {
assertTrue(gridCompatible("OL62TI", "OL62TI"))
}
@Test
fun grid_longerAdifGridPasses() {
assertTrue(gridCompatible("OL62TI", "OL62"))
}
@Test
fun grid_longerStationGridPasses() {
assertTrue(gridCompatible("OL62", "OL62TI"))
}
@Test
fun grid_differentSquaresFail() {
assertFalse(gridCompatible("PM01", "OL62"))
}
@Test
fun grid_blankSidesDisableTheCheck() {
assertTrue(gridCompatible("", "OL62"))
assertTrue(gridCompatible("OL62", ""))
}
@Test
fun grid_stationMarginAcceptsAPrefixPart() {
assertTrue(gridCompatible("OL63AA", "OL62,OL63"))
assertFalse(gridCompatible("PM01AA", "OL62,OL63"))
}
@Test
fun grid_caseInsensitive() {
assertTrue(gridCompatible("ol62ti", "OL62"))
}
// endregion
// region # classifyUploadResponse
private val calls = mapOf(1L to "BG5JVM", 2L to "BH6RJD", 3L to "BI3SIR")
@Test
fun createdMarksEverything() {
val outcome = classifyUploadResponse(
201, """{"status":"created","adif_count":3,"adif_errors":0,"messages":[""]}""", calls
)
assertEquals(WavelogUploadOutcome.Imported(3, 0, 0, listOf(1L, 2L, 3L)), outcome)
}
@Test
fun duplicatesAreCountedAndStillMarked() {
val body = """{"status":"abort","messages":["Date/Time: 2026-10-04 12:34:00 Callsign: BG5JVM Band: 70CM Duplicate for BA7OPF<br>"]}"""
val outcome = classifyUploadResponse(400, body, calls)
assertEquals(WavelogUploadOutcome.Imported(2, 1, 0, listOf(1L, 2L, 3L)), outcome)
}
@Test
fun skippedRecordIsNotMarked() {
val body = """{"status":"abort","messages":["Differing locator <b>PM01</b> while importing QSO with <b>BH6RJD</b> for station locator <b>OL62TI</b>: SKIPPED<br>"]}"""
val outcome = classifyUploadResponse(400, body, calls)
assertEquals(WavelogUploadOutcome.Imported(2, 0, 1, listOf(1L, 3L)), outcome)
}
@Test
fun duplicateAndSkipTogether() {
val body = """{"status":"abort","messages":["Differing locator <b>PM01</b> while importing QSO with <b>BH6RJD</b> for station locator <b>OL62TI</b>: SKIPPED<br>Date/Time: 2026-10-04 12:34:00 Callsign: BG5JVM Band: 70CM Duplicate for BA7OPF<br>"]}"""
val outcome = classifyUploadResponse(400, body, calls)
assertEquals(WavelogUploadOutcome.Imported(1, 1, 1, listOf(1L, 3L)), outcome)
}
@Test
fun unmappedSkipMarksNothing() {
val body = """{"status":"abort","messages":["Differing locator <b>PM01</b> while importing QSO with <b>N0CALL</b> for station locator <b>OL62TI</b>: SKIPPED<br>"]}"""
val outcome = classifyUploadResponse(400, body, calls)
assertTrue(outcome is WavelogUploadOutcome.Rejected)
}
@Test
fun otherMessagesAreRejected() {
val body = """{"status":"abort","messages":["QSO on 2026-10-04: You tried to import a QSO without any given CALL. This QSO wasn't imported. It's invalid.<br>"]}"""
val outcome = classifyUploadResponse(400, body, calls)
assertTrue(outcome is WavelogUploadOutcome.Rejected)
}
@Test
fun authFailuresAreActionable() {
assertTrue(classifyUploadResponse(401, "", calls) is WavelogUploadOutcome.Failed)
val forbidden = classifyUploadResponse(403, """{"reason":"API key does not have write permissions"}""", calls)
assertTrue(forbidden is WavelogUploadOutcome.Failed)
assertTrue((forbidden as WavelogUploadOutcome.Failed).message.contains("write"))
}
@Test
fun htmlGarbageOrEmptyBodyMarksNothing() {
assertTrue(classifyUploadResponse(400, "<html>oops</html>", calls) is WavelogUploadOutcome.Rejected)
assertTrue(classifyUploadResponse(400, "", calls) is WavelogUploadOutcome.Failed)
}
// endregion
// region # parseStations + prepare
@Test
fun stationsParse() {
val body = """[{"station_id":"1","station_profile_name":"Shenzhen","station_gridsquare":"OL62TI","station_callsign":"BA7OPF","station_active":"1"}]"""
val stations = parseStations(body)
assertEquals(1, stations.size)
assertEquals("1", stations.single().id)
assertEquals("Shenzhen", stations.single().name)
assertEquals("BA7OPF", stations.single().callsign)
assertEquals("OL62TI", stations.single().grid)
assertTrue(stations.single().active)
}
@Test
fun prepareWithoutConfigIsBlocked() = runBlocking {
val repo = WavelogUploadRepository()
val preview = repo.prepare(listOf(sampleRecord(start = 1_700_000_000_000L)), WavelogUploadSettings())
assertEquals(WavelogProblem.NOT_CONFIGURED, preview.blockedBy)
assertEquals(0, preview.count)
}
@Test
fun prepareWithoutStationIsBlocked() = runBlocking {
val repo = WavelogUploadRepository()
val settings = WavelogUploadSettings(url = "http://example.org", apiKey = "k")
val preview = repo.prepare(listOf(sampleRecord(start = 1_700_000_000_000L)), settings)
assertEquals(WavelogProblem.NO_STATION, preview.blockedBy)
}
@Test
fun prepareSkipsGridMismatchAndDeduplicates() = runBlocking {
val repo = WavelogUploadRepository()
val settings = WavelogUploadSettings(
url = "http://example.org", apiKey = "k", stationId = "1",
stationName = "Shenzhen", stationCallsign = "BA7OPF", stationGrid = "OL62TI"
)
val records = listOf(
sampleRecord(start = 1_700_000_000_000L, myGrid = "PM01AA"),
sampleRecord(start = 1_700_000_120_000L, myGrid = "OL62TI"),
sampleRecord(start = 1_700_000_120_000L, myGrid = "OL62TI") // batch duplicate of the previous
)
val preview = repo.prepare(records, settings)
assertEquals(1, preview.count)
assertEquals(2, preview.skipped)
assertEquals(1, preview.reasons[WavelogProblem.GRID_MISMATCH])
assertEquals(1, preview.submittedIds.size)
assertEquals("Shenzhen · BA7OPF · OL62TI", preview.stationLabel)
}
// endregion
private fun sampleRecord(start: Long, myGrid: String = "OL62TI") = QsoRecord(
id = start,
startUtcMillis = start,
theirCallsign = "BG5JVM",
myCallsign = "BA7OPF",
myGrid = myGrid,
txFrequencyHz = 145_850_000L,
rxFrequencyHz = 436_795_000L,
band = "2M",
rxBand = "70CM",
mode = "FM",
satelliteName = "SO-50",
propagationMode = "SAT",
status = QsoStatus.COMPLETE
)
}
@@ -18,7 +18,7 @@ import java.util.TimeZone
object AdifCodec {
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("PROGRAMID", "Look4Sat"))
append("<EOH>\r\n")
@@ -32,7 +32,7 @@ object AdifCodec {
append(field("TIME_OFF", format(it, "HHmmss")))
}
append(field("CALL", record.theirCallsign))
append(field("STATION_CALLSIGN", record.myCallsign))
if (includeStationCallsign) append(field("STATION_CALLSIGN", record.myCallsign))
appendOptional("MODE", record.mode)
appendOptional("SUBMODE", record.submode)
appendOptional("GRIDSQUARE", record.theirGrid)
@@ -17,9 +17,26 @@ interface IQsoRepository {
suspend fun find(id: Long): QsoRecord?
suspend fun save(record: QsoRecord): 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). */
suspend fun markWavelogUploaded(ids: List<Long>)
suspend fun exportAdi(ids: Set<Long>? = null, includeIncomplete: Boolean = false): String
suspend fun importAdi(content: String): AdifImportResult
suspend fun mergeConfirmed(records: List<QsoRecord>): AdifImportResult
suspend fun mergeLoTW(records: List<QsoRecord>): AdifImportResult
/**
* 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]).
* Confirmations merged before the tracker-name/band identity fix split every contact in
* two; this repairs the existing logbook. Returns the number of merged rows.
*/
suspend fun consolidateConfirmations(): Int
}
@@ -1,3 +1,21 @@
/*
* 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.model.GridQso
@@ -9,6 +27,8 @@ fun GridQso.toConfirmedRecord(accountCallsign: String): QsoRecord = QsoRecord(
startUtcMillis = epochMs,
theirCallsign = call,
myCallsign = accountCallsign.trim().uppercase(),
theirGrid = theirGrids.firstOrNull().orEmpty(),
theirVuccGrids = theirGrids,
myGrid = (myGrids.firstOrNull() ?: myGrid).orEmpty(),
vuccGrids = myGrids.toList(),
band = bandUp,
@@ -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.logbook
import java.util.Locale
/**
* Satellite names of the ARRL LoTW catalogue and the tracker names that map onto them.
*
* ARRL's config.tq6 knows every satellite by exactly ONE name ("SO-50", "ARISS", "PO-101",
* "BO-102"), while the TLE sources the tracker uses carry their own catalogue names: SatNOGS
* lists SO-50 as "SAUDISAT 1C", the ISS as "ISS (ZARYA)", PO-101 as "DIWATA 2B" and AO-123 as
* "OBJECT AY". Without this mapping a signed QSO on those satellites is rejected as an unknown
* satellite.
*
* Keys are tracker names normalised by [normalize] (upper case, A-Z0-9 only); values are the
* exact config.tq6 satellite names. Generated 2026-09-27 from:
* - SatNOGS DB satellites (`name` + `names` alias list) joined to config.tq6 by exact alias,
* - live SatNOGS 3LE names and Celestrak amateur names joined to the same NORAD id,
* - designator tokens of the config.tq6 satellite descriptions (e.g. CAS-7B -> BO-102),
* - the ISS family, which SatNOGS does not list under its ARRL name "ARISS".
* Placeholder names ("OBJECT AY" and friends) are deliberately NOT mapped: they are recycled
* between objects and would sign the wrong satellite. LoTWConfigTest asserts that every target
* here is a real config.tq6 name.
*/
object LoTWSatelliteAliases {
val table: Map<String, String> = mapOf(
"AMSATOSCAR7" to "AO-7", "AO123" to "AO-123", "AO16" to "AO-16",
"AO27" to "AO-27", "AO7" to "AO-7", "AO73" to "AO-73",
"AO85" to "AO-85", "AO91" to "AO-91", "AO92" to "AO-92",
"ARISSAT1" to "KEDR", "ASRTU1" to "AO-123", "ASRTUFRIENDSHIP" to "AO-123",
"BJ2CR" to "AO-123", "BREEZEKMRB" to "RS-44", "BRICSAT2" to "NO-103",
"BRICSATP" to "NO-83", "BY701" to "BY70-1", "CAMSAT" to "FO-118",
"CAS10" to "HO-119", "CAS2F" to "XW-2F", "CAS2T" to "CAS-2T",
"CAS3A" to "XW-2A", "CAS3B" to "XW-2B", "CAS3C" to "XW-2C",
"CAS3CBJ1SD" to "XW-2C", "CAS3D" to "XW-2D", "CAS3E" to "XW-2E",
"CAS3F" to "XW-2F", "CAS4A" to "CAS-4A", "CAS4B" to "CAS-4B",
"CAS5A" to "FO-118", "CAS6" to "TO-108", "CAS7B" to "BO-102",
"CAS9" to "HO-113", "CZ11RB" to "CAS-2T", "DELFIC3" to "DO-64",
"DIWATA2" to "PO-101", "DIWATA2B" to "PO-101", "DO64" to "DO-64",
"DOSAAF85" to "RS-44", "DRUZHBAATURK" to "AO-123", "EO79" to "EO-79",
"EO88" to "EO-88", "ESHAIL2" to "QO-100", "EUROPEANOSCAR79" to "EO-79",
"EYESAT1" to "AO-27", "EYESATA" to "AO-27", "FENGTAIOSCAR118" to "FO-118",
"FO118" to "FO-118", "FO29" to "FO-29", "FOX1" to "AO-85",
"FOX1A" to "AO-85", "FOX1B" to "AO-91", "FOX1D" to "AO-92",
"FRESCO" to "LO-87", "FUNCUBE1" to "AO-73", "FUNCUBE2" to "UKUBE1",
"FUNCUBE3" to "EO-79", "FUNCUBE5" to "EO-88", "GREENCUBE" to "IO-117",
"HADESD" to "SO-121", "HADESICM" to "SO-125", "HADESR" to "SO-124",
"HAIL2" to "QO-100", "HAMSAT" to "VO-52", "HO113" to "HO-113",
"HO119" to "HO-119", "HOPE4" to "HO-119", "HYDRA1" to "SO-121",
"INSPIRESAT7" to "INSPR7", "IO117" to "IO-117", "IO86" to "IO-86",
"ISS" to "ARISS", "ISSZARYA" to "ARISS", "JAS2" to "FO-29",
"JINNIUZUO1" to "TAURUS", "JO97" to "JO-97", "JORDANOSCAR97" to "JO-97",
"JY1SAT" to "JO-97", "LAPANA2" to "IO-86", "LILACSAT1" to "LO-90",
"LILACSAT2" to "CAS-3H", "LITUANICASAT1" to "LO-78", "LO19" to "LO-19",
"LO87" to "LO-87", "LUSAT" to "LO-19", "LUSEX" to "LO-87",
"MAYA3" to "MAYA-3", "MAYA4" to "MAYA-4", "MESAT1" to "MO-122",
"MESAT1OSCAR" to "MO-122", "MESAT1OSCAR122" to "MO-122", "MIRSAT1" to "MO-112",
"MO122" to "MO-122", "MTCUBE2" to "IO-117", "NA1SS" to "ARISS",
"NAYIF1" to "EO-88", "NEWSAT1" to "LO-87", "NO103" to "NO-103",
"NO104" to "NO-104", "NO44" to "NO-44", "NO83" to "NO-83",
"NO84" to "NO-84", "NUSAT1" to "LO-87", "ORARI" to "IO-86",
"OSCAR16PACSAT" to "AO-16", "OSCAR19LUSAT" to "LO-19", "OSCAR64" to "DO-64",
"OSCAR7" to "AO-7", "PACSAT" to "AO-16", "PARKINSONSAT" to "NO-84",
"PCSAT" to "NO-44", "PHILLIPINESOSCAR101" to "PO-101", "PO101" to "PO-101",
"PSAT" to "NO-84", "PSAT2" to "NO-104", "QB50P1" to "EO-79",
"QO100" to "QO-100", "RADFXSAT" to "AO-91", "RADIOROSTO" to "RS-15",
"RS0ISS" to "ARISS", "RS14" to "AO-21", "RS15" to "RS-15",
"RS44" to "RS-44", "RS64S" to "AO-123", "SAUDISAT1C" to "SO-50",
"SO121" to "SO-121", "SO124" to "SO-124", "SO125" to "SO-125",
"SO50" to "SO-50", "SOLUTUSNANOSATTELITE" to "SONATE", "SONATE2" to "SONATE",
"TAURUS1" to "TAURUS", "TEVEL21" to "TEV2-1", "TEVEL22" to "TEV2-2",
"TEVEL23" to "TEV2-3", "TEVEL24" to "TEV2-4", "TEVEL25" to "TEV2-5",
"TEVEL26" to "TEV2-6", "TEVEL27" to "TEV2-7", "TEVEL28" to "TEV2-8",
"TEVEL29" to "TEV2-9", "USAT1" to "LO-87", "USNAP1" to "NO-103",
"VO52" to "VO-52", "W3ADO" to "NO-44", "XW2A" to "XW-2A",
"XW2B" to "XW-2B", "XW2C" to "XW-2C", "XW2D" to "XW-2D",
"XW2E" to "XW-2E", "XW2F" to "XW-2F", "XW3" to "HO-113",
"XW3CAS9" to "HO-113", "XW4" to "HO-119", "YB0X" to "IO-86",
"ZARYA" to "ARISS",
)
/**
* Tracker name -> ARRL name. An exact table hit wins; otherwise the longest alias key
* contained in the name matches, so a name carrying an extra suffix ("SAUDISAT 1C
* (SO-50)") still resolves. Keys shorter than 5 characters never match loosely.
*/
fun lookup(name: String): String? {
val normalized = normalize(name)
table[normalized]?.let { return it }
return table.entries
.filter { it.key.length >= 5 && normalized.contains(it.key) }
.maxByOrNull { it.key.length }
?.value
}
/** Upper case, everything that is not A-Z0-9 removed ("ISS (ZARYA)" -> "ISSZARYA"). */
fun normalize(name: String): String = name.uppercase(Locale.US).filter { it.isLetterOrDigit() }
/**
* Resolve any tracker name against the ARRL catalogue: exact official name, then an alias
* from [table], then an official name contained in the tracker name. Returns the official
* name, or null when the tracker name is not an ARRL satellite at all.
*/
fun resolve(name: String, officialNames: Collection<String>): String? {
val trimmed = name.trim()
if (trimmed.isEmpty()) return null
val normalized = trimmed.uppercase(Locale.US)
officialNames.firstOrNull { it.equals(trimmed, true) }?.let { return it }
lookup(trimmed)?.let { alias -> officialNames.firstOrNull { it.equals(alias, true) }?.let { return it } }
return officialNames.firstOrNull { it.length > 1 && normalized.contains(it) }
}
/**
* Catalogue candidates for a partly typed name. Alias keys are matched too, so the
* operator's own spelling ("diwata", "saudisat 1c", "ao9") reaches the ARRL name even
* when the two share no characters.
*/
fun suggestions(query: String, officialNames: Collection<String>, limit: Int = 6): List<String> {
val normalized = normalize(query)
if (normalized.isEmpty()) return emptyList()
val fromAliases = table.entries
.filter { it.key.contains(normalized) || normalized.contains(it.key) }
.map { it.value }
val fromNames = officialNames.filter { normalize(it).contains(normalized) }
return (fromAliases + fromNames + listOfNotNull(lookup(query)))
.distinct()
.filter { candidate -> officialNames.any { it.equals(candidate, true) } }
.take(limit)
}
}
@@ -0,0 +1,71 @@
/*
* 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.logbook
import com.rtbishop.look4sat.core.domain.repository.LoTWProblem
/**
* Wording for the reasons a QSO cannot be signed for LoTW.
*
* The logbook used to report every one of these as "invalid call/date", which sent the operator
* looking at the callsign and date even when the real cause was a missing frequency (no BAND) or
* a satellite name ARRL does not know.
*/
fun LoTWProblem.label(): String = when (this) {
LoTWProblem.BAND -> "missing frequency/band"
LoTWProblem.SATELLITE -> "satellite name not in ARRL's list"
LoTWProblem.MODE -> "mode not accepted by LoTW"
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.INVALID_CONTACT -> "invalid callsign/record"
LoTWProblem.STATION_GRID -> "station grid missing"
LoTWProblem.STATION_REGION -> "station region missing"
LoTWProblem.STATION_ZONE -> "station zones missing"
LoTWProblem.STATION_IOTA -> "station IOTA missing"
LoTWProblem.TOO_MANY_CONTACTS -> "too many contacts in one batch"
LoTWProblem.EMPTY_SELECTION -> "nothing selected"
else -> name.lowercase().replace('_', ' ')
}
/**
* One-line explanation of why records were left out of an upload, e.g.
* "3 QSO(s) can't be uploaded — 2× missing frequency/band, 1× satellite name not in ARRL's list (SAUDISAT 1C)".
* Returns an empty string when nothing was skipped.
*/
fun unavailableUploadSummary(
total: Int,
reasons: Map<LoTWProblem, Int> = emptyMap(),
details: Map<LoTWProblem, String> = emptyMap(),
duplicates: Int = 0,
incomplete: Int = 0
): String {
if (total <= 0) return ""
val parts = reasons.entries
.sortedByDescending { it.value }
.map { (problem, count) ->
val detail = details[problem]?.takeIf { it.isNotBlank() }?.let { " ($it)" }.orEmpty()
"$count× ${problem.label()}$detail"
}
.toMutableList()
if (duplicates > 0) parts += "$duplicates× duplicate of another record in the batch"
if (incomplete > 0) parts += "$incomplete× record not completed"
val subject = if (total == 1) "1 QSO can't be uploaded" else "$total QSO(s) can't be uploaded"
val detail = parts.joinToString(", ").ifBlank { "check the logbook" }
return "$subject — $detail"
}
@@ -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 }
@@ -1,29 +1,192 @@
/*
* 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 java.util.Locale
import kotlin.math.abs
/** LoTW official name vs SatNOGS name ("SO-50 (SaudiOSCAR 50)"): compare the
* parenthetical-free prefix so local records and downloaded confirmations match. */
/** LoTW official name vs tracking-source name ("SO-50 (SaudiOSCAR 50)"): compare the
* parenthetical-free prefix. Kept as the last-resort identity for names the alias
* table does not know (see [satelliteIdentity]). */
fun satMatchKey(name: String): String = name.substringBefore('(').trim().uppercase(Locale.US)
/**
* The satellite identity both sides of a match resolve to.
*
* A local record keeps whatever the tracking source published ("SAUDISAT 1C",
* "ISS (ZARYA)", "DIWATA 2B", "JAS 2") while the LoTW report always carries the ARRL
* name ("SO-50", "ARISS", "PO-101", "FO-29"). Comparing the raw names made the two
* sides of the SAME contact unequal, so a downloaded confirmation was stored as a
* second, separate QSO instead of confirming the uploaded one.
*
* Both sides are therefore resolved through [LoTWSatelliteAliases], which maps the
* tracker names onto the ARRL names and every ARRL name onto itself. Names the table
* does not know (e.g. the recycled "OBJECT xx" placeholders) keep falling back to the
* parenthetical-free prefix, i.e. they simply never match anything else.
*/
fun satelliteIdentity(name: String): String {
val trimmed = name.trim()
if (trimmed.isEmpty()) return ""
val prefix = satMatchKey(trimmed)
return LoTWSatelliteAliases.lookup(trimmed)
?: LoTWSatelliteAliases.lookup(prefix)
?: prefix
}
/**
* The name a QSO is stored under: the ARRL name whenever the tracking source's name maps onto
* one, otherwise the name as published.
*
* Records used to keep the tracker's spelling ("SAUDISAT 1C", "ISS (ZARYA)", "OBJECT AY") while
* every signed ADIF carries the ARRL name, so the logbook showed a different satellite than the
* one LoTW knows and only the upload path resolved the two. Storing the ARRL name keeps one name
* for the logbook, the ADIF export and the signature. Names the alias table does not know are
* left exactly as they are: satellites ARRL does not list have no official name to store, and the
* recycled "OBJECT xx" placeholders must never be mapped onto a real satellite.
*/
fun officialSatelliteName(name: String, officialNames: Collection<String> = emptyList()): String {
val trimmed = name.trim()
if (trimmed.isEmpty()) return ""
LoTWSatelliteAliases.resolve(trimmed, officialNames)?.let { return it }
val prefix = satMatchKey(trimmed)
return LoTWSatelliteAliases.lookup(trimmed) ?: LoTWSatelliteAliases.lookup(prefix) ?: trimmed
}
/**
* Everything about a contact except its bands: the same opposite station, the same
* satellite, the same mode and less than a minute apart. LoTW reports the minute, so
* the time window is what makes a match unambiguous.
*/
fun sameContactIdentity(local: QsoRecord, remote: QsoRecord): Boolean {
val localSat = satelliteIdentity(local.satelliteName)
return localSat.isNotBlank() &&
localSat == satelliteIdentity(remote.satelliteName) &&
local.theirCallsign.trim().equals(remote.theirCallsign.trim(), true) &&
(local.myCallsign.isBlank() || remote.myCallsign.isBlank() || local.myCallsign.equals(remote.myCallsign, true)) &&
local.isSatellite == remote.isSatellite &&
local.matchableMode == remote.matchableMode &&
abs(local.startUtcMillis - remote.startUtcMillis) < 60_000L
}
/** Bands agree when both sides name one and they are the same; a missing band is not a mismatch. */
private fun sameBandOrBlank(one: String, other: String): Boolean =
one.isBlank() || other.isBlank() || one.equals(other, true)
/** LoTW omits frequency and may round time to a minute. Only merge an unambiguous contact. */
fun sameConfirmedContact(local: QsoRecord, remote: QsoRecord): Boolean =
local.theirCallsign.trim().equals(remote.theirCallsign.trim(), true) &&
(local.myCallsign.isBlank() || remote.myCallsign.isBlank() || local.myCallsign.equals(remote.myCallsign, true)) &&
satMatchKey(local.satelliteName) == satMatchKey(remote.satelliteName) &&
local.isSatellite == remote.isSatellite &&
local.displayMode == remote.displayMode &&
(local.band.isBlank() || remote.band.isBlank() || local.band.equals(remote.band, true)) &&
abs(local.startUtcMillis - remote.startUtcMillis) < 60_000L
sameContactIdentity(local, remote) && sameBandOrBlank(local.band, remote.band)
fun QsoRecord.confirmationLookupKey(): String = listOf(
theirCallsign.trim().uppercase(Locale.US), satMatchKey(satelliteName), displayMode
theirCallsign.trim().uppercase(Locale.US), satelliteIdentity(satelliteName), matchableMode
).joinToString("|")
/** 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)
/**
* Rows that should have been one row but were not, so they can be consolidated back into one.
*
* Two known holes leave a contact stored twice:
*
* 1. Confirmations merged before the identity fix landed as their own rows: the tracker name
* vs ARRL name comparison failed, and the band direction of the report was read mirrored
* (BAND_RX taken as the uplink) — so an unconfirmed local row and its confirmation stayed
* 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> {
val taken = mutableSetOf<Int>()
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 localIndex = pendingIndices.firstOrNull { index ->
index !in taken && sameContactIdentity(records[index], confirmation) &&
bandsMatchOrMirror(records[index], confirmation)
} ?: return@forEach
taken += localIndex
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. */
fun bandsMatchOrMirror(local: QsoRecord, confirmation: QsoRecord): Boolean =
(sameBandOrBlank(local.band, confirmation.band) && sameBandOrBlank(local.rxBand, confirmation.rxBand)) ||
(sameBandOrBlank(local.band, confirmation.rxBand) && sameBandOrBlank(local.rxBand, confirmation.band))
fun QsoRecord.withConfirmation(confirmed: QsoRecord): QsoRecord = copy(
myCallsign = myCallsign.ifBlank { confirmed.myCallsign },
theirGrid = confirmed.theirGrid.ifBlank { theirGrid },
theirVuccGrids = confirmed.theirVuccGrids.ifEmpty { theirVuccGrids },
myGrid = myGrid.ifBlank { confirmed.myGrid },
sentReport = sentReport.ifBlank { confirmed.sentReport },
receivedReport = receivedReport.ifBlank { confirmed.receivedReport },
@@ -51,6 +214,12 @@ val QsoRecord.displayMode: String
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
get() = propagationMode.equals("SAT", true) || (propagationMode.isBlank() && satelliteName.isNotBlank())
@@ -18,6 +18,10 @@ data class QsoRecord(
val theirCallsign: String,
val myCallsign: 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 sentReport: String = "",
val receivedReport: String = "",
@@ -36,6 +40,8 @@ data class QsoRecord(
val propagationMode: String = if (satelliteName.isNotBlank()) "SAT" else "",
val lotwConfirmed: Boolean = false,
val lotwUploaded: Boolean = false,
/** Internal: set after a successful Wavelog upload; keeps the pending filter small. Not shown in any list. */
val wavelogUploaded: Boolean = false,
val lotwReceived: Boolean = false,
val lotwQslDate: String = "",
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"
@@ -1,3 +1,21 @@
/*
* 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.model
/** One public AMSAT satellite status report submission. */
@@ -26,9 +26,12 @@ package com.rtbishop.look4sat.core.domain.model
* @param epochMs QSO date+time in UTC milliseconds
* @param satName satellite name (e.g. "FO-29")
* @param mode ADIF mode (FM / CW / SSB ...)
* @param bandUp uplink band as reported by LoTW (BAND_RX: "70CM", "2M", "10M"...)
* @param bandDown downlink band (BAND: "2M", "70CM"...) — may be empty when
* LoTW did not include it
* @param bandUp uplink band, the band of the transmitted frequency (ADIF BAND:
* "2M", "70CM", "10M"...). Measured against the live report of the
* user's own uploads: SO-50 arrives as BAND=2M/FREQ=145.85 with
* BAND_RX=70CM/FREQ_RX=436.8, i.e. BAND carries the uplink.
* @param bandDown downlink band (ADIF BAND_RX) — may be empty when LoTW did not
* include it
* @param dxcc ARRL DXCC entity code of the opposite station (from ADIF
* <DXCC>), or null when LoTW omitted it
* @param country DXCC entity name (ADIF <COUNTRY>), or null
@@ -69,7 +72,13 @@ data class GridQso(
* MY_STATE + MY_CQ_ZONE + MY_ITU_ZONE + MY_IOTA + MY_COUNTRY), so the
* operated-grid selector can group QSOs per 台址 and show each 台址 with
* its full grid set. Null for data synced before this field existed. */
val stationKey: String? = null
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. */
val bandLabel: String
@@ -95,3 +104,40 @@ data class GridQso(
else -> band.trim().uppercase()
}
}
/**
* Every MY-side grid of this QSO: [myGrids] (MY_GRIDSQUARE + MY_VUCC_GRIDS)
* with a single-grid fallback to [myGrid] for records synced before
* multi-grid support. Empty when the record carries no MY-side grid at all.
*/
fun GridQso.myStationGrids(): Set<String> =
myGrids.ifEmpty { myGrid?.let { setOf(it) }.orEmpty() }
/**
* Station-location (台址) grouping key: the QSO's MY grid set, sorted and
* comma-joined ("OL62", "OM60,PM01"). Records covering the same grid set
* belong to the same 台址 — they are VUCC-equivalent. Null when the record
* carries no MY-side grid. Backs both the map's operated-grid selector and
* its per-台址 grid-detail filtering, so the two scope by one identity.
*/
fun GridQso.stationGridSetKey(): String? =
myStationGrids().takeIf { it.isNotEmpty() }?.sorted()?.joinToString(",")
/**
* Scopes a per-grid QSO list to the operated-grid selector's choice:
* [stationId] == null ("All") keeps the list as-is, otherwise only records
* whose [stationGridSetKey] matches stay. Records without a MY-side grid
* belong to no 台址 and are dropped under any specific scope.
*/
fun List<GridQso>.scopedToStation(stationId: String?): List<GridQso> =
if (stationId == null) this else filter { it.stationGridSetKey() == stationId }
/**
* For every grid in the store, the callsign of the earliest QSO — scoped to the
* operated-grid selector's choice first, so a specific 台址 labels each worked
* cell with ITS first contact; null ("All") keeps the global first call.
*/
fun Map<String, List<GridQso>>.firstCallsByGrid(stationId: String?): Map<String, String> =
mapNotNull { (grid, qsos) ->
qsos.scopedToStation(stationId).minByOrNull { it.epochMs }?.let { grid to it.call }
}.toMap()
@@ -1,3 +1,21 @@
/*
* 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.model
/** Latest release info fetched from the fork's GitHub releases API. */
@@ -1,3 +1,21 @@
/*
* 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.model
/** One satellite status report (AMSAT site tooltip data) */
@@ -109,6 +109,19 @@ data class WavelogSettings(
val isConfigured: Boolean get() = url.isNotBlank() && token.isNotBlank()
}
/** Wavelog QSO-upload configuration (a separate block from the worked-grids sync). */
data class WavelogUploadSettings(
val url: String = "",
val apiKey: String = "",
val stationId: String = "",
val stationName: String = "",
val stationCallsign: String = "",
val stationGrid: String = ""
) {
val isConfigured: Boolean get() = url.isNotBlank() && apiKey.isNotBlank()
val isReady: Boolean get() = isConfigured && stationId.isNotBlank()
}
data class LoTWSettings(
val callsign: String = "",
val password: String = ""
@@ -37,30 +37,42 @@ data class OrbitalData(
val xno: Double = meanmo * TWO_PI / MIN_PER_DAY
val orbitalPeriod: Double = MIN_PER_DAY / meanmo
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)
/** Check if satellite has likely decayed by the given time. */
fun hasDecayed(currentTimeMillis: Long): Boolean {
if (ndot == 0.0) return false
val currentDaynum = (currentTimeMillis - 315446400000L) / 86400000.0
val epochDaynum = epochToDaynum(epoch)
return CelestialComputer.hasDecayed(meanmo, ndot, epochDaynum, currentDaynum)
return CelestialComputer.hasDecayed(meanmo, ndot, epochDaynum, timeToDaynum(currentTimeMillis))
}
private fun epochToDaynum(epoch: Double): Double {
var year = kotlin.math.floor(epoch * 1E-3)
val day = (epoch * 1E-3 - year) * 1000.0
year = if (year < 57) year + 2000 else year + 1900
// daynum = days since 31 Dec 1979, Julian date of 31Dec79 = 2444238.5
val jan1Jd = julianDateOfYear(year)
return jan1Jd + day - 2444238.5
}
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
private fun julianDateOfYear(theYear: Double): Double {
val aYear = theYear - 1
val a = kotlin.math.floor(aYear / 100).toLong()
val b = 2 - a + a / 4
val i = kotlin.math.floor(365.25 * aYear).toLong()
return i + (30.6001 * 14).toLong() + 1720994.5 + b
/**
* 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)
val day = (epoch * 1E-3 - year) * 1000.0
year = if (year < 57) year + 2000 else year + 1900
// daynum = days since 31 Dec 1979, Julian date of 31Dec79 = 2444238.5
val jan1Jd = julianDateOfYear(year)
return jan1Jd + day - 2444238.5
}
private fun julianDateOfYear(theYear: Double): Double {
val aYear = theYear - 1
val a = kotlin.math.floor(aYear / 100).toLong()
val b = 2 - a + a / 4
val i = kotlin.math.floor(365.25 * aYear).toLong()
return i + (30.6001 * 14).toLong() + 1720994.5 + b
}
}
}
@@ -1,3 +1,21 @@
/*
* 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.model.AmSatReportSubmission
@@ -1,6 +1,25 @@
/*
* 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 java.util.Locale
interface ILoTWUploadRepository {
suspend fun certificate(): LoTWCertificate?
@@ -11,6 +30,9 @@ interface ILoTWUploadRepository {
suspend fun removeCertificate()
/** Region field (State/Province/Prefecture…) and national CQZ/ITUZ map for a DXCC entity. */
suspend fun stationMeta(dxcc: Int): LoTWStationMeta
/** Every satellite name ARRL accepts (config.tq6), sorted. Only these names may be
* signed, so the logbook UI validates/picks satellite names from this list. */
suspend fun satelliteCatalog(): List<String>
suspend fun audit(records: List<QsoRecord>): LoTWUploadAudit
suspend fun prepare(records: List<QsoRecord>, resubmit: Boolean): LoTWUploadPreview
suspend fun upload(previewId: String): LoTWUploadResult
@@ -66,16 +88,49 @@ data class LoTWUploadPreview(
val firstUtc: String,
val lastUtc: String,
val contacts: List<String>,
val unknownSkipped: Int = 0
val unknownSkipped: Int = 0,
/** Un-signable records (invalid call/date/…) skipped instead of aborting the batch. */
val unavailableSkipped: Int = 0,
/** Why those records were un-signable, so the operator sees the real cause. */
val unavailableReasons: Map<LoTWProblem, Int> = emptyMap(),
/** Records dropped because the same contact already appears earlier in the batch. */
val duplicateSkipped: Int = 0,
/** 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. */
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
)
/** Local comparison against downloaded LoTW receipt flags and this app's durable upload receipts. */
/** 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. */
data class LoTWUploadAudit(
val total: Int,
val pending: Int,
val uploaded: Int,
val unknown: Int,
val unavailable: Int
val unavailable: Int,
/** Signing failures counted per reason (see [com.rtbishop.look4sat.core.domain.logbook.label]). */
val reasons: Map<LoTWProblem, Int> = emptyMap(),
/** First offending value per reason, e.g. SATELLITE -> "SAUDISAT 1C". */
val details: Map<LoTWProblem, String> = emptyMap(),
/** Records dropped as duplicates of an earlier record in the same selection. */
val duplicates: Int = 0,
/** Records that are not marked complete, so they are not uploadable at all. */
val incomplete: Int = 0
)
sealed interface LoTWUploadResult {
@@ -90,7 +145,25 @@ class LoTWOperationException(val reason: LoTWProblem, val detail: String = "") :
enum class LoTWProblem {
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,
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 }
@@ -0,0 +1,65 @@
/*
* 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 kotlinx.coroutines.flow.StateFlow
/**
* A single position fix from the device's location provider, with the
* horizontal accuracy reported by the platform.
*/
data class LocationFix(
val latitude: Double,
val longitude: Double,
val altitudeMeters: Double = 0.0,
val accuracyMeters: Float,
val epochMs: Long
) {
/** ARRL VUCC work needs a fix good to the 20 ft (6.1 m) rule. */
fun isPreciseEnough(limitMeters: Float): Boolean = accuracyMeters in 0f..limitMeters
}
/** What the live-location source is currently able to give. */
sealed interface LocationState {
/** No fix yet - either permission is missing or the provider is warming up. */
data class Waiting(val permissionGranted: Boolean, val providerEnabled: Boolean) : LocationState
data class Fix(val location: LocationFix) : LocationState
}
/**
* Live device position for field tools (currently the Grid Finder). Kept
* separate from [ISettingsRepo.stationPosition] on purpose: collecting fixes
* here must never move the user's station, which is what drives every pass
* prediction in the app.
*
* Callers register with [startUpdates] while their screen is alive and MUST
* call [stopUpdates] when it goes away.
*/
interface ILocationRepo {
val state: StateFlow<LocationState>
/** Whether the app currently holds the fine-location permission. */
fun hasPermission(): Boolean
/** Begins delivering fixes; no-op when permission is missing. */
fun startUpdates()
/** Stops delivering fixes and releases the platform listener. */
fun stopUpdates()
}
@@ -34,12 +34,17 @@ interface IMainContainer {
val amSatRepo: IAmSatRepository
val updateRepo: IUpdateRepository
val wavelogRepo: IWavelogRepository
val wavelogUploadRepository: IWavelogUploadRepository
val lotwRepo: com.rtbishop.look4sat.core.domain.repository.ILoTWRepository
val qsoRepository: IQsoRepository
val lotwUploadRepository: ILoTWUploadRepository
val radioTrackingService: IRadioTrackingService
val locationRepo: ILocationRepo
val mutualPassData: StateFlow<MutualPassData>
/** Grid Finder → LoTW station page prefill, consumed once by that page. */
val pendingLoTWStationGrid: StateFlow<String?>
fun setMutualPassData(data: MutualPassData)
fun setPendingLoTWStationGrid(grid: String?)
fun provideAddToCalendar(): IAddToCalendar
fun provideShowToast(): IShowToast
fun provideBluetoothReporter(): IReporter
@@ -24,6 +24,7 @@ import com.rtbishop.look4sat.core.domain.model.PassesSettings
import com.rtbishop.look4sat.core.domain.model.RCSettings
import com.rtbishop.look4sat.core.domain.model.RadioControlSettings
import com.rtbishop.look4sat.core.domain.model.WavelogSettings
import com.rtbishop.look4sat.core.domain.model.WavelogUploadSettings
import com.rtbishop.look4sat.core.domain.predict.GeoPos
import kotlinx.coroutines.flow.StateFlow
@@ -48,6 +49,12 @@ interface ISettingsRepo {
fun setStationPosition(latitude: Double, longitude: Double, altitude: Double): Boolean
fun setStationPosition(): 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
//region # Database update settings
@@ -118,6 +125,11 @@ interface ISettingsRepo {
fun setMarkedGridStations(stations: Map<String, List<com.rtbishop.look4sat.core.domain.model.MarkedStation>>)
//endregion
//region # Wavelog upload settings
val wavelogUploadSettings: StateFlow<WavelogUploadSettings>
fun updateWavelogUploadSettings(settings: WavelogUploadSettings)
//endregion
//region # LoTW confirmed-grids settings
val lotwSettings: StateFlow<com.rtbishop.look4sat.core.domain.model.LoTWSettings>
fun updateLoTWSettings(settings: com.rtbishop.look4sat.core.domain.model.LoTWSettings)
@@ -1,3 +1,21 @@
/*
* 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.model.LatestRelease
@@ -0,0 +1,79 @@
/*
* Look4Sat-BA7OPF. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2026 BA7OPF.
* Based on Look4Sat by Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.core.domain.repository
import com.rtbishop.look4sat.core.domain.logbook.QsoRecord
import com.rtbishop.look4sat.core.domain.model.WavelogUploadSettings
/** One station profile as returned by the v1 station_info endpoint. */
data class WavelogStationInfo(
val id: String,
val name: String,
val callsign: String,
val grid: String,
val active: Boolean
)
/** Result of probing a Wavelog server (check_auth + station_info). */
data class WavelogProbe(val rights: String, val stations: List<WavelogStationInfo>)
/** Why a record (or the whole upload) cannot be submitted. */
enum class WavelogProblem { NOT_CONFIGURED, NO_STATION, GRID_MISMATCH }
/** Records skipped before the POST (with reasons), plus the records that will be submitted. */
data class WavelogUploadPreview(
val count: Int,
val skipped: Int,
val reasons: Map<WavelogProblem, Int>,
val stationLabel: String,
val firstUtc: String,
val lastUtc: String,
val contacts: List<String>,
val submittedIds: List<Long>,
val blockedBy: WavelogProblem? = null
)
sealed interface WavelogUploadOutcome {
/** [markIds] can be smaller than the submission when Wavelog reported skipped records. */
data class Imported(
val imported: Int,
val duplicates: Int,
val skipped: Int,
val markIds: List<Long>
) : WavelogUploadOutcome
data class Rejected(val message: String) : WavelogUploadOutcome
data class Failed(val message: String) : WavelogUploadOutcome
data object Expired : WavelogUploadOutcome
}
/**
* Uploads local QSO records to a self-hosted Wavelog via the v1 API
* (`POST {base}/index.php/api/qso` with a batch ADIF string).
*/
interface IWavelogUploadRepository {
/** check_auth + station_info; null on any transport/parse failure. */
suspend fun probe(url: String, apiKey: String): WavelogProbe?
/** Filters + encodes the batch and keeps a one-shot pending payload for [upload]. */
suspend fun prepare(records: List<QsoRecord>, settings: WavelogUploadSettings): WavelogUploadPreview
/** POSTs the prepared batch and classifies Wavelog's response. */
suspend fun upload(preview: WavelogUploadPreview, settings: WavelogUploadSettings): WavelogUploadOutcome
}
@@ -26,6 +26,10 @@ interface ILocalSource {
suspend fun getEntriesTotal(): Int
suspend fun getEntriesList(): List<SatItem>
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 deleteEntries()
suspend fun getIdsWithModes(modes: List<String>): List<Int>
@@ -18,41 +18,32 @@
package com.rtbishop.look4sat.core.domain.source
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(
"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",
"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",
"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",
"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
)
// 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(
"SatNOGS" to "https://db.satnogs.org/api/transmitters/?format=json&status=active",
"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
* types in the picker. */
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"
}
@@ -0,0 +1,193 @@
/*
* 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/>.
*
* Contains code ported from OrbitDeckiOS (https://github.com/prstoetzer/OrbitDeckiOS),
* Copyright (c) 2025 Paul Stoetzer, N8HM, licensed under the MIT License.
* See THIRD_PARTY_NOTICES.md for the full MIT license text.
*/
package com.rtbishop.look4sat.core.domain.utility
import kotlin.math.abs
import kotlin.math.atan2
import kotlin.math.cos
import kotlin.math.hypot
import kotlin.math.round
/**
* Grid-line / grid-corner geometry for the 4-character Maidenhead squares that
* VUCC credits, used by the Grid Finder field tool.
*
* A 4-character square is 1 degree tall (boundaries at integer latitudes) and
* 2 degrees wide (boundaries at even offsets from the -180 degree antimeridian),
* so a station's distance to the nearest VUCC line and corner is a matter of
* simple planar geometry at the station's latitude.
*
* Geometry ported from OrbitDeck for iOS (MIT License, (c) 2026 Paul Stoetzer,
* N8HM) - `GridGeometry` in `Views/HomeView.swift` and `vuccGrids` in
* `Engine/FeatureEngine.swift`. Golden values are pinned by GridGeometryTest.
*/
/** ARRL VUCC "20 ft rule": a fix within this distance of a boundary counts as on it. */
const val VUCC_BOUNDARY_TOLERANCE_METERS = 20.0 * 0.3048
/** One degree of latitude in metres; 4-character squares are 1 degree tall. */
private const val METERS_PER_DEGREE_LATITUDE = 111_320.0
/** Nudge used to name the square on the far side of a boundary (display only). */
private const val NEIGHBOUR_NUDGE_DEGREES = 0.0005
/** Nudge used by [vuccClaimableGrids] so the neighbour lands squarely across. */
private const val CLAIM_NEIGHBOUR_NUDGE_DEGREES = 0.001
private const val DEG_TO_RAD = 0.017453292519943295
private const val RAD_TO_DEG = 57.29577951308232
/** Where the nearest 4-character boundary lies relative to the fix. */
enum class GridBoundaryDirection { NORTH, SOUTH, EAST, WEST }
/** ARRL VUCC standing of a fix, derived from how many squares it may claim. */
enum class GridFixStatus { INSIDE_GRID, ON_GRID_LINE, ON_GRID_CORNER }
/**
* Distance and bearing to the nearest 4-character grid corner and grid line,
* plus the locators of the squares that meet there. All distances are metres
* computed on a flat approximation local to the fix (the same approximation
* the source implementation uses; over the <=100 km distances involved the
* error is far below a GPS fix's own accuracy).
*/
data class GridGeometry(
val grid4: String,
val grid6: String,
val grid8: String,
val latLineMeters: Double,
val latLineDirection: GridBoundaryDirection,
val lonLineMeters: Double,
val lonLineDirection: GridBoundaryDirection,
val cornerMeters: Double,
val cornerBearingDegrees: Double,
val cornerGrids: List<String>,
val nearestLineMeters: Double,
val nearestLineBearingDegrees: Double,
val nearestLineIsLatitude: Boolean,
val nearestLineGrids: List<String>
)
/**
* The geometry of the VUCC (4-character) grid around a position, or null when
* the position is out of range.
*/
fun gridGeometry(latitude: Double, longitude: Double): GridGeometry? {
val grid4 = positionToGrid4(latitude, longitude) ?: return null
val grid6 = positionToQth(latitude, longitude) ?: return null
val grid8 = positionToGrid8(latitude, longitude) ?: return null
// 4-character boundaries: integer latitudes, even-degree longitudes.
val latBoundary = round(latitude)
val lonBoundary = round((longitude + 180.0) / 2.0) * 2.0 - 180.0
val metersPerDegreeLon = METERS_PER_DEGREE_LATITUDE * cos(latitude * DEG_TO_RAD)
val dNorth = (latBoundary - latitude) * METERS_PER_DEGREE_LATITUDE
val dEast = (lonBoundary - longitude) * metersPerDegreeLon
val otherLat = latBoundary - if (latitude >= latBoundary) NEIGHBOUR_NUDGE_DEGREES else -NEIGHBOUR_NUDGE_DEGREES
val otherLon = lonBoundary - if (longitude >= lonBoundary) NEIGHBOUR_NUDGE_DEGREES else -NEIGHBOUR_NUDGE_DEGREES
val cornerGrids = setOfNotNull(
positionToGrid4(latitude, longitude),
positionToGrid4(otherLat, longitude),
positionToGrid4(latitude, otherLon),
positionToGrid4(otherLat, otherLon)
).sorted()
val onLatitude = abs(dNorth) <= abs(dEast)
val nearestLineGrids = if (onLatitude) {
setOfNotNull(grid4, positionToGrid4(otherLat, longitude)).sorted()
} else {
setOfNotNull(grid4, positionToGrid4(latitude, otherLon)).sorted()
}
return GridGeometry(
grid4 = grid4,
grid6 = grid6,
grid8 = grid8,
latLineMeters = abs(dNorth),
latLineDirection = if (dNorth >= 0) GridBoundaryDirection.NORTH else GridBoundaryDirection.SOUTH,
lonLineMeters = abs(dEast),
lonLineDirection = if (dEast >= 0) GridBoundaryDirection.EAST else GridBoundaryDirection.WEST,
cornerMeters = hypot(dNorth, dEast),
cornerBearingDegrees = bearingDegrees(dNorth, dEast),
cornerGrids = cornerGrids,
nearestLineMeters = if (onLatitude) abs(dNorth) else abs(dEast),
nearestLineBearingDegrees = if (onLatitude) {
if (dNorth >= 0) 0.0 else 180.0
} else {
if (dEast >= 0) 90.0 else 270.0
},
nearestLineIsLatitude = onLatitude,
nearestLineGrids = nearestLineGrids
)
}
/**
* The 4-character squares a station at this position may claim under ARRL VUCC
* rules: normally one, but two when the fix is on the line between two squares
* and four when it is on the corner where four squares meet. A fix within
* [toleranceMeters] of a boundary is considered to be on it (the 20 ft rule).
*/
fun vuccClaimableGrids(
latitude: Double,
longitude: Double,
toleranceMeters: Double = VUCC_BOUNDARY_TOLERANCE_METERS
): List<String> {
val own = positionToGrid4(latitude, longitude) ?: return emptyList()
val grids = mutableSetOf(own)
val latBoundary = round(latitude)
val lonBoundary = round((longitude + 180.0) / 2.0) * 2.0 - 180.0
val metersPerDegreeLon = METERS_PER_DEGREE_LATITUDE * cos(latitude * DEG_TO_RAD)
val onLatitude = abs(latitude - latBoundary) * METERS_PER_DEGREE_LATITUDE <= toleranceMeters
val onLongitude = abs(longitude - lonBoundary) * metersPerDegreeLon <= toleranceMeters
if (!onLatitude && !onLongitude) return grids.sorted()
// Step just past the boundary so the neighbour's locator is unambiguous.
val otherLat = latBoundary - if (latitude >= latBoundary) CLAIM_NEIGHBOUR_NUDGE_DEGREES else -CLAIM_NEIGHBOUR_NUDGE_DEGREES
val otherLon = lonBoundary - if (longitude >= lonBoundary) CLAIM_NEIGHBOUR_NUDGE_DEGREES else -CLAIM_NEIGHBOUR_NUDGE_DEGREES
if (onLatitude) {
positionToGrid4(otherLat, longitude)?.let(grids::add)
}
if (onLongitude) {
positionToGrid4(latitude, otherLon)?.let(grids::add)
}
if (onLatitude && onLongitude) {
positionToGrid4(otherLat, otherLon)?.let(grids::add)
}
return grids.sorted()
}
/** VUCC standing implied by the number of squares a fix may claim. */
fun gridFixStatus(claimableGrids: List<String>): GridFixStatus = when {
claimableGrids.size >= 4 -> GridFixStatus.ON_GRID_CORNER
claimableGrids.size >= 2 -> GridFixStatus.ON_GRID_LINE
else -> GridFixStatus.INSIDE_GRID
}
private fun bearingDegrees(dNorth: Double, dEast: Double): Double {
val bearing = atan2(dEast, dNorth) * RAD_TO_DEG
return if (bearing < 0.0) bearing + 360.0 else bearing
}
@@ -69,6 +69,45 @@ fun positionToQth(latitude: Double, longitude: Double): String? {
return "$lonFirst$latFirst$lonSecond$latSecond$lonThird$latThird"
}
/**
* Convert a position to its 4-char Maidenhead locator (field + square), the
* square VUCC credits, upper-cased. Returns null outside the Maidenhead grid
* (lat >= 90 is out of range: the top row is 80..90 N). The longitude is
* wrapped into [-180, 180) first, so fixes either side of the antimeridian
* resolve to the square they are actually in.
*/
fun positionToGrid4(latitude: Double, longitude: Double): String? {
if (latitude < -90.0 || latitude >= 90.0) return null
val normalizedLon = normalizeLongitude(longitude)
val fieldLat = ((latitude + 90.0) / 10.0).toInt().coerceIn(0, 17)
val fieldLon = ((normalizedLon + 180.0) / 20.0).toInt().coerceIn(0, 17)
val subLat = ((latitude + 90.0) % 10.0).toInt()
val subLon = ((normalizedLon + 180.0) % 20.0 / 2.0).toInt()
return "${'A' + fieldLon}${'A' + fieldLat}$subLon$subLat"
}
/**
* Convert a position to its 8-char Maidenhead locator: the 6-char locator plus
* the numeric extended-square pair (the Maidenhead maximum). Returns null for
* out-of-range positions.
*/
fun positionToGrid8(latitude: Double, longitude: Double): String? {
val normalizedLon = normalizeLongitude(longitude)
val base = positionToQth(latitude, normalizedLon) ?: return null
val latitudeOffset = latitude.coerceIn(-90.0, 89.999999) + 90.0
val longitudeOffset = normalizedLon.coerceIn(-180.0, 179.999999) + 180.0
val subLon = 2.0 / 24.0
val subLat = 1.0 / 24.0
val lonDigit = minOf(9, ((longitudeOffset % subLon) / subLon * 10).toInt())
val latDigit = minOf(9, ((latitudeOffset % subLat) / subLat * 10).toInt())
return "$base$lonDigit$latDigit"
}
/** Wrap any longitude into [-180, 180). */
private fun normalizeLongitude(longitude: Double): Double {
return ((longitude + 180.0) % 360.0 + 360.0) % 360.0 - 180.0
}
private fun isValidPosition(lat: Double, lon: Double): Boolean {
return (lat >= -90.0 && lat <= 90.0) && (lon >= -180.0 && lon <= 360.0)
}
@@ -1,3 +1,21 @@
/*
* 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.utility
/**
@@ -0,0 +1,53 @@
/*
* 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.utility
import com.rtbishop.look4sat.core.domain.model.SatRadio
import kotlin.math.abs
/** Nominal uplink frequency: the middle of the passband when one is published. */
fun SatRadio.uplinkHz(): Long? = uplinkLow?.let { low -> uplinkHigh?.let { high -> (low + high) / 2 } ?: low }
/** Nominal downlink frequency: the middle of the passband when one is published. */
fun SatRadio.downlinkHz(): Long? = downlinkLow?.let { low -> downlinkHigh?.let { high -> (low + high) / 2 } ?: low }
/**
* The FM voice repeater of a satellite, if it has one.
*
* A voice repeater is the single-frequency FM transceiver the operator actually
* talks through (SatNOGS type "Transceiver", e.g. SO-50 "Mode V/U FM Voice
* CTCSS 67.0 Hz"). Beacons and telemetry carry no uplink, so requiring BOTH an
* uplink and a downlink already filters them out; the FM mode check removes
* AFSK/APRS and linear entries.
*
* Ranking when several FM duplex entries exist (ISS is the awkward case, it also
* lists crew V/V channels):
* 1. entries described as a repeater / voice channel,
* 2. amateur service entries,
* 3. cross-band pairs (a V/U repeater over a same-band V/V downlink).
*/
fun List<SatRadio>.voiceRepeater(): SatRadio? = asSequence()
.filter { it.isAlive }
.filter { it.uplinkHz() != null && it.downlinkHz() != null }
.filter { it.uplinkMode.equals("FM", true) || it.downlinkMode.equals("FM", true) }
.sortedWith(
compareByDescending<SatRadio> { it.info.contains("repeater", true) || it.info.contains("voice", true) }
.thenByDescending { it.service.equals("Amateur", true) }
.thenByDescending { abs((it.downlinkHz() ?: 0L) - (it.uplinkHz() ?: 0L)) }
)
.firstOrNull()
@@ -1,3 +1,21 @@
/*
* 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
import com.rtbishop.look4sat.core.domain.model.SatRadio
@@ -0,0 +1,78 @@
/*
* 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
import com.rtbishop.look4sat.core.domain.logbook.LoTWSatelliteAliases
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
/** Alias table that maps tracker catalogue names onto the names ARRL's config.tq6 uses. */
class LoTWSatelliteAliasesTest {
private val catalogue = listOf("SO-50", "ARISS", "PO-101", "BO-102", "AO-91", "RS-44", "IO-117")
@Test
fun `lookup maps SatNOGS catalogue names to ARRL names`() {
assertEquals("SO-50", LoTWSatelliteAliases.lookup("SAUDISAT 1C"))
assertEquals("ARISS", LoTWSatelliteAliases.lookup("ISS (ZARYA)"))
assertEquals("PO-101", LoTWSatelliteAliases.lookup("DIWATA 2B"))
assertEquals("AO-91", LoTWSatelliteAliases.lookup("FOX-1B"))
assertEquals("RS-44", LoTWSatelliteAliases.lookup("DOSAAF-85"))
assertEquals("IO-117", LoTWSatelliteAliases.lookup("GREENCUBE"))
}
@Test
fun `normalize strips separators and case`() {
assertEquals("ISSZARYA", LoTWSatelliteAliases.normalize("iss (zarya)"))
assertEquals("SAUDISAT1C", LoTWSatelliteAliases.normalize("SaudiSat-1C"))
}
@Test
fun `lookup ignores unknown and placeholder names`() {
assertNull(LoTWSatelliteAliases.lookup("NOT A SATELLITE"))
// Recycled temporary designations must never resolve: they would sign the wrong object.
assertNull(LoTWSatelliteAliases.lookup("OBJECT AY"))
}
@Test
fun `resolve accepts ARRL names, aliases and suffixed tracker names`() {
assertEquals("SO-50", LoTWSatelliteAliases.resolve("SO-50", catalogue))
assertEquals("SO-50", LoTWSatelliteAliases.resolve("saudisat 1c", catalogue))
assertEquals("SO-50", LoTWSatelliteAliases.resolve("SAUDISAT 1C (SO-50)", catalogue))
assertEquals("ARISS", LoTWSatelliteAliases.resolve("ISS (ZARYA)", catalogue))
assertEquals("AO-91", LoTWSatelliteAliases.resolve("AO-91 (RadFxSat)", catalogue))
}
@Test
fun `resolve rejects names that are not ARRL satellites`() {
assertNull(LoTWSatelliteAliases.resolve("", catalogue))
assertNull(LoTWSatelliteAliases.resolve("MARINA", catalogue))
assertNull(LoTWSatelliteAliases.resolve("OBJECT AY", catalogue))
}
@Test
fun `suggestions find the ARRL name from a tracker spelling`() {
assertEquals(listOf("SO-50"), LoTWSatelliteAliases.suggestions("saudisat 1c", catalogue))
assertTrue(LoTWSatelliteAliases.suggestions("ao9", catalogue).contains("AO-91"))
assertEquals(listOf("PO-101"), LoTWSatelliteAliases.suggestions("diwata", catalogue))
assertTrue(LoTWSatelliteAliases.suggestions("", catalogue).isEmpty())
}
}
@@ -1,3 +1,21 @@
/*
* 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
import com.rtbishop.look4sat.core.domain.utility.positionToQth
@@ -1,3 +1,21 @@
/*
* 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
import com.rtbishop.look4sat.core.domain.model.SatRadio
@@ -1,3 +1,21 @@
/*
* 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
import com.rtbishop.look4sat.core.domain.utility.VersionComparator
@@ -0,0 +1,140 @@
/*
* 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
import com.rtbishop.look4sat.core.domain.model.SatRadio
import com.rtbishop.look4sat.core.domain.utility.downlinkHz
import com.rtbishop.look4sat.core.domain.utility.uplinkHz
import com.rtbishop.look4sat.core.domain.utility.voiceRepeater
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
/** Fixtures are the real SatNOGS transceiver rows of the three hardcoded AMSAT Live FM
* satellites (fetched 2026-09-27), so the selection rules are tested against live data. */
class VoiceRepeaterTest {
private fun radio(
uuid: String,
info: String,
up: Long? = null,
upHigh: Long? = null,
down: Long? = null,
downHigh: Long? = null,
upMode: String? = null,
downMode: String? = null,
alive: Boolean = true,
service: String? = null,
catnum: Int = 12345
) = SatRadio(
uuid = uuid, info = info, isAlive = alive,
downlinkLow = down, downlinkHigh = downHigh, downlinkMode = downMode,
uplinkLow = up, uplinkHigh = upHigh, uplinkMode = upMode,
isInverted = false, catnum = catnum, service = service
)
// SO-50 (27607)
private val so50Tlm = radio("so50-tlm", "Mode V TLM", down = 149_025_000L, downMode = "FM")
private val so50Voice = radio(
"so50-voice", "Mode V/U FM Voice CTCSS 67.0 Hz",
up = 145_850_000L, down = 436_795_000L, upMode = "FM", downMode = "FM", catnum = 27607
)
// ISS (25544): a voice repeater plus two crew V/V channels and an APRS transceiver
private val issCrew2 = radio(
"iss-crew2", "Mode V/V FM (crew R2+3)",
up = 144_490_000L, down = 145_800_000L, upMode = "FM", downMode = "FM", catnum = 25544
)
private val issCrew1 = radio(
"iss-crew1", "Mode V/V FM (crew R1)",
up = 145_200_000L, down = 145_800_000L, upMode = "FM", downMode = "FM", catnum = 25544
)
private val issVoice = radio(
"iss-voice", "Mode V/U FM - Voice Repeater CTCSS 67.0 Hz",
up = 145_990_000L, down = 437_800_000L, upMode = "FM", downMode = "FM",
service = "Amateur", catnum = 25544
)
private val issAprs = radio(
"iss-aprs", "Mode V APRS",
up = 145_825_000L, down = 145_825_000L, upMode = "AFSK", downMode = "AFSK", catnum = 25544
)
// AO-123 / ASRTU-1 (61781)
private val ao123Voice = radio(
"ao123-voice", "Mode V/U - FM Transceiver",
up = 145_850_000L, down = 435_400_000L, upMode = "FM", downMode = "FM",
service = "Amateur", catnum = 61781
)
@Test
fun voiceRepeater_picksSo50VoicePair() {
val repeater = listOf(so50Tlm, so50Voice).voiceRepeater()
assertEquals("so50-voice", repeater?.uuid)
assertEquals(145_850_000L, repeater?.uplinkHz())
assertEquals(436_795_000L, repeater?.downlinkHz())
}
@Test
fun voiceRepeater_prefersVoiceChannelOverCrewChannels() {
val transmitterList = listOf(issCrew2, issAprs, issVoice, issCrew1)
// Order must not matter - ranking decides.
assertEquals("iss-voice", transmitterList.voiceRepeater()?.uuid)
assertEquals("iss-voice", transmitterList.reversed().voiceRepeater()?.uuid)
}
@Test
fun voiceRepeater_picksAo123Transceiver() {
val repeater = listOf(ao123Voice).voiceRepeater()
assertEquals(145_850_000L, repeater?.uplinkHz())
assertEquals(435_400_000L, repeater?.downlinkHz())
}
@Test
fun voiceRepeater_ignoresBeaconsWithoutUplink() {
assertNull(listOf(so50Tlm).voiceRepeater())
}
@Test
fun voiceRepeater_ignoresDeadEntries() {
val dead = so50Voice.copy(uuid = "dead", isAlive = false)
assertNull(listOf(dead).voiceRepeater())
}
@Test
fun voiceRepeater_ignoresLinearTransponders() {
val linear = radio(
"lin", "Linear Transponder",
up = 145_900_000L, upHigh = 145_950_000L, down = 435_600_000L, downHigh = 435_650_000L,
upMode = "LSB", downMode = "USB"
)
assertNull(listOf(linear).voiceRepeater())
}
@Test
fun uplinkHz_usesPassbandMiddleWhenPresent() {
val wide = so50Voice.copy(uuid = "wide", uplinkLow = 145_840_000L, uplinkHigh = 145_860_000L)
assertEquals(145_850_000L, wide.uplinkHz())
assertEquals(145_850_000L, so50Voice.uplinkHz())
}
@Test
fun downlinkHz_isNullWhenNoDownlink() {
assertNull(so50Voice.copy(downlinkLow = null).downlinkHz())
}
}
@@ -83,4 +83,20 @@ class AdifCodecTest {
assertTrue(record.lotwConfirmed)
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"))
}
}
@@ -12,6 +12,7 @@ package com.rtbishop.look4sat.core.domain.logbook
import com.rtbishop.look4sat.core.domain.model.GridQso
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotEquals
import org.junit.Assert.assertTrue
import org.junit.Test
@@ -39,7 +40,8 @@ class LogbookMergeTest {
propagationMode = "SAT",
status = QsoStatus.COMPLETE,
lotwConfirmed = true,
vuccGrids = listOf("OM60", "OM50")
vuccGrids = listOf("OM60", "OM50"),
theirVuccGrids = listOf("EN52", "EN53")
)
@Test
@@ -72,12 +74,24 @@ class LogbookMergeTest {
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
fun withConfirmation_marksConfirmedAndMergesVuccGrids() {
val merged = local.withConfirmation(confirmed)
assertTrue(merged.lotwConfirmed)
assertTrue(merged.lotwReceived)
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.
assertEquals("ba7opf", merged.myCallsign)
}
@@ -96,13 +110,16 @@ class LogbookMergeTest {
cqz = 24,
state = "GD",
myGrid = "OM60",
myGrids = setOf("OM60", "OM50")
myGrids = setOf("OM60", "OM50"),
theirGrids = listOf("EN52", "EN53")
)
val record = qso.toConfirmedRecord("ba7opf")
assertEquals("BH6RJD", record.theirCallsign)
assertEquals("BA7OPF", record.myCallsign)
assertEquals("OM60", record.myGrid)
assertEquals(listOf("OM60", "OM50"), record.vuccGrids)
assertEquals("EN52", record.theirGrid)
assertEquals(listOf("EN52", "EN53"), record.theirVuccGrids)
assertTrue(record.lotwConfirmed)
assertTrue(record.isSatellite)
assertEquals("70CM", record.band)
@@ -133,4 +150,164 @@ class LogbookMergeTest {
assertEquals("FM", staleFm.displayMode)
assertEquals("CW", staleCw.displayMode)
}
// region logbook split (tracker name / band direction, 2026-09-28)
@Test
fun satelliteIdentity_mapsTrackerNamesOntoArlNames() {
// Local records keep the tracking-source name, LoTW reports the ARRL name; both
// must land on one identity or every confirmation becomes a second row.
assertEquals("SO-50", satelliteIdentity("SAUDISAT 1C"))
assertEquals("SO-50", satelliteIdentity("SO-50"))
assertEquals("ARISS", satelliteIdentity("ISS (ZARYA)"))
assertEquals("ARISS", satelliteIdentity("ARISS"))
assertEquals("PO-101", satelliteIdentity("DIWATA 2B"))
assertEquals("FO-29", satelliteIdentity("JAS 2"))
}
@Test
fun satelliteIdentity_isSymmetricForEveryAliasEntry() {
// Guards the whole table: an entry whose target does not resolve back to the same
// identity would split that satellite's contacts again.
LoTWSatelliteAliases.table.forEach { (tracker, official) ->
assertEquals(official, satelliteIdentity(tracker), satelliteIdentity(official))
}
}
@Test
fun satelliteIdentity_keepsRecycledPlaceholdersUnmatched() {
// "OBJECT xx" names are reused between objects and deliberately unmapped, so they
// must never resolve onto a real ARRL satellite.
assertEquals("OBJECT AY", satelliteIdentity("OBJECT AY"))
assertNotEquals(satelliteIdentity("AO-123"), satelliteIdentity("OBJECT AY"))
}
@Test
fun sameConfirmedContact_matchesTrackerNameAgainstArlName() {
// The reported bug: SO-50 logged in the app (tracker name, 145.850 uplink) never
// picked up the confirmation LoTW reported for it.
val uploaded = loggedRecord("SAUDISAT 1C", uplinkHz = 145_850_000L, downlinkHz = 436_795_000L)
val reported = reportedRecord("SO-50", band = "2M", rxBand = "70CM")
assertTrue(sameConfirmedContact(uploaded, reported))
}
@Test
fun sameConfirmedContact_stillRejectsOtherContactOrMinute() {
val uploaded = loggedRecord("SAUDISAT 1C", uplinkHz = 145_850_000L, downlinkHz = 436_795_000L)
val otherOperator = reportedRecord("SO-50", band = "2M", rxBand = "70CM").copy(theirCallsign = "BG5JVM")
val anotherMinute = reportedRecord("SO-50", band = "2M", rxBand = "70CM")
.copy(startUtcMillis = uploaded.startUtcMillis + 90_000L)
assertFalse(sameConfirmedContact(uploaded, otherOperator))
assertFalse(sameConfirmedContact(uploaded, anotherMinute))
}
@Test
fun splitConfirmationPairs_foldsRowsSplitByTheOldMismatch() {
// Both pre-fix symptoms in one pair: the imported row keeps the ARRL name and the
// mirrored band direction the old parser produced (band=downlink).
val uploaded = loggedRecord("SAUDISAT 1C", 145_850_000L, 436_795_000L)
.copy(id = 1L, lotwUploaded = true)
val imported = reportedRecord("SO-50", band = "70CM", rxBand = "2M").copy(id = 2L)
val pairs = splitConfirmationPairs(listOf(uploaded, imported))
assertEquals(1, pairs.size)
assertEquals(0, pairs.first().localIndex)
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
fun splitConfirmationPairs_keepsUnrelatedRowsApart() {
val uploaded = loggedRecord("SAUDISAT 1C", 145_850_000L, 436_795_000L)
.copy(id = 1L, lotwUploaded = true)
// Another operator on another satellite in the same minute must not be folded in.
val otherOperator = reportedRecord("SO-50", band = "70CM", rxBand = "2M")
.copy(id = 2L, theirCallsign = "BG5JVM")
// A contact on a satellite the local record does not name at all stays separate.
val otherSatellite = reportedRecord("AO-91", band = "70CM", rxBand = "2M").copy(id = 3L)
assertTrue(splitConfirmationPairs(listOf(uploaded, otherOperator, otherSatellite)).isEmpty())
}
/** A record as the log page creates it: tracker name, uplink/downlink bands, no flags. */
@Test
fun officialSatelliteName_storesTheArlName() {
assertEquals("SO-50", officialSatelliteName("SAUDISAT 1C"))
assertEquals("ARISS", officialSatelliteName("ISS (ZARYA)"))
assertEquals("PO-101", officialSatelliteName("DIWATA 2B"))
assertEquals("FO-29", officialSatelliteName("JAS 2"))
// Already-ARRL names are left alone (the rewrite is idempotent).
assertEquals("SO-50", officialSatelliteName("SO-50"))
assertEquals("AO-123", officialSatelliteName("AO-123"))
// Nothing to map onto: a satellite ARRL does not list, a recycled placeholder, a blank.
assertEquals("FOO-1", officialSatelliteName("FOO-1"))
assertEquals("OBJECT AY", officialSatelliteName("OBJECT AY"))
assertEquals("", officialSatelliteName(" "))
}
@Test
fun officialSatelliteName_usesTheCatalogueWhenItKnowsTheNameTheTrackerCarries() {
val catalogue = listOf("BO-102", "SO-50")
// "CAS-7B" resolves through the alias table, the description-only name through the catalogue.
assertEquals("BO-102", officialSatelliteName("CAS-7B", catalogue))
assertEquals("BO-102", officialSatelliteName("CAS-7B (BO-102)", catalogue))
assertEquals("SO-50", officialSatelliteName("SAUDISAT 1C", catalogue))
}
private fun loggedRecord(satelliteName: String, uplinkHz: Long, downlinkHz: Long) = QsoRecord(
startUtcMillis = REPORTED_START,
theirCallsign = "BG5JSB",
myCallsign = "BA7OPF",
txFrequencyHz = uplinkHz,
rxFrequencyHz = downlinkHz,
band = frequencyBand(uplinkHz),
rxBand = frequencyBand(downlinkHz),
mode = "FM",
satelliteName = satelliteName,
propagationMode = "SAT",
status = QsoStatus.COMPLETE,
lotwUploaded = true
)
/** A record as a LoTW confirmation carries it: ARRL name, report bands, confirmed. */
private fun reportedRecord(satelliteName: String, band: String, rxBand: String) = QsoRecord(
startUtcMillis = REPORTED_START,
theirCallsign = "BG5JSB",
myCallsign = "BA7OPF",
band = band,
rxBand = rxBand,
mode = "FM",
satelliteName = satelliteName,
propagationMode = "SAT",
status = QsoStatus.COMPLETE,
lotwConfirmed = true
)
private companion object {
/** 2026-09-16 07:45Z — an SO-50 pass of the user's real report. */
const val REPORTED_START = 1_789_544_700_000L
}
// endregion
}
@@ -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()
)
}
}
@@ -0,0 +1,128 @@
/*
* Look4Sat. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2019-2026 Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.core.domain.model
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
/**
* The map's VUCC view scopes QSO data to the operated-grid selector's choice
* (台址 = station location): [stationGridSetKey] identifies a record's 台址 and
* [scopedToStation] applies the choice — the grid-detail dialog relies on both.
*/
class GridQsoStationScopeTest {
private fun qso(
call: String = "BG7XYZ",
myGrid: String? = null,
myGrids: Set<String> = emptySet()
) = GridQso(
call = call, epochMs = 0L, satName = "SO-50", mode = "FM",
bandUp = "70CM", bandDown = "2M", myGrid = myGrid, myGrids = myGrids
)
@Test
fun `multi-grid record keys by its full sorted grid set`() {
assertEquals("OL62,PM01", qso(myGrids = setOf("PM01", "OL62")).stationGridSetKey())
}
@Test
fun `legacy single-grid record falls back to myGrid`() {
assertEquals("OL62", qso(myGrid = "OL62").stationGridSetKey())
}
@Test
fun `myGrids wins over myGrid when both are present`() {
val q = qso(myGrid = "OL62", myGrids = setOf("OM60", "PM01"))
assertEquals("OM60,PM01", q.stationGridSetKey())
}
@Test
fun `record without any MY grid carries no key`() {
assertNull(qso().stationGridSetKey())
}
@Test
fun `key does not depend on the grid set's iteration order`() {
assertEquals(
qso(myGrids = setOf("PM01", "OL62")).stationGridSetKey(),
qso(myGrids = setOf("OL62", "PM01")).stationGridSetKey()
)
}
@Test
fun `null scope keeps every record`() {
val a = qso("A", myGrid = "OL62")
val b = qso("B", myGrid = "PM01")
assertEquals(listOf(a, b), listOf(a, b).scopedToStation(null))
}
@Test
fun `specific scope keeps only the matching station's records`() {
val multi = qso("A", myGrids = setOf("OL62", "PM01"))
val single = qso("B", myGrid = "OL62")
val list = listOf(multi, single)
assertEquals(listOf(multi), list.scopedToStation("OL62,PM01"))
assertEquals(listOf(single), list.scopedToStation("OL62"))
}
@Test
fun `records without a MY grid belong to no station scope`() {
val orphan = qso("A")
val mine = qso("B", myGrid = "OL62")
assertEquals(listOf(mine), listOf(orphan, mine).scopedToStation("OL62"))
}
@Test
fun `scoping to a station absent from the list yields empty`() {
val list = listOf(qso("A", myGrid = "OL62"))
assertEquals(emptyList<GridQso>(), list.scopedToStation("PM01"))
}
// --- firstCallsByGrid ---
@Test
fun `first call per grid picks the earliest of the scoped records`() {
val store = mapOf(
"OL62" to listOf(
qso("OTHER", myGrid = "PM01").copy(epochMs = 10),
qso("MINE", myGrid = "OL62").copy(epochMs = 20)
),
"PM95" to listOf(qso("MINE2", myGrid = "OL62").copy(epochMs = 30))
)
assertEquals(mapOf("OL62" to "MINE", "PM95" to "MINE2"), store.firstCallsByGrid("OL62"))
}
@Test
fun `first call per grid with All uses every record`() {
val store = mapOf(
"OL62" to listOf(
qso("OTHER", myGrid = "PM01").copy(epochMs = 10),
qso("MINE", myGrid = "OL62").copy(epochMs = 20)
)
)
assertEquals(mapOf("OL62" to "OTHER"), store.firstCallsByGrid(null))
}
@Test
fun `grids without scoped records get no first call`() {
val store = mapOf("OL62" to listOf(qso("A", myGrid = "PM01")))
assertEquals(emptyMap<String, String>(), store.firstCallsByGrid("OL62"))
}
}
@@ -27,6 +27,7 @@ import com.rtbishop.look4sat.core.domain.model.PassesSettings
import com.rtbishop.look4sat.core.domain.model.RCSettings
import com.rtbishop.look4sat.core.domain.model.RadioControlSettings
import com.rtbishop.look4sat.core.domain.model.WavelogSettings
import com.rtbishop.look4sat.core.domain.model.WavelogUploadSettings
import com.rtbishop.look4sat.core.domain.predict.GeoPos
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@@ -248,6 +249,7 @@ class LoTWGridSyncTest {
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())
private var workedGrids: Set<String> = emptySet()
@@ -276,6 +278,7 @@ class LoTWGridSyncTest {
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 fun getSatelliteTypesIds(types: List<String>): List<Int> = TODO()
override fun setSatelliteTypeIds(type: String, ids: List<Int>) = TODO()
override fun updateDatabaseState(state: DatabaseState) = TODO()
@@ -291,6 +294,7 @@ class LoTWGridSyncTest {
override fun getAmSatCallsign(): String = ""
override fun setAmSatCallsign(callsign: String) = TODO()
override fun updateWavelogSettings(settings: WavelogSettings) = TODO()
override fun updateWavelogUploadSettings(settings: WavelogUploadSettings) = TODO()
override fun updateLoTWSettings(settings: LoTWSettings) = TODO()
}
}
@@ -0,0 +1,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,171 @@
/*
* 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.utility
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* Golden values were produced by a line-by-line port of the reference
* implementation (OrbitDeck for iOS, MIT) covering four hemispheres/quadrant
* cases plus the two awkward longitudes: the Greenwich meridian and the
* antimeridian.
*/
class GridGeometryTest {
private val meterDelta = 0.5
private val bearingDelta = 0.05
@Test
fun `station at home square reports both boundaries and the corner`() {
val geometry = requireNotNull(gridGeometry(22.3542, 113.6250))
assertEquals("OL62", geometry.grid4)
assertEquals("OL62TI", geometry.grid6)
assertEquals("OL62TI55", geometry.grid8)
assertEquals(39429.5, geometry.latLineMeters, meterDelta)
assertEquals(GridBoundaryDirection.SOUTH, geometry.latLineDirection)
assertEquals(38607.9, geometry.lonLineMeters, meterDelta)
assertEquals(GridBoundaryDirection.EAST, geometry.lonLineDirection)
assertEquals(55183.8, geometry.cornerMeters, meterDelta)
assertEquals(135.60, geometry.cornerBearingDegrees, bearingDelta)
assertEquals(listOf("OL61", "OL62", "OL71", "OL72"), geometry.cornerGrids)
assertEquals(38607.9, geometry.nearestLineMeters, meterDelta)
assertEquals(90.0, geometry.nearestLineBearingDegrees, bearingDelta)
assertEquals(false, geometry.nearestLineIsLatitude)
assertEquals(listOf("OL62", "OL72"), geometry.nearestLineGrids)
}
@Test
fun `fix on the 114 east line sits on the boundary itself`() {
val geometry = requireNotNull(gridGeometry(22.3542, 114.0000))
assertEquals("OL72", geometry.grid4)
assertEquals("OL72AI", geometry.grid6)
assertEquals("OL72AI05", geometry.grid8)
assertEquals(39429.5, geometry.latLineMeters, meterDelta)
assertEquals(0.0, geometry.lonLineMeters, meterDelta)
assertEquals(39429.5, geometry.cornerMeters, meterDelta)
assertEquals(180.00, geometry.cornerBearingDegrees, bearingDelta)
assertEquals(listOf("OL62", "OL72"), geometry.nearestLineGrids)
}
@Test
fun `fix on the 22 north 114 east corner names all four squares`() {
val geometry = requireNotNull(gridGeometry(22.00002, 114.00003))
assertEquals("OL72", geometry.grid4)
assertEquals(2.2, geometry.latLineMeters, meterDelta)
assertEquals(3.1, geometry.lonLineMeters, meterDelta)
assertEquals(3.8, geometry.cornerMeters, meterDelta)
assertEquals(234.28, geometry.cornerBearingDegrees, bearingDelta)
assertEquals(listOf("OL61", "OL62", "OL71", "OL72"), geometry.cornerGrids)
assertEquals("OL71", geometry.nearestLineGrids.first())
}
@Test
fun `greenwich meridian fix reports a zero distance latitude line to the north`() {
val geometry = requireNotNull(gridGeometry(45.0000, -0.0004))
assertEquals("IN95", geometry.grid4)
assertEquals("IN95XA", geometry.grid6)
assertEquals("IN95XA90", geometry.grid8)
assertEquals(0.0, geometry.latLineMeters, meterDelta)
assertEquals(GridBoundaryDirection.NORTH, geometry.latLineDirection)
assertEquals(31.5, geometry.lonLineMeters, meterDelta)
assertEquals(listOf("IN94", "IN95", "JN04", "JN05"), geometry.cornerGrids)
assertEquals(true, geometry.nearestLineIsLatitude)
assertEquals(listOf("IN94", "IN95"), geometry.nearestLineGrids)
}
@Test
fun `antimeridian fix wraps into the eastern square`() {
val geometry = requireNotNull(gridGeometry(0.05, 179.9998))
assertEquals("RJ90", geometry.grid4)
assertEquals("RJ90XB", geometry.grid6)
assertEquals("RJ90XB92", geometry.grid8)
assertEquals(22.3, geometry.lonLineMeters, meterDelta)
assertEquals(GridBoundaryDirection.EAST, geometry.lonLineDirection)
assertEquals(179.77, geometry.cornerBearingDegrees, bearingDelta)
// The corner's four squares straddle the antimeridian, so the western
// pair rolls over to the A-series fields.
assertEquals(listOf("AI09", "AJ00", "RI99", "RJ90"), geometry.cornerGrids)
assertEquals(listOf("AJ00", "RJ90"), geometry.nearestLineGrids)
}
@Test
fun `southern hemisphere fix keeps its bearings right way round`() {
val geometry = requireNotNull(gridGeometry(-33.86, 151.21))
assertEquals("QF56", geometry.grid4)
assertEquals("QF56OD", geometry.grid6)
assertEquals(15584.8, geometry.latLineMeters, meterDelta)
assertEquals(GridBoundaryDirection.SOUTH, geometry.latLineDirection)
assertEquals(73027.8, geometry.lonLineMeters, meterDelta)
assertEquals(GridBoundaryDirection.EAST, geometry.lonLineDirection)
assertEquals(102.05, geometry.cornerBearingDegrees, bearingDelta)
assertEquals(180.0, geometry.nearestLineBearingDegrees, bearingDelta)
}
@Test
fun `out of range positions yield no geometry`() {
assertNull(gridGeometry(90.0, 0.0))
assertNull(gridGeometry(-91.0, 0.0))
}
@Test
fun `vucc claims one square when the fix is inside`() {
assertEquals(listOf("OL62"), vuccClaimableGrids(22.3542, 113.6250))
assertEquals(GridFixStatus.INSIDE_GRID, gridFixStatus(vuccClaimableGrids(22.3542, 113.6250)))
}
@Test
fun `vucc claims both squares on a line`() {
val claimed = vuccClaimableGrids(22.3542, 114.0000)
assertEquals(listOf("OL62", "OL72"), claimed)
assertEquals(GridFixStatus.ON_GRID_LINE, gridFixStatus(claimed))
}
@Test
fun `vucc claims all four squares on a corner`() {
val claimed = vuccClaimableGrids(22.00002, 114.00003)
assertEquals(listOf("OL61", "OL62", "OL71", "OL72"), claimed)
assertEquals(GridFixStatus.ON_GRID_CORNER, gridFixStatus(claimed))
}
@Test
fun `vucc claims nothing extra just outside the 20 foot tolerance`() {
// Same corner, but ~38 m away: outside the 20 ft (6.096 m) rule.
val claimed = vuccClaimableGrids(22.0002, 114.0003)
assertEquals(listOf("OL72"), claimed)
assertEquals(GridFixStatus.INSIDE_GRID, gridFixStatus(claimed))
}
@Test
fun `vucc tolerance boundary is 20 feet`() {
assertEquals(6.096, VUCC_BOUNDARY_TOLERANCE_METERS, 0.0001)
// 5 m north of the 22 N line: inside the tolerance for a custom, tighter
// call, outside it when the caller demands sub-metre precision.
assertTrue(vuccClaimableGrids(22.00005, 113.62).size >= 2)
assertEquals(1, vuccClaimableGrids(22.00005, 113.62, toleranceMeters = 1.0).size)
}
@Test
fun `grid4 ignores longitudes beyond the antimeridian`() {
assertNull(positionToGrid4(91.0, 0.0))
// A longitude of 200 degrees is the same meridian as -160 degrees.
assertEquals(positionToGrid4(30.0, -160.0), positionToGrid4(30.0, 200.0))
}
}
@@ -492,6 +492,7 @@ fun SwipeRevealRow(
controller: SwipeController,
modifier: Modifier = Modifier,
revealWidth: Dp = 64.dp,
gesturesEnabled: Boolean = true,
revealAction: () -> Unit,
revealButton: @Composable () -> Unit = {
Box(
@@ -555,7 +556,9 @@ fun SwipeRevealRow(
modifier = Modifier
.fillMaxWidth()
.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(
onDragStart = { settleJob?.cancel() },
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)
}
}
}
@@ -1,3 +1,21 @@
/*
* 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.Canvas
@@ -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)) }
}
)
}
@@ -61,8 +61,9 @@ fun MainTheme(isDarkTheme: Boolean = isSystemInDarkTheme(), content: @Composable
SideEffect {
val window = (view.context as ComponentActivity).window
val insetsController = WindowCompat.getInsetsController(window, view)
insetsController.isAppearanceLightStatusBars = false
insetsController.isAppearanceLightNavigationBars = false
// Light theme needs dark status/nav bar icons; the dark theme needs light ones.
insetsController.isAppearanceLightStatusBars = !isDarkTheme
insetsController.isAppearanceLightNavigationBars = !isDarkTheme
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
window.isNavigationBarContrastEnforced = false
}
@@ -45,6 +45,14 @@ sealed class Screen(val iconResId: Int, val titleResId: Int) : NavKey {
@Serializable
data object RadarDestination : NavKey
/** Full-screen field tool: guides the operator onto a VUCC grid line or corner. */
@Serializable
data object GridFinderDestination : NavKey
/** LoTW upload certificate + station location page, reached from the Grid Finder. */
@Serializable
data object LoTWUploadDestination : NavKey
interface IDeeplinkMatcher {
fun match(deeplink: String): NavKey?
}
@@ -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.presentation
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
/**
* The fork's Project Wiki: a short overview of what Look4Sat-BA7OPF adds over upstream
* and how to use those features. Shown in the What's-New card (first launch after an
* update) and behind the Settings → Project Wiki entry. Text lives in string resources,
* so it always renders even when the GitHub page is unreachable.
*/
@Composable
fun ProjectWikiBody(modifier: Modifier = Modifier) {
Column(modifier = modifier, verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(
text = stringResource(R.string.project_wiki_intro),
fontSize = 13.sp
)
WikiSection(stringResource(R.string.project_wiki_section_logbook))
WikiEntry(stringResource(R.string.project_wiki_logbook_1))
WikiEntry(stringResource(R.string.project_wiki_logbook_2))
WikiEntry(stringResource(R.string.project_wiki_logbook_3))
WikiHow(stringResource(R.string.project_wiki_logbook_how))
WikiSection(stringResource(R.string.project_wiki_section_mutual))
WikiEntry(stringResource(R.string.project_wiki_mutual_1))
WikiHow(stringResource(R.string.project_wiki_mutual_how))
WikiSection(stringResource(R.string.project_wiki_section_grids))
WikiEntry(stringResource(R.string.project_wiki_grids_1))
WikiHow(stringResource(R.string.project_wiki_grids_how))
}
}
@Composable
private fun WikiSection(text: String) {
Text(
text = text,
color = MaterialTheme.colorScheme.primary,
fontWeight = FontWeight.Medium,
fontSize = 13.sp
)
}
@Composable
private fun WikiEntry(text: String) {
Text(text = text, fontSize = 12.sp)
}
@Composable
private fun WikiHow(text: String) {
Text(
text = text,
fontSize = 12.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(start = 8.dp)
)
}
@@ -0,0 +1,386 @@
/*
* 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.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.FilterChip
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.rtbishop.look4sat.core.domain.logbook.LoTWSatelliteAliases
import com.rtbishop.look4sat.core.domain.logbook.QsoRecord
import com.rtbishop.look4sat.core.domain.logbook.displayMode
import com.rtbishop.look4sat.core.domain.logbook.frequencyBand
import java.math.BigDecimal
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import java.util.TimeZone
import kotlin.math.roundToLong
private const val DATE_PATTERN = "yyyy-MM-dd"
private const val TIME_PATTERN = "HH:mm:ss"
/**
* Edit every field of a recorded QSO (the record list rows in the radar Log tab and
* the logbook dialog both open this). Frequencies are entered in MHz and the band is
* re-derived from them, because that derivation is what LoTW validates: a record with
* no uplink frequency has no BAND and cannot be signed.
*
* The satellite name is restricted to ARRL's catalogue ([satelliteCandidates], from
* config.tq6): typing shows fuzzy suggestions, the picker lists the whole catalogue, and
* a name that resolves to no ARRL satellite cannot be saved. A tracker name such as
* "SAUDISAT 1C" is accepted and stored as the ARRL name it resolves to ("SO-50").
*/
@Composable
fun QsoEditDialog(
record: QsoRecord,
satelliteCandidates: List<String> = emptyList(),
onDismiss: () -> Unit,
onSave: (QsoRecord) -> Unit
) {
val utc = remember { TimeZone.getTimeZone("UTC") }
fun formatDate(millis: Long) = SimpleDateFormat(DATE_PATTERN, Locale.US).apply { timeZone = utc }.format(Date(millis))
fun formatTime(millis: Long) = SimpleDateFormat(TIME_PATTERN, Locale.US).apply { timeZone = utc }.format(Date(millis))
var callsign by remember(record.id) { mutableStateOf(record.theirCallsign.trim()) }
var dateText by remember(record.id) { mutableStateOf(formatDate(record.startUtcMillis)) }
var timeText by remember(record.id) { mutableStateOf(formatTime(record.startUtcMillis)) }
var txText by remember(record.id) { mutableStateOf(formatMhz(record.txFrequencyHz)) }
var rxText by remember(record.id) { mutableStateOf(formatMhz(record.rxFrequencyHz)) }
var modeText by remember(record.id) { mutableStateOf(record.displayMode) }
var satelliteText by remember(record.id) { mutableStateOf(record.satelliteName) }
var pickerOpen by remember(record.id) { mutableStateOf(false) }
var sentText by remember(record.id) { mutableStateOf(record.sentReport) }
var receivedText by remember(record.id) { mutableStateOf(record.receivedReport) }
var gridText by remember(record.id) { mutableStateOf(record.theirGrid) }
var commentText by remember(record.id) { mutableStateOf(record.comment) }
var error by remember(record.id) { mutableStateOf("") }
// ARRL name the typed tracker name resolves to ("SAUDISAT 1C" -> "SO-50"), if any.
val officialSatellite = remember(satelliteText, satelliteCandidates) {
LoTWSatelliteAliases.resolve(satelliteText, satelliteCandidates)
}
val satelliteSuggestions = remember(satelliteText, satelliteCandidates) {
LoTWSatelliteAliases.suggestions(satelliteText, satelliteCandidates)
}
val txHz = parseMhz(txText)
val rxHz = parseMhz(rxText)
val bandText = listOf(frequencyBand(txHz), frequencyBand(rxHz)).filter { it.isNotBlank() }.distinct()
.joinToString(" / ")
val errCallsign = stringResource(R.string.qso_edit_err_callsign)
val errDateTime = stringResource(R.string.qso_edit_err_datetime)
val errFrequency = stringResource(R.string.qso_edit_err_frequency)
val errSatellite = stringResource(R.string.qso_edit_err_satellite)
AlertDialog(
onDismissRequest = onDismiss,
shape = sheetDialogShape(),
containerColor = sheetDialogContainerColor(),
title = { SheetDialogTitle(stringResource(R.string.qso_edit_title)) },
text = {
Column(
modifier = Modifier.fillMaxWidth().heightIn(max = 460.dp).verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(6.dp)
) {
if (record.lotwUploaded || record.lotwConfirmed) {
Text(
text = stringResource(R.string.qso_edit_uploaded_notice),
fontSize = 12.sp,
color = MaterialTheme.colorScheme.primary,
style = MaterialTheme.typography.bodySmall
)
}
EditField(stringResource(R.string.qso_edit_callsign), callsign, KeyboardCapitalization.Characters) {
callsign = it.uppercase(Locale.US)
}
Row(horizontalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.fillMaxWidth()) {
EditField(stringResource(R.string.qso_edit_date), dateText, KeyboardCapitalization.None, Modifier.weight(1f)) {
dateText = it
}
EditField(stringResource(R.string.qso_edit_time), timeText, KeyboardCapitalization.None, Modifier.weight(1f)) {
timeText = it
}
}
Row(horizontalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.fillMaxWidth()) {
EditField(stringResource(R.string.qso_edit_tx), txText, KeyboardCapitalization.None, Modifier.weight(1f)) {
txText = it
}
EditField(stringResource(R.string.qso_edit_rx), rxText, KeyboardCapitalization.None, Modifier.weight(1f)) {
rxText = it
}
}
Text(
text = stringResource(R.string.qso_edit_band, bandText.ifBlank { "—" }),
fontSize = 12.sp,
color = if (txHz == null) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurfaceVariant
)
EditField(stringResource(R.string.qso_edit_mode), modeText, KeyboardCapitalization.Characters) {
modeText = it.uppercase(Locale.US)
}
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
EditField(
label = stringResource(R.string.qso_edit_satellite),
value = satelliteText,
capitalization = KeyboardCapitalization.Characters,
modifier = Modifier.weight(1f)
) { satelliteText = it.uppercase(Locale.US) }
TextButton(
onClick = { pickerOpen = true },
enabled = satelliteCandidates.isNotEmpty()
) { Text(stringResource(R.string.qso_edit_satellite_pick), fontSize = 12.sp) }
}
when {
// Resolves to an ARRL satellite under a different name: say so, the
// ARRL name is what gets stored and signed.
officialSatellite != null && !officialSatellite.equals(satelliteText.trim(), true) ->
Text(
text = stringResource(R.string.qso_edit_satellite_official, officialSatellite),
fontSize = 11.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
// Not an ARRL satellite at all: offer the closest catalogue names.
satelliteText.isNotBlank() && officialSatellite == null -> {
Text(
text = stringResource(R.string.qso_edit_err_satellite),
fontSize = 11.sp,
color = MaterialTheme.colorScheme.error
)
if (satelliteSuggestions.isNotEmpty()) {
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
satelliteSuggestions.take(4).forEach { candidate ->
FilterChip(
selected = false,
onClick = { satelliteText = candidate },
label = { Text(candidate, fontSize = 11.sp) }
)
}
}
}
}
else -> Text(
text = stringResource(R.string.qso_edit_hint_satellite),
fontSize = 11.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Row(horizontalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.fillMaxWidth()) {
EditField(stringResource(R.string.qso_edit_sent), sentText, KeyboardCapitalization.Characters, Modifier.weight(1f)) {
sentText = it
}
EditField(stringResource(R.string.qso_edit_received), receivedText, KeyboardCapitalization.Characters, Modifier.weight(1f)) {
receivedText = it
}
}
Row(horizontalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.fillMaxWidth()) {
EditField(stringResource(R.string.qso_edit_grid), gridText, KeyboardCapitalization.Characters, Modifier.weight(1f)) {
gridText = it.uppercase(Locale.US)
}
EditField(stringResource(R.string.qso_edit_comment), commentText, KeyboardCapitalization.None, Modifier.weight(1f)) {
commentText = it
}
}
if (txHz == null) {
Text(
text = stringResource(R.string.qso_edit_warn_no_freq),
fontSize = 12.sp,
color = MaterialTheme.colorScheme.error
)
}
if (error.isNotBlank()) {
Text(text = error, fontSize = 12.sp, color = MaterialTheme.colorScheme.error)
}
}
},
confirmButton = {
TextButton(onClick = {
val millis = parseUtc(dateText, timeText, utc)
val badFrequency = (txText.isNotBlank() && txHz == null) || (rxText.isNotBlank() && rxHz == null)
when {
callsign.isBlank() -> error = errCallsign
millis == null -> error = errDateTime
badFrequency -> error = errFrequency
// Only ARRL satellite names may be stored: a record that cannot be
// resolved here would be rejected at upload time anyway.
satelliteCandidates.isNotEmpty() && officialSatellite == null ->
error = errSatellite
else -> {
val mode = modeText.trim().uppercase(Locale.US).ifBlank { "FM" }
val satellite = officialSatellite ?: satelliteText.trim()
onSave(
record.copy(
theirCallsign = callsign.trim().uppercase(Locale.US),
startUtcMillis = millis,
endUtcMillis = record.endUtcMillis?.let { millis },
theirGrid = gridText.trim().uppercase(Locale.US),
sentReport = sentText.trim(),
receivedReport = receivedText.trim(),
txFrequencyHz = txHz,
rxFrequencyHz = rxHz,
band = frequencyBand(txHz),
rxBand = frequencyBand(rxHz),
mode = if (mode == "FT4") "MFSK" else mode,
submode = if (mode == "FT4") "FT4" else "",
satelliteName = satellite,
propagationMode = if (satellite.isBlank()) record.propagationMode else "SAT",
comment = commentText.trim()
)
)
}
}
}) { Text(stringResource(R.string.qso_edit_save)) }
},
dismissButton = {
TextButton(onClick = onDismiss) { Text(stringResource(R.string.qso_edit_cancel)) }
}
)
if (pickerOpen) {
SatellitePickerDialog(
candidates = satelliteCandidates,
onDismiss = { pickerOpen = false },
onPick = {
satelliteText = it
pickerOpen = false
}
)
}
}
/**
* Full ARRL satellite catalogue with a fuzzy filter: typing "saud" or "saudisat 1c" narrows the
* list down to "SO-50", so a tracker name can always be turned into the name LoTW accepts.
*/
@Composable
private fun SatellitePickerDialog(
candidates: List<String>,
onDismiss: () -> Unit,
onPick: (String) -> Unit
) {
var query by remember { mutableStateOf("") }
val matches = remember(query, candidates) { filteredCandidates(query, candidates) }
AlertDialog(
onDismissRequest = onDismiss,
shape = sheetDialogShape(),
containerColor = sheetDialogContainerColor(),
title = { SheetDialogTitle(stringResource(R.string.qso_edit_satellite_pick_title)) },
text = {
Column(modifier = Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(6.dp)) {
OutlinedTextField(
value = query,
onValueChange = { query = it.uppercase(Locale.US) },
label = { Text(stringResource(R.string.qso_edit_satellite_search), fontSize = 12.sp) },
singleLine = true,
textStyle = MaterialTheme.typography.bodyMedium,
modifier = Modifier.fillMaxWidth()
)
LazyColumn(
modifier = Modifier.fillMaxWidth().heightIn(max = 320.dp),
verticalArrangement = Arrangement.spacedBy(2.dp)
) {
items(matches, key = { it }) { name ->
Text(
text = name,
fontSize = 14.sp,
modifier = Modifier
.fillMaxWidth()
.clickable { onPick(name) }
.padding(horizontal = 4.dp, vertical = 8.dp)
)
}
}
}
},
confirmButton = {
TextButton(onClick = onDismiss) { Text(stringResource(R.string.qso_edit_cancel)) }
}
)
}
/** Catalogue filtered by the fuzzy query; the whole list when nothing matches or the query is blank. */
internal fun filteredCandidates(query: String, candidates: List<String>): List<String> {
if (query.isBlank()) return candidates
val matches = LoTWSatelliteAliases.suggestions(query, candidates, limit = 500)
return matches.ifEmpty { candidates }
}
@Composable
private fun EditField(
label: String,
value: String,
capitalization: KeyboardCapitalization,
modifier: Modifier = Modifier.fillMaxWidth(),
onValueChange: (String) -> Unit
) {
OutlinedTextField(
value = value,
onValueChange = onValueChange,
label = { Text(label, fontSize = 12.sp) },
singleLine = true,
textStyle = MaterialTheme.typography.bodyMedium,
keyboardOptions = KeyboardOptions(capitalization = capitalization),
modifier = modifier.padding(vertical = 1.dp)
)
}
/** "436.795" (MHz) -> 436_795_000 Hz. Null when blank or unparsable. */
internal fun parseMhz(text: String): Long? = text.trim()
.takeIf { it.isNotEmpty() }
?.toDoubleOrNull()
?.let { (it * 1_000_000.0).roundToLong() }
/** Hz -> "436.795" (MHz), trailing zeros trimmed. */
internal fun formatMhz(hz: Long?): String = hz
?.let { BigDecimal.valueOf(it, 6).stripTrailingZeros().toPlainString() }
.orEmpty()
internal fun parseUtc(date: String, time: String, zone: TimeZone): Long? = runCatching {
SimpleDateFormat("$DATE_PATTERN $TIME_PATTERN", Locale.US).apply {
timeZone = zone
isLenient = false
}.parse("${date.trim()} ${time.trim()}")?.time
}.getOrNull()
@@ -0,0 +1,58 @@
/*
* 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.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.sp
/**
* Styling of the app's sheet dialogs ([DialogShell] / [SharedDialog] / [InfoDialog], all built on
* ModalBottomSheet). AlertDialog-based dialogs (logbook, QSO editor, upload preview) use these so
* their container colour, corner radius and title look identical to the bottom sheets.
*/
/**
* Container colour of the app's bottom sheets. Equal to `BottomSheetDefaults.ContainerColor`
* (Material3 resolves that token to `ColorScheme.surfaceContainerLow`) — spelled out here so the
* dialogs don't have to opt into the experimental bottom-sheet API.
*/
@Composable
fun sheetDialogContainerColor(): Color = MaterialTheme.colorScheme.surfaceContainerLow
/** Corner radius of the app's bottom sheets (`MaterialTheme.shapes.medium`, 12 dp). */
@Composable
fun sheetDialogShape(): Shape = MaterialTheme.shapes.medium
/** Title row of the app's sheet dialogs: 16 sp medium, primary colour, single line. */
@Composable
fun SheetDialogTitle(text: String) {
Text(
text = text,
fontSize = 16.sp,
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.primary,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
@@ -1,3 +1,21 @@
/*
* 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.animation.core.animateDpAsState
@@ -0,0 +1,13 @@
<!--
Grid Finder icon: a GPS crosshair.
Ported from Material Design Icons (crosshairs-gps), Apache License 2.0.
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M12,8A4,4 0 0,1 16,12A4,4 0 0,1 12,16A4,4 0 0,1 8,12A4,4 0 0,1 12,8M3.05,13H1V11H3.05C3.5,6.83 6.83,3.5 11,3.05V1H13V3.05C17.17,3.5 20.5,6.83 20.95,11H23V13H20.95C20.5,17.17 17.17,20.5 13,20.95V23H11V20.95C6.83,20.5 3.5,17.17 3.05,13M12,5A7,7 0 0,0 5,12A7,7 0 0,0 12,19A7,7 0 0,0 19,12A7,7 0 0,0 12,5Z" />
</vector>
@@ -83,7 +83,6 @@
<string name="map_visible">Visible</string>
<!-- Settings screen -->
<string name="prefs_donate_title">Soporte</string>
<string name="prefs_privacy_title">Política de privacidad</string>
<string name="prefs_updated_title">Actualizado: %s</string>
@@ -113,6 +112,8 @@
<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_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_transceivers_error">No transceivers imported. Select a valid SatNOGS (.json) file.</string>
<string name="prefs_data_sources_url_title" translatable="false">URL</string>
@@ -82,7 +82,6 @@
<string name="map_visible">Видимый</string>
<!-- Settings screen -->
<string name="prefs_donate_title">Поддержать</string>
<string name="prefs_privacy_title">Политика конфиденциальности</string>
<string name="prefs_updated_title">Обновлено: %s</string>
@@ -112,6 +111,8 @@
<string name="prefs_data_update_success">Обновление прошло успешно</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_transceivers_error">No transceivers imported. Select a valid SatNOGS (.json) file.</string>
<string name="prefs_data_sources_url_title" translatable="false">URL</string>
@@ -83,7 +83,6 @@
<string name="map_visible">දෘශ්‍ය</string>
<!-- Settings screen -->
<string name="prefs_donate_title">දායකත්ව</string>
<string name="prefs_privacy_title">රහස්යතා ප්රතිපත්තිය</string>
<string name="prefs_updated_title">යාවත්කාලීන කරන ලදී: %s</string>
@@ -113,6 +112,8 @@
<string name="prefs_data_update_success">යාවත්කාලීනය සම්පූර්ණ කිරීම සාර්ථකයි</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_transceivers_error">No transceivers imported. Select a valid SatNOGS (.json) file.</string>
<string name="prefs_data_sources_url_title" translatable="false">URL</string>
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Commmon -->
<string name="app_name" translatable="false">Look4Sat</string>
<string name="app_name" translatable="false">Look4Sat-BA7OPF</string>
<string name="btn_accept">Kabul et</string>
<string name="btn_cancel">İptal</string>
<string name="empty_list_message">Gösterilecek veri bulunamadı</string>
@@ -104,18 +104,16 @@
<string name="map_visible">Görünür</string>
<!-- Settings screen -->
<string name="prefs_app_title" translatable="false">Look4Sat v%s</string>
<string name="prefs_app_url" translatable="false">https://play.google.com/store/apps/details?id=com.rtbishop.look4sat</string>
<string name="prefs_donate_title">Bağış yap</string>
<string name="prefs_donate_url" translatable="false">https://ko-fi.com/rt_bishop</string>
<string name="prefs_fdroid_title" translatable="false">F-Droid</string>
<string name="prefs_fdroid_url" translatable="false">https://f-droid.org/en/packages/com.rtbishop.look4sat/</string>
<string name="prefs_app_title" translatable="false">Look4Sat-BA7OPF v%s</string>
<string name="prefs_app_url" translatable="false">https://github.com/atsunatsu/Look4Sat/releases</string>
<string name="prefs_github_title" translatable="false">GitHub</string>
<string name="prefs_github_url" translatable="false">https://github.com/rt-bishop/Look4Sat/</string>
<string name="prefs_github_url" translatable="false">https://github.com/atsunatsu/Look4Sat</string>
<string name="prefs_license_title" translatable="false">GPLv3</string>
<string name="prefs_license_url" translatable="false">https://www.gnu.org/licenses/gpl-3.0.html</string>
<string name="prefs_privacy_title">Gizlilik Politikası</string>
<string name="prefs_privacy_url" translatable="false">https://sites.google.com/view/look4sat-privacy-policy/home</string>
<string name="prefs_upstream_title" translatable="false">Upstream Project</string>
<string name="prefs_upstream_url" translatable="false">https://github.com/rt-bishop/Look4Sat</string>
<string name="prefs_updated_title">Güncellendi: %s</string>
<string name="prefs_updated_time">d MMM yyyy - HH:mm:ss</string>
@@ -144,6 +142,8 @@
<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_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_transceivers_error">No transceivers imported. Select a valid SatNOGS (.json) file.</string>
<string name="prefs_data_sources_satellites_label" translatable="false">Satellites data</string>
@@ -83,7 +83,6 @@
<string name="map_visible">Видимий</string>
<!-- Settings screen -->
<string name="prefs_donate_title">Підтримати</string>
<string name="prefs_privacy_title">Політика конфіденційності</string>
<string name="prefs_updated_title">Оновлено: %s</string>
@@ -113,6 +112,8 @@
<string name="prefs_data_update_success">Оновлення успішне</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_transceivers_error">No transceivers imported. Select a valid SatNOGS (.json) file.</string>
<string name="prefs_data_sources_url_title" translatable="false">URL</string>
@@ -41,7 +41,6 @@
<string name="pass_welcome_message">请务必在设置中通过GPS、经纬度或QTH定位您的位置\n建议至少每周更新一次数据库,以确保预测结果的准确性</string>
<!-- AMSAT Status screen -->
<string name="pass_whatsnew_message">4.4.6 以来的全部更新(4.4.7 系列):\n\n• 网格模式地图:LoTW 已确认网格绿色填充、漫游网格蓝色条纹、当前网格加粗框;右上角网格/卫星模式开关。\n\n• 首通呼号(VUCC 视图):绿格显示该格最早通联的呼号,非绿格空白。\n\n• worked 网格可按操作网格筛选(含“全部”);点击网格弹出 QSO 明细;每格 QSO 计数。\n\n• 各操作网格独立 VUCC 计数;极区网格标签;反经线修复;漫游条纹密度一致。\n\n• WAPC/WAJA 地图标签随系统语言。\n\n• 网格模式视口与筛选跨页保持。\n\n• LoTW 同步:账号直连、全量/增量模式、实时进度、可取消、启动自动同步(独立开关)、设置页显示上次同步时间。\n\n• 历史过境:过境筛选可回看过去数小时(历史过境时间)。\n\n• 指南针校准:8 字动作精度指示 + 手动航向偏置。\n\n• AMSAT 状态页:连续相同状态报告计数。\n\n• 过境/雷达:UTC 切换重算过境;日出日落与仰角颜色修复;卫星模糊搜索;相互过境页滚动保持、跟随 UTC、雷达快捷入口。\n\n• CW 解码器支持 32/64 位设备;Android 17 (API 37) 适配。</string>
<string name="nav_amsat">AMSAT</string>
<string name="amsat_title">AMSAT 卫星状态</string>
@@ -141,7 +140,6 @@
<string name="map_visible">日照中</string>
<!-- Settings screen -->
<string name="prefs_donate_title">支持</string>
<string name="prefs_privacy_title">隐私政策</string>
<string name="prefs_updated_title">更新:%s</string>
@@ -168,6 +166,16 @@
<string name="prefs_wavelog_hint">已同步网格数:%1$d。格式:API key:日志ID。填好直接点同步,会自动保存并拉取。</string>
<string name="prefs_wavelog_sync">立即同步</string>
<string name="prefs_wavelog_syncing">同步中…</string>
<string name="prefs_wavelog_upload_title">Wavelog 日志上传</string>
<string name="prefs_wavelog_upload_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_callsign">LoTW 呼号</string>
<string name="prefs_lotw_password">LoTW 密码</string>
@@ -199,6 +207,16 @@
<string name="prefs_logbook_upload">上传</string>
<string name="prefs_logbook_upload_title">上传到 LoTW</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_wavelog_sync">同时同步 Wavelog:%1$d 条</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_configured">证书已导入 · 台址网格 %1$s</string>
<string name="prefs_lotw_upload_not_configured">未配置 — 导入 TrustedQSL 证书后即可上传通联</string>
@@ -234,6 +252,8 @@
<string name="prefs_data_import_transceivers_error">未导入收发器。请选择有效的 SatNOGS (.json) 文件。</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_tle_switch">自定义TLE URL</string>
<string name="prefs_data_sources_transceivers_switch">自定义收发器URL</string>
@@ -304,6 +324,7 @@
<string name="prefs_compass_offset">手动航向偏置(°)</string>
<string name="prefs_compass_offset_support">范围 −180° 至 +180°;正值为顺时针偏移。</string>
<string name="prefs_compass_offset_reset">偏置归零</string>
<string name="prefs_other_switch_light_theme">浅色主题</string>
<string name="prefs_other_switch_night_mode">启用红色夜间模式</string>
<string name="prefs_other_switch_map_grid">地图显示 Maidenhead 网格</string>
@@ -312,7 +333,20 @@
<string name="prefs_highlight_mid">中 %1$d-%2$d°</string>
<string name="prefs_highlight_high">高 &gt; %1$d°</string>
<string name="prefs_outro_title">我要感谢:</string>
<string name="prefs_outro_title">致谢与声明</string>
<string name="prefs_project_wiki">Project Wiki</string>
<string name="project_wiki_intro">Look4Sat-BA7OPF 是基于 Look4Sat 的定制版,保留上游全部功能,并新增:</string>
<string name="project_wiki_section_logbook">卫星日志本与 LoTW</string>
<string name="project_wiki_logbook_1">卫星通联日志本——通联时一键记录呼号、时间、频率、模式、信号报告和网格。</string>
<string name="project_wiki_logbook_2">LoTW 直传——导入 ARRL 证书后,直接在 App 内把日志上传到 LoTW,无需电脑。</string>
<string name="project_wiki_logbook_3">确认同步——一键同步 LoTW 官方确认,日志自动标记“已上传 / 已确认”;修改已上传的记录会自动新建一条,原记录不受影响。</string>
<string name="project_wiki_logbook_how">用法:通联后点“记录”→ 日志页点“上传”→ 设置里“同步”查看确认。</string>
<string name="project_wiki_section_mutual">双站过境匹配</string>
<string name="project_wiki_mutual_1">友台共视匹配——输入你和友台的位置,找出双方同时可见的卫星过境,点一下直接进雷达图。</string>
<string name="project_wiki_mutual_how">用法:Match 页 → 设置两站位置 → 查询。</string>
<string name="project_wiki_section_grids">通联网格地图</string>
<string name="project_wiki_grids_1">已通联网格——LoTW/Wavelog 同步的网格在地图上标绿,支持奖状边界和首通呼号标签;点格子看该网格的通联记录,还能一键去匹配友台。</string>
<string name="project_wiki_grids_how">用法:地图页开启网格模式 → 设置里同步 LoTW/Wavelog。</string>
<string name="prefs_outro_thanks" translatable="false">
• Look4Sat 所有用户及贡献者!
\n• David A. B. Johnson (predict4java)
@@ -325,7 +359,9 @@
\n• BH2VSQ BG5JSU(天地图渲染支持)
\n• BG5JSU(LoTW上传支持)
</string>
<string name="prefs_outro_license">该应用程序不提供任何保修.</string>
<string name="prefs_outro_notice">Look4Sat-BA7OPF 是基于 Look4Sat(GPL-3.0)的非官方修改版,由 BA7OPF 独立维护,与原项目无隶属关系,亦未获其背书。
上游项目:https://github.com/rt-bishop/Look4Sat</string>
<string name="prefs_outro_license">基于 GNU GPL v3 许可 · 本程序不提供任何保修</string>
<!-- 匹配页 Mutual match page -->
<string name="mutual_title">过境匹配</string>
@@ -374,4 +410,92 @@
<string name="update_check_download_error">下载失败,请重试。</string>
<string name="update_check_no_apk">该版本未附带 APK 安装包。</string>
<!-- QSO edit dialog -->
<string name="qso_edit_title">编辑通联记录</string>
<string name="qso_edit_uploaded_notice">该通联已上传或已确认。保存将保留原记录不变,并新建一条记录。</string>
<string name="qso_edit_callsign">呼号</string>
<string name="qso_edit_date">日期 (UTC)</string>
<string name="qso_edit_time">时间 (UTC)</string>
<string name="qso_edit_tx">上行频率 (MHz)</string>
<string name="qso_edit_rx">下行频率 (MHz)</string>
<string name="qso_edit_band">频段:%1$s</string>
<string name="qso_edit_mode">模式</string>
<string name="qso_edit_satellite">卫星</string>
<string name="qso_edit_hint_satellite">LoTW 只接受它的官方卫星名</string>
<string name="qso_edit_sent">发送报告</string>
<string name="qso_edit_received">接收报告</string>
<string name="qso_edit_grid">对方网格</string>
<string name="qso_edit_comment">备注</string>
<string name="qso_edit_save">保存</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_datetime">日期或时间格式不对 —— 应为 yyyy-MM-dd 与 HH:mm:ss(UTC)</string>
<string name="qso_edit_err_frequency">频率格式不对 —— 请输入 MHz,例如 145.850</string>
<string name="qso_edit_err_satellite">不是 LoTW 官方卫星名 —— 请从列表中选一个</string>
<string name="qso_edit_satellite_pick">选择</string>
<string name="qso_edit_satellite_pick_title">LoTW 官方卫星名</string>
<string name="qso_edit_satellite_search">搜索卫星名或数据源别名</string>
<string name="qso_edit_satellite_official">保存并签名时使用 %1$s</string>
<string name="qso_edit_warn_no_freq">没有上行频率:LoTW 要求 BAND,补上频率前这条无法上传</string>
<!-- 网格寻线(VUCC 网格线/网格角实地导航) -->
<string name="gridfinder_title">网格寻线</string>
<string name="gridfinder_status_corner">位于网格点上</string>
<string name="gridfinder_status_line">位于网格线上</string>
<string name="gridfinder_status_inside">在网格内部</string>
<string name="gridfinder_status_unknown">位置不可用</string>
<string name="gridfinder_status_waiting">正在获取定位…</string>
<string name="gridfinder_status_blocked">需要定位权限</string>
<string name="gridfinder_status_disabled">本机定位服务未开启</string>
<string name="gridfinder_status_confirmed">已由 %1$d 次连续满足 20 英尺规则的定位确认</string>
<string name="gridfinder_status_unconfirmed">尚未确认:精度达 6.1 m 的连续定位 %1$d/%2$d 次</string>
<string name="gridfinder_grant_permission">授予定位权限</string>
<string name="gridfinder_card_location">位置</string>
<string name="gridfinder_card_corner">最近网格点引导</string>
<string name="gridfinder_card_line">最近网格线引导</string>
<string name="gridfinder_card_lines">两条边界线</string>
<string name="gridfinder_card_map">近距离网格图</string>
<string name="gridfinder_card_fix">定位质量</string>
<string name="gridfinder_card_compass">罗盘</string>
<string name="gridfinder_label_claimable">此处可计分的网格</string>
<string name="gridfinder_label_grid4">网格 (4)</string>
<string name="gridfinder_label_grid6">网格 (6)</string>
<string name="gridfinder_label_grid8">网格 (8)</string>
<string name="gridfinder_label_latitude">纬度</string>
<string name="gridfinder_label_longitude">经度</string>
<string name="gridfinder_label_altitude">海拔</string>
<string name="gridfinder_label_accuracy">水平精度</string>
<string name="gridfinder_label_fix_age">定位时刻</string>
<string name="gridfinder_label_provider">定位来源</string>
<string name="gridfinder_value_provider_on">已开启</string>
<string name="gridfinder_value_provider_off">未开启</string>
<string name="gridfinder_label_distance">距离</string>
<string name="gridfinder_label_bearing">方位角</string>
<string name="gridfinder_label_direction">方向</string>
<string name="gridfinder_label_grids">网格</string>
<string name="gridfinder_label_lat_line">纬线</string>
<string name="gridfinder_label_lon_line">经线</string>
<string name="gridfinder_line_value">位于%2$s方向 %1$s</string>
<string name="gridfinder_line_latitude">这是纬度边界,东西走向</string>
<string name="gridfinder_line_longitude">这是经度边界,南北走向</string>
<string name="gridfinder_label_heading">朝向</string>
<string name="gridfinder_label_compass_accuracy">罗盘精度</string>
<string name="gridfinder_action_set_station">设为我的台址</string>
<string name="gridfinder_action_station_saved">台址已更新 —— 过境预报将使用这个位置</string>
<string name="gridfinder_action_calibrate">校准罗盘</string>
<string name="gridfinder_map_legend">中心点是你的位置,虚线是正北与正东,直线是两条网格边界,绿点是最近的网格点,圆环是最近网格线的垂足,红色指针是你的朝向</string>
<string name="gridfinder_compass_disabled_hint">设置里未开启罗盘,表盘按正北朝上显示。方位角数字仍可引导你</string>
<string name="gridfinder_compass_unavailable_hint">本机没有磁力传感器 —— 请按方位角数字导航</string>
<string name="gridfinder_compass_hint">请平持手机,箭头指向应走的方向</string>
<string name="gridfinder_vucc_rule_note">VUCC 20 英尺规则:%1$.1f m</string>
<string name="gridfinder_direction_north">北</string>
<string name="gridfinder_direction_south">南</string>
<string name="gridfinder_direction_east">东</string>
<string name="gridfinder_direction_west">西</string>
<string name="gridfinder_action_set_lotw_station">设为 LoTW 上传台址(%1$s)</string>
<string name="gridfinder_lotw_station_hint">预填上传台站网格:上传时 LoTW 按这些格记录(线上 2 格、角上 4 格);证书呼号保持不变。</string>
</resources>
+134 -12
View File
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Commmon -->
<string name="app_name" translatable="false">Look4Sat</string>
<string name="app_name" translatable="false">Look4Sat-BA7OPF</string>
<string name="btn_accept">Accept</string>
<string name="btn_cancel">Cancel</string>
<string name="empty_list_message">No data to show</string>
@@ -51,8 +51,6 @@
<string name="pass_welcome_message">
Make sure to set your position via GPS, Lat/Lon or QTH in Settings.
\n\nPlease update the database at least weekly to get accurate predictions.</string>
<string name="pass_whatsnew_title" translatable="false">What\'s new in Look4Sat</string>
<string name="pass_whatsnew_message">All changes since 4.4.6 (the 4.4.7 series):\n\n• Grid-mode map: LoTW confirmed grids in green, roamed grids with blue stripes, current grid highlighted; mode switch pill at the map\'s top-right.\n\n• First-call labels (VUCC view): show the first callsign worked in each green grid instead of the grid code; non-worked cells stay empty.\n\n• Worked grids filtered by operated grid (with an All option); tap a grid to see its QSO detail dialog; per-grid QSO counts.\n\n• Per-operated-grid VUCC counting; polar grid labels; antimeridian fixes; consistent roamed-stripe density.\n\n• WAPC/WAJA map labels follow the system language.\n\n• Grid-mode viewport and award filter survive page switches.\n\n• LoTW sync: direct login, full/incremental modes, live progress, cancel button, auto-sync on app start with its own toggle, last-sync time shown.\n\n• Pass history: filter can look back over past passes (Time before).\n\n• Compass calibration: figure-eight accuracy meter plus manual heading offset.\n\n• AMSAT status page: consecutive same-status report counts.\n\n• Passes/radar: UTC toggle recalculates passes; sunrise/sunset and elevation color fixes; satellite fuzzy search; mutual page keeps scroll, follows UTC, radar shortcut.\n\n• CW decoder for 32/64-bit devices; Android 17 (API 37) support.</string>
<!-- AMSAT Status screen -->
<string name="nav_amsat">AMSAT</string>
@@ -162,18 +160,16 @@
<string name="map_visible">Visible</string>
<!-- Settings screen -->
<string name="prefs_app_title" translatable="false">Look4Sat v%s</string>
<string name="prefs_app_url" translatable="false">https://play.google.com/store/apps/details?id=com.rtbishop.look4sat</string>
<string name="prefs_donate_title">Donate</string>
<string name="prefs_donate_url" translatable="false">https://ko-fi.com/rt_bishop</string>
<string name="prefs_fdroid_title" translatable="false">F-Droid</string>
<string name="prefs_fdroid_url" translatable="false">https://f-droid.org/en/packages/com.rtbishop.look4sat/</string>
<string name="prefs_app_title" translatable="false">Look4Sat-BA7OPF v%s</string>
<string name="prefs_app_url" translatable="false">https://github.com/atsunatsu/Look4Sat/releases</string>
<string name="prefs_github_title" translatable="false">GitHub</string>
<string name="prefs_github_url" translatable="false">https://github.com/rt-bishop/Look4Sat/</string>
<string name="prefs_github_url" translatable="false">https://github.com/atsunatsu/Look4Sat</string>
<string name="prefs_license_title" translatable="false">GPLv3</string>
<string name="prefs_license_url" translatable="false">https://www.gnu.org/licenses/gpl-3.0.html</string>
<string name="prefs_privacy_title">Privacy Policy</string>
<string name="prefs_privacy_url" translatable="false">https://sites.google.com/view/look4sat-privacy-policy/home</string>
<string name="prefs_upstream_title" translatable="false">Upstream Project</string>
<string name="prefs_upstream_url" translatable="false">https://github.com/rt-bishop/Look4Sat</string>
<string name="prefs_updated_title">Updated: %s</string>
<string name="prefs_updated_time">d MMM yyyy - HH:mm:ss</string>
@@ -199,6 +195,16 @@
<string name="prefs_wavelog_hint">Worked grids synced: %1$d. Format: API key, colon, logbook ID. Just tap Sync — credentials are saved automatically.</string>
<string name="prefs_wavelog_sync">Sync now</string>
<string name="prefs_wavelog_syncing">Syncing…</string>
<string name="prefs_wavelog_upload_title">Wavelog log upload</string>
<string name="prefs_wavelog_upload_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_callsign">LoTW callsign</string>
<string name="prefs_lotw_password">LoTW password</string>
@@ -230,6 +236,16 @@
<string name="prefs_logbook_upload">Upload</string>
<string name="prefs_logbook_upload_title">Upload to LoTW</string>
<string name="prefs_logbook_upload_confirm">Upload</string>
<string name="prefs_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_wavelog_sync">Also syncs to Wavelog: %1$d QSO(s)</string>
<string name="lotw_upload_grid_title">Upload check</string>
<string name="lotw_upload_grid_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_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>
@@ -264,6 +280,8 @@
<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_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_tle_switch">Custom TLE URL</string>
<string name="prefs_data_sources_transceivers_switch">Custom transceivers URL</string>
@@ -334,6 +352,7 @@
<string name="prefs_compass_offset">Manual heading offset (°)</string>
<string name="prefs_compass_offset_support">Range −180° to +180°. Positive values rotate clockwise.</string>
<string name="prefs_compass_offset_reset">Reset offset</string>
<string name="prefs_other_switch_light_theme">Light theme</string>
<string name="prefs_other_switch_night_mode">Enable red night mode filter</string>
<string name="prefs_other_switch_map_grid">Show Maidenhead grid on map</string>
@@ -342,7 +361,20 @@
<string name="prefs_highlight_mid">Mid %1$d–%2$d°</string>
<string name="prefs_highlight_high">High &gt; %1$d°</string>
<string name="prefs_outro_title">I would like to say thanks to</string>
<string name="prefs_outro_title">Credits &amp; Notices</string>
<string name="prefs_project_wiki">Project Wiki</string>
<string name="project_wiki_intro">Look4Sat-BA7OPF is a customized fork of Look4Sat. All upstream features, plus:</string>
<string name="project_wiki_section_logbook">Satellite Logbook &amp; LoTW</string>
<string name="project_wiki_logbook_1">Satellite logbook — one-tap logging of callsign, time, frequency, mode, report and grid.</string>
<string name="project_wiki_logbook_2">Direct LoTW upload — import your ARRL certificate and upload logs to LoTW right from the app, no PC needed.</string>
<string name="project_wiki_logbook_3">Confirmation sync — one-tap sync of LoTW confirmations, marked as uploaded/confirmed. Editing an uploaded entry creates a new one, leaving the original untouched.</string>
<string name="project_wiki_logbook_how">How: tap Record after a QSO → Upload on the log page → Sync in Settings.</string>
<string name="project_wiki_section_mutual">Mutual Pass Matching</string>
<string name="project_wiki_mutual_1">Enter your QTH and a friend\'s to find passes visible to both; tap a pass to open the radar.</string>
<string name="project_wiki_mutual_how">How: Match tab → set both stations → Query.</string>
<string name="project_wiki_section_grids">Worked-Grid Map</string>
<string name="project_wiki_grids_1">Grids synced from LoTW/Wavelog shown in green, with award boundaries and first-QSO callsign labels; tap a grid to see its QSOs and match a friend.</string>
<string name="project_wiki_grids_how">How: enable Grid mode on the map → sync LoTW/Wavelog in Settings.</string>
<string name="prefs_outro_thanks" translatable="false">
* Look4Sat users and contributors!
\n* David A. B. Johnson (predict4java)
@@ -355,7 +387,9 @@
\n* BH2VSQ BG5JSU (TianDiTu rendering support)
\n* BG5JSU (LoTW upload support)
</string>
<string name="prefs_outro_license">The app comes with no warranty</string>
<string name="prefs_outro_notice">Look4Sat-BA7OPF is an unofficial modified build based on Look4Sat (GPL-3.0), independently maintained by BA7OPF. It is not affiliated with or endorsed by the original Look4Sat project.
Upstream: https://github.com/rt-bishop/Look4Sat</string>
<string name="prefs_outro_license">Licensed under the GNU General Public License v3 · The app comes with no warranty</string>
<!-- Mutual match page -->
<string name="mutual_title">Pass Match</string>
@@ -404,4 +438,92 @@
<string name="update_check_download_error">Download failed. Try again.</string>
<string name="update_check_no_apk">No APK attached to this release.</string>
<!-- QSO edit dialog -->
<string name="qso_edit_title">Edit QSO</string>
<string name="qso_edit_uploaded_notice">This QSO was already uploaded or confirmed. Saving keeps the original record unchanged and creates a new one.</string>
<string name="qso_edit_callsign">Callsign</string>
<string name="qso_edit_date">Date (UTC)</string>
<string name="qso_edit_time">Time (UTC)</string>
<string name="qso_edit_tx">Uplink (MHz)</string>
<string name="qso_edit_rx">Downlink (MHz)</string>
<string name="qso_edit_band">Band: %1$s</string>
<string name="qso_edit_mode">Mode</string>
<string name="qso_edit_satellite">Satellite</string>
<string name="qso_edit_hint_satellite">LoTW only accepts its official satellite name</string>
<string name="qso_edit_sent">Sent RST</string>
<string name="qso_edit_received">Received RST</string>
<string name="qso_edit_grid">Opposite grid</string>
<string name="qso_edit_comment">Comment</string>
<string name="qso_edit_save">Save</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_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_satellite">Not an ARRL satellite name — pick one from the list</string>
<string name="qso_edit_satellite_pick">Pick</string>
<string name="qso_edit_satellite_pick_title">ARRL satellite name</string>
<string name="qso_edit_satellite_search">Search name or catalogue alias</string>
<string name="qso_edit_satellite_official">Saved and signed as %1$s</string>
<string name="qso_edit_warn_no_freq">No uplink frequency: LoTW requires a band, so this QSO cannot be uploaded yet</string>
<!-- Grid Finder (VUCC grid line / corner field guide) -->
<string name="gridfinder_title">Grid Finder</string>
<string name="gridfinder_status_corner">ON GRID POINT</string>
<string name="gridfinder_status_line">ON GRID LINE</string>
<string name="gridfinder_status_inside">INSIDE GRID</string>
<string name="gridfinder_status_unknown">Position unavailable</string>
<string name="gridfinder_status_waiting">Waiting for a fix…</string>
<string name="gridfinder_status_blocked">Location permission required</string>
<string name="gridfinder_status_disabled">Location services are off on this device</string>
<string name="gridfinder_status_confirmed">Confirmed by %1$d consecutive fixes inside the 20 ft rule</string>
<string name="gridfinder_status_unconfirmed">Not confirmed yet: %1$d of %2$d consecutive fixes good to 6.1 m</string>
<string name="gridfinder_grant_permission">Grant location access</string>
<string name="gridfinder_card_location">Location</string>
<string name="gridfinder_card_corner">Guide to the nearest grid point</string>
<string name="gridfinder_card_line">Guide to the nearest grid line</string>
<string name="gridfinder_card_lines">Both boundary lines</string>
<string name="gridfinder_card_map">Close-up grid map</string>
<string name="gridfinder_card_fix">Fix quality</string>
<string name="gridfinder_card_compass">Compass</string>
<string name="gridfinder_label_claimable">VUCC grids here</string>
<string name="gridfinder_label_grid4">Grid (4)</string>
<string name="gridfinder_label_grid6">Grid (6)</string>
<string name="gridfinder_label_grid8">Grid (8)</string>
<string name="gridfinder_label_latitude">Latitude</string>
<string name="gridfinder_label_longitude">Longitude</string>
<string name="gridfinder_label_altitude">Altitude</string>
<string name="gridfinder_label_accuracy">Horizontal accuracy</string>
<string name="gridfinder_label_fix_age">Fix age</string>
<string name="gridfinder_label_provider">Location provider</string>
<string name="gridfinder_value_provider_on">On</string>
<string name="gridfinder_value_provider_off">Off</string>
<string name="gridfinder_label_distance">Distance</string>
<string name="gridfinder_label_bearing">Bearing</string>
<string name="gridfinder_label_direction">Direction</string>
<string name="gridfinder_label_grids">Grids</string>
<string name="gridfinder_label_lat_line">Latitude line</string>
<string name="gridfinder_label_lon_line">Longitude line</string>
<string name="gridfinder_line_value">%1$s to the %2$s</string>
<string name="gridfinder_line_latitude">This is the latitude boundary, which runs east to west</string>
<string name="gridfinder_line_longitude">This is the longitude boundary, which runs north to south</string>
<string name="gridfinder_label_heading">Heading</string>
<string name="gridfinder_label_compass_accuracy">Compass accuracy</string>
<string name="gridfinder_action_set_station">Set as my station</string>
<string name="gridfinder_action_station_saved">Station position updated — pass predictions now use this fix</string>
<string name="gridfinder_action_calibrate">Calibrate compass</string>
<string name="gridfinder_map_legend">The centre dot is you, dashed axes are north and east, the straight lines are the two grid boundaries, the green dot is the nearest grid point, the ring is the closest point of the nearest line and the red needle is your heading</string>
<string name="gridfinder_compass_disabled_hint">The compass is switched off in Settings, so the dials stay north-up. The bearing numbers still guide you</string>
<string name="gridfinder_compass_unavailable_hint">This device has no magnetic sensor — navigate with the bearing numbers</string>
<string name="gridfinder_compass_hint">Hold the phone flat; the arrow points the way to walk</string>
<string name="gridfinder_vucc_rule_note">VUCC 20 ft rule: %1$.1f m</string>
<string name="gridfinder_direction_north">north</string>
<string name="gridfinder_direction_south">south</string>
<string name="gridfinder_direction_east">east</string>
<string name="gridfinder_direction_west">west</string>
<string name="gridfinder_action_set_lotw_station">Set as LoTW station (%1$s)</string>
<string name="gridfinder_lotw_station_hint">Prefills the upload station grid — LoTW then records these squares (2 on a line, 4 on a corner). The certificate callsign stays unchanged.</string>
</resources>
@@ -1,3 +1,21 @@
/*
* 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/>.
*/
/*
* CwDecodeScreen.kt — Look4Sat 的 CW 解码页面(Compose 外壳)。
*
Loaded 100 of 138 files, more files were not shown because too many files have changed in this diff. Show more