mirror of
https://github.com/atsunatsu/Look4Sat.git
synced 2026-10-09 22:07:23 +00:00
Compare commits
36
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ccf1cff351 | ||
|
|
5d792a0fc8 | ||
|
|
83ce7229f6 | ||
|
|
782b438a07 | ||
|
|
fac319b215 | ||
|
|
b7ac9c913a | ||
|
|
de3d5a6d84 | ||
|
|
5e39c945b2 | ||
|
|
1d07f76ada | ||
|
|
d471f02370 | ||
|
|
9699d142fa | ||
|
|
6f3e3351c9 | ||
|
|
d4a2fae0cd | ||
|
|
d0bbb97f29 | ||
|
|
c2fc5688b9 | ||
|
|
796056a582 | ||
|
|
9cac33dae7 | ||
|
|
082266e385 | ||
|
|
a42a5f1f0d | ||
|
|
7cdc2952dc | ||
|
|
bd51044690 | ||
|
|
1982c2d3dc | ||
|
|
2f4e3f5802 | ||
|
|
24eebdef74 | ||
|
|
3f5b48f270 | ||
|
|
060fa2dfcd | ||
|
|
8b5960282f | ||
|
|
10eb84690e | ||
|
|
bf25292bf8 | ||
|
|
8fbfcb3712 | ||
|
|
602b1553a2 | ||
|
|
bd0881fb4b | ||
|
|
a9dd3e6e55 | ||
|
|
905995ab44 | ||
|
|
f9806d7f7f | ||
|
|
2d3adfc6a6 |
No files matched your search
@@ -4,3 +4,7 @@ updates:
|
|||||||
directory: "/"
|
directory: "/"
|
||||||
schedule:
|
schedule:
|
||||||
interval: "weekly"
|
interval: "weekly"
|
||||||
|
- package-ecosystem: "bundler"
|
||||||
|
directory: "/"
|
||||||
|
schedule:
|
||||||
|
interval: "never"
|
||||||
@@ -19,7 +19,7 @@ jobs:
|
|||||||
uses: actions/checkout@v7
|
uses: actions/checkout@v7
|
||||||
|
|
||||||
- name: Setup Java
|
- name: Setup Java
|
||||||
uses: actions/setup-java@v5
|
uses: actions/setup-java@v6
|
||||||
with:
|
with:
|
||||||
distribution: 'temurin'
|
distribution: 'temurin'
|
||||||
java-version: '21'
|
java-version: '21'
|
||||||
|
|||||||
+1
-5
@@ -1,9 +1,6 @@
|
|||||||
# Built application files
|
# Built application files
|
||||||
*.ap_
|
*.ap_
|
||||||
|
|
||||||
# Hermes AI plans and metadata
|
|
||||||
.hermes/
|
|
||||||
|
|
||||||
# Files for the ART/Dalvik VM
|
# Files for the ART/Dalvik VM
|
||||||
*.dex
|
*.dex
|
||||||
|
|
||||||
@@ -18,7 +15,6 @@ out/
|
|||||||
# Gradle files
|
# Gradle files
|
||||||
.gradle/
|
.gradle/
|
||||||
build/
|
build/
|
||||||
.cxx/
|
|
||||||
|
|
||||||
# Local configuration file (sdk path, etc)
|
# Local configuration file (sdk path, etc)
|
||||||
local.properties
|
local.properties
|
||||||
@@ -68,4 +64,4 @@ fastlane/readme.md
|
|||||||
/app/release/output-metadata.json
|
/app/release/output-metadata.json
|
||||||
/app/release/
|
/app/release/
|
||||||
/.kotlin/sessions/
|
/.kotlin/sessions/
|
||||||
dist/
|
.hermes/
|
||||||
@@ -8,16 +8,15 @@ All assistant-specific files (`CLAUDE.md`, `.github/copilot-instructions.md`) po
|
|||||||
## Project Overview
|
## Project Overview
|
||||||
|
|
||||||
Look4Sat is an open-source, fully offline Android satellite tracker and pass predictor. It tracks 9000+ active
|
Look4Sat is an open-source, fully offline Android satellite tracker and pass predictor. It tracks 9000+ active
|
||||||
satellites using TLE/OMM data from Celestrak/SatNOGS, calculates orbital positions via SGP4/SDP4 models, and displays
|
satellites using Celestrak/SatNOGS orbital data, calculates positions via SGP4/SDP4, and predicts passes relative to
|
||||||
passes relative to the user's location. Features include polar radar visualization, SSTV image decoding, satellite
|
the user's location. Features include polar radar visualization, SSTV image decoding, and ground track mapping. No ads,
|
||||||
ground track mapping, and pass predictions up to 10 days ahead. No ads, no tracking, no network required after initial
|
no tracking, no network required after initial data download.
|
||||||
data download.
|
|
||||||
|
|
||||||
## Architecture
|
## Architecture & Design
|
||||||
|
|
||||||
**MVI (Model-View-Intent)** with unidirectional data flow:
|
**MVI (Model-View-Intent)** with unidirectional data flow:
|
||||||
- `State` data class → exposed via `StateFlow` from ViewModel
|
- `State` data class (named `<Feature>State`) exposed via `StateFlow` from ViewModel
|
||||||
- `Action` sealed interface → user intents dispatched to ViewModel's `onAction()`
|
- `Action` sealed interface (named `<Feature>Action`) dispatched to ViewModel's `onAction()`
|
||||||
- Jetpack Compose UI observes state and recomposes reactively
|
- Jetpack Compose UI observes state and recomposes reactively
|
||||||
|
|
||||||
**Clean Architecture layers:**
|
**Clean Architecture layers:**
|
||||||
@@ -34,9 +33,11 @@ data download.
|
|||||||
| `feature:satellites` | Satellite list, filtering, selection |
|
| `feature:satellites` | Satellite list, filtering, selection |
|
||||||
| `feature:settings` | User preferences |
|
| `feature:settings` | User preferences |
|
||||||
|
|
||||||
- `feature:*` modules depend only on `core:domain` + `core:presentation`. Features never depend on each other.
|
**Feature isolation:**
|
||||||
|
- `feature:*` modules depend only on `core:domain` and `core:presentation`.
|
||||||
|
- No feature-to-feature dependencies; cross-feature communication goes through core layers.
|
||||||
|
|
||||||
## Build & Run
|
## Build & Platform
|
||||||
|
|
||||||
```shell
|
```shell
|
||||||
# Debug build
|
# Debug build
|
||||||
@@ -50,54 +51,55 @@ data download.
|
|||||||
```
|
```
|
||||||
|
|
||||||
- **Min SDK**: 24 | **Target SDK**: 36 | **JDK**: 17
|
- **Min SDK**: 24 | **Target SDK**: 36 | **JDK**: 17
|
||||||
- **Gradle**: Uses version catalog (`gradle/libs.versions.toml`) + convention plugins in `build-logic/`
|
- **Gradle**: Version catalog in `gradle/libs.versions.toml` + convention plugins in `build-logic/`
|
||||||
|
|
||||||
## Key Libraries
|
## Tech Stack
|
||||||
|
|
||||||
- **Compose** (BOM 2026.05.01) + Material3 Adaptive
|
- **Compose** (BOM 2026.05.01) + Material3 Adaptive
|
||||||
- **Navigation3** (type-safe, uses `@Serializable` NavKeys)
|
- **Navigation3**: Type-safe navigation with `@Serializable` nav keys
|
||||||
- **Room** (KSP code generation) for local satellite/TLE storage
|
- **Room** (KSP code generation) for local satellite/orbital storage
|
||||||
- **OkHttp** 5.x for TLE downloads
|
- **OkHttp** 5.x for data downloads
|
||||||
- **OSMDroid** for map rendering
|
- **OSMDroid** for map rendering
|
||||||
- **Kotlin Serialization** for navigation args and data parsing
|
- **Kotlin Serialization** for navigation args and parsing
|
||||||
- **Coroutines** + `StateFlow` for async/reactive patterns
|
- **Coroutines** + `StateFlow` for async/reactive patterns
|
||||||
|
- **Localization**: 7 languages (en, es, ru, si, tr, uk, zh)
|
||||||
## Conventions
|
|
||||||
|
|
||||||
- **Minimal dependencies**: Avoid adding libraries when a simple manual solution exists. Fewer deps = less maintenance.
|
|
||||||
- **DI**: Manual — ViewModels use companion `factory()` methods with `IMainContainer` interface.
|
|
||||||
- **Navigation**: Type-safe Compose Navigation3 with `@Serializable` data classes as nav keys.
|
|
||||||
- **State naming**: `<Feature>State` data class + `<Feature>Action` sealed interface per feature.
|
|
||||||
- **No feature-to-feature deps**: All cross-feature communication goes through core layers.
|
|
||||||
- **Localization**: 7 languages (en, es, ru, si, tr, uk, zh).
|
|
||||||
|
|
||||||
## Data Formats & Migration
|
## Data Formats & Migration
|
||||||
|
|
||||||
**TLE vs. OMM/CSV format:**
|
Look4Sat supports both TLE and OMM (Orbit Mean-Elements Message) CSV formats:
|
||||||
|
|
||||||
Look4Sat supports both TLE and OMM (Orbit Mean-Elements Message) formats for backward compatibility:
|
- **TLE format**: Legacy 3-line element format limited by 5-digit NORAD IDs
|
||||||
|
- **OMM/CSV format**: Successor format with ISO 8601 timestamps and larger NORAD ID support
|
||||||
|
- New 5-digit NORAD IDs are exhausted; TLE is officially deprecated and OMM/CSV is the clear default
|
||||||
|
- `DataParser.kt` supports both via `parseTLEStream()` and `parseCSVStream()`
|
||||||
|
- Downloads auto-detect format; both produce identical `OrbitalData` objects
|
||||||
|
- Existing code already supports transparent source transition without feature changes
|
||||||
|
- Refresh orbital data weekly for accurate pass prediction (orbital decay)
|
||||||
|
|
||||||
- **TLE format**: Traditional 3-line element format (deprecated). NORAD catalog numbers are 5-digit integers, which
|
## Engineering Heuristics (Lazy = Efficient)
|
||||||
are running out of space. Celestrak has signaled that TLE format will eventually be phased out.
|
|
||||||
- **OMM/CSV format**: The future standard. CSV files contain the same orbital parameters as TLE but use ISO 8601
|
|
||||||
timestamps and support larger NORAD IDs. Celestrak and SatNOGS already provide OMM data in CSV format.
|
|
||||||
|
|
||||||
**Current implementation:**
|
- Treat "lazy" as efficient, not careless: the best code is the code never written.
|
||||||
- `DataParser.kt` handles both `parseTLEStream()` and `parseCSVStream()` seamlessly
|
- First understand the task and trace the real flow end-to-end, then climb this ladder:
|
||||||
- TLE data is downloaded from configured sources and stored in Room database
|
1. Does this need to be built now? (YAGNI)
|
||||||
- When downloading satellite data, the app automatically detects format and parses accordingly
|
2. Does it already exist in this codebase? Reuse helpers/patterns before rewriting.
|
||||||
- Both formats produce identical `OrbitalData` objects, ensuring transparent format switching
|
3. Does Kotlin/Java stdlib already solve it?
|
||||||
|
4. Does the Android/platform API already solve it?
|
||||||
|
5. Does an already-installed dependency solve it?
|
||||||
|
6. Can this be simpler (including one-liner simple)?
|
||||||
|
7. Only then: write the minimum code that works.
|
||||||
|
- Prefer deletion to addition, boring over clever, and the fewest touched files.
|
||||||
|
- Avoid new abstractions, dependencies, and boilerplate unless explicitly requested.
|
||||||
|
- Manual DI only: ViewModels use companion `factory()` methods with `IMainContainer`.
|
||||||
|
- Release builds use ProGuard: avoid reflection-heavy libraries unless explicitly approved.
|
||||||
|
- When two options are similar in size, choose the edge-case-correct one.
|
||||||
|
- If you keep a deliberate simplification (for example O(n^2) scan or global lock), leave a short comment with the ceiling and upgrade path.
|
||||||
|
- For complex asks, challenge scope when appropriate: "Do you need X, or does Y already cover it?"
|
||||||
|
|
||||||
**Migration path:**
|
## Bug-Fix Policy
|
||||||
As NORAD catalog space becomes constrained, OMM/CSV will become the primary format. Look4Sat is already positioned
|
|
||||||
to handle this transition without code changes — existing users can continue using TLE files while new sources
|
|
||||||
transition to OMM/CSV automatically.
|
|
||||||
|
|
||||||
## Code Style
|
- Fix root cause, not just the reported symptom.
|
||||||
|
- If touching a shared function, inspect callers and prefer one shared fix over per-caller patches.
|
||||||
- Prefer **short, focused functions** — single responsibility, easy to read.
|
- The smallest correct diff wins only after behavior is understood.
|
||||||
- **Exceptions**: Composable functions and math-heavy algorithms (SGP4/SDP4) may be longer.
|
|
||||||
- Strict code style — no dead code, no unused imports, consistent formatting.
|
|
||||||
|
|
||||||
## Roadmap
|
## Roadmap
|
||||||
|
|
||||||
@@ -105,23 +107,9 @@ transition to OMM/CSV automatically.
|
|||||||
|
|
||||||
## Gotchas
|
## Gotchas
|
||||||
|
|
||||||
- Orbital math lives in `core:domain/predict/` — it's dense vector math (SGP4/SDP4). Tread carefully.
|
- Orbital math lives in `core:domain/predict/` — dense vector math (SGP4/SDP4). Tread carefully.
|
||||||
- TLE/OMM data must be refreshed weekly for accurate predictions (satellite orbits decay). TLE format is legacy and
|
|
||||||
will eventually be deprecated in favor of OMM/CSV as NORAD catalog numbers approach the 5-digit limit.
|
|
||||||
- SSTV decoding in `feature:radar` is experimental; image quality depends on signal strength during satellite pass.
|
- SSTV decoding in `feature:radar` is experimental; image quality depends on signal strength during satellite pass.
|
||||||
- `build-logic/convention/` contains all shared Gradle configuration — edit there, not in individual modules.
|
- `build-logic/convention/` contains 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
|
## Copilot Working Mode: Code-Only
|
||||||
|
|
||||||
|
|||||||
@@ -1,44 +1,45 @@
|
|||||||
# Look4Sat-BA7OPF
|
# Look4Sat: Satellite tracker
|
||||||
|
|
||||||
[](https://github.com/atsunatsu/Look4Sat/releases)
|
[](https://github.com/rt-bishop/Look4Sat/actions/workflows/release.yml)
|
||||||
|
|
||||||
**BA7OPF 定制版** — 基于 [rt-bishop/Look4Sat](https://github.com/rt-bishop/Look4Sat) 的业余无线电卫星追踪器,增加了线性卫星频率计算器等功能。
|
[<img src="https://play.google.com/intl/en_gb/badges/static/images/badges/en_badge_web_generic.png" alt="Get it on Google Play" height="80">](https://play.google.com/store/apps/details?id=com.rtbishop.look4sat)
|
||||||
|
[<img src="https://fdroid.gitlab.io/artwork/badge/get-it-on.png" alt="Get it on F-Droid" height="80">](https://f-droid.org/packages/com.rtbishop.look4sat/)
|
||||||
|
|
||||||
非官方修改版(fork),独立维护;首次发布 2026-09,持续修改中。
|
### Radio satellite tracker and pass predictor for Android, inspired by Gpredict
|
||||||
|
|
||||||
## 本仓库特色功能
|
<p float="left">
|
||||||
|
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/1.png" width="192"/>
|
||||||
|
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/2.png" width="192"/>
|
||||||
|
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/3.png" width="192"/>
|
||||||
|
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/4.png" width="192">
|
||||||
|
</p>
|
||||||
|
|
||||||
- **网格地图模式(Grid Mode)** — 地图页内置 VUCC 网格覆盖图:已通联网格绿色填充、漫游网格蓝色 45° 斜纹(疏朗半透明样式)、当前所在方格淡黄加粗框;点击已通联网格弹出该网格内通联明细弹窗;进入网格模式默认 VUCC 视图
|
### Track satellite passes with ease!
|
||||||
- **奖状边界模式(Award Boundaries)** — 在网格地图上叠加 DXCC / WAPC / WAJA / WAZ / WAS 国界·省界·分区边界,已确认区域绿色填充
|
|
||||||
- **LoTW 网格同步** — 直连 ARRL Logbook(账号密码验证)下载确认报告,worked / roamed 网格自动更新(兼容任意字段顺序的 ADIF 解析);同步失败按凭据错误/限流/超时分类提示
|
|
||||||
- **双站过境匹配(Mutual Pass)** — 输入友台经纬度/网格,筛选双方同时可见的卫星过境,在地图上同时显示双方仰角曲线和地面轨迹,支持一键跳转雷达页查看详情
|
|
||||||
- **线性卫星转发器频率计算器** — 在雷达页的 Calculator 标签页中,支持 TX/RX 双向多普勒频率计算,以及下行频率偏移(offset)输入,方便操作带偏移的线性卫星
|
|
||||||
- **CW 解码器** — 集成 Morse Expert 解码引擎,支持瀑布图、实时解码文本(arm64 + armeabi-v7a 双架构)
|
|
||||||
- **AMSAT 状态页** — 实时查看卫星的业余无线电转发器状态(开启/关闭/待机),支持 72 小时历史记录回放,启动时预取缓存
|
|
||||||
- **应用内更新检查** — 设置页一键检查 GitHub 最新 Release,展示版本说明并直接下载安装
|
|
||||||
- **自定义 TLE 数据源** — 可添加自定义 TLE/Celestrak CSV 数据源,长按拖拽排序,数据源启停开关与 HTTP 状态码显示
|
|
||||||
- **中文界面优化** — 翻译修正、UI 布局调整,系统语言自动切换
|
|
||||||
|
|
||||||
## 上游仓库
|
Thanks to [Celestrak](https://celestrak.com/) and [SatNOGS](https://satnogs.org/) you have access to over 9000 active satellites.\
|
||||||
|
You can search the entire database by NORAD Catalog Number or the satellite's name.
|
||||||
|
|
||||||
本仓库是 [rt-bishop/Look4Sat](https://github.com/rt-bishop/Look4Sat) 的分支,上游仓库的原始功能包括:
|
Orbital positions and passes are calculated relative to your location.\
|
||||||
|
To get reliable data make sure to set the station position via the app Settings.
|
||||||
|
|
||||||
- 基于 Celestrak / SatNOGS 数据的 9000+ 活跃卫星追踪
|
The application is built using Kotlin, Coroutines, Jetpack Compose and Navigation.\
|
||||||
- SGP4/SDP4 轨道预测,10 天过境预报
|
It is now and always will be completely ad-free and open-source.
|
||||||
- 极坐标雷达图、地面轨迹图
|
|
||||||
- SSTV 图像解码
|
|
||||||
- 无广告、无跟踪、完全离线
|
|
||||||
|
|
||||||
## 许可证
|
## Main features:
|
||||||
|
|
||||||
GNU General Public License v3.0。详见 [LICENSE](LICENSE)。
|
* Predicting satellite positions and passes for up to 10 days
|
||||||
|
* Showing the list of currently active and upcoming satellite passes
|
||||||
|
* Showing the active pass progress, polar trajectory and transceivers info
|
||||||
|
* Showing the satellite positional data, footprint and ground track on the map
|
||||||
|
* Custom TLE satellite data import is available via Three Line Element .txt files
|
||||||
|
* Offline first: calculations are made offline. Weekly TLE data update is recommended.
|
||||||
|
|
||||||
## Star History
|
## Star History
|
||||||
|
|
||||||
<a href="https://star-history.dera.page/#atsunatsu/Look4Sat&type=timeline&legend=top-left">
|
<a href="https://star-history.dera.page/#rt-bishop/Look4Sat&type=timeline&legend=top-left">
|
||||||
<picture>
|
<picture>
|
||||||
<source media="(prefers-color-scheme: dark)" srcset="https://star-history.dera.page/svg?repos=atsunatsu/Look4Sat&type=timeline&theme=dark&legend=top-left" />
|
<source media="(prefers-color-scheme: dark)" srcset="https://star-history.dera.page/svg?repos=rt-bishop/Look4Sat&type=timeline&theme=dark&legend=top-left" />
|
||||||
<source media="(prefers-color-scheme: light)" srcset="https://star-history.dera.page/svg?repos=atsunatsu/Look4Sat&type=timeline&legend=top-left" />
|
<source media="(prefers-color-scheme: light)" srcset="https://star-history.dera.page/svg?repos=rt-bishop/Look4Sat&type=timeline&legend=top-left" />
|
||||||
<img alt="Star History Chart" src="https://star-history.dera.page/svg?repos=atsunatsu/Look4Sat&type=timeline&legend=top-left" />
|
<img alt="Star History Chart" src="https://star-history.dera.page/svg?repos=rt-bishop/Look4Sat&type=timeline&legend=top-left" />
|
||||||
</picture>
|
</picture>
|
||||||
</a>
|
</a>
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
# 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.
|
|
||||||
|
|
||||||
## OpenStreetMap (award boundary geometry)
|
|
||||||
|
|
||||||
- Source: https://www.openstreetmap.org — relations `Ural Mountains` (21368163),
|
|
||||||
`England` (58447), `Scotland` (58446), `Wales` (58437),
|
|
||||||
`Northern Ireland` (156393)
|
|
||||||
- License: Open Database License (ODbL) 1.0 —
|
|
||||||
https://opendatacommons.org/licenses/odbl/1-0/
|
|
||||||
- Used for: the Europe/Asia split line of European/Asiatic Russia and the
|
|
||||||
England/Scotland/Wales/Northern Ireland partitions in
|
|
||||||
`feature/map/src/main/assets/awards/dxcc.json` (derived geometry).
|
|
||||||
Map tiles displayed in the app are © OpenStreetMap contributors and carry
|
|
||||||
their own in-app attribution.
|
|
||||||
|
|
||||||
## Natural Earth
|
|
||||||
|
|
||||||
- Source: https://www.naturalearthdata.com (1:110m countries, 1:10m admin-0
|
|
||||||
map units, 1:10m rivers)
|
|
||||||
- License: Public domain — https://www.naturalearthdata.com/about/terms-of-use/
|
|
||||||
- Used for: the country/region polygons of the award boundary assets under
|
|
||||||
`feature/map/src/main/assets/awards/` (including the island / microstate
|
|
||||||
DXCC entities — Singapore, Malta, Canary Is., ... — and the Balearic/Canary
|
|
||||||
rings split out of Spain's 1:10m multipolygon), and the Ural river centerline
|
|
||||||
used for the European/Asiatic Russia split.
|
|
||||||
@@ -1,41 +1,3 @@
|
|||||||
import java.io.FileInputStream
|
|
||||||
import java.util.Properties
|
|
||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
alias(libs.plugins.convention.applicationPlugin)
|
alias(libs.plugins.convention.applicationPlugin)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Release signing credentials live in local.properties (gitignored) or
|
|
||||||
// environment variables — never hardcode passwords in VCS.
|
|
||||||
val releaseProps = Properties().apply {
|
|
||||||
val propsFile = rootProject.file("local.properties")
|
|
||||||
if (propsFile.exists()) FileInputStream(propsFile).use { load(it) }
|
|
||||||
}
|
|
||||||
fun releaseCred(name: String): String =
|
|
||||||
releaseProps.getProperty(name) ?: System.getenv(name) ?: ""
|
|
||||||
|
|
||||||
android {
|
|
||||||
namespace = libs.versions.packageName.get()
|
|
||||||
defaultConfig {
|
|
||||||
applicationId = "cn.ba7opf.look4sat"
|
|
||||||
// CW decoder ships for both ABIs (see feature/cw jniLibs); the APK must
|
|
||||||
// not be pinned to 32-bit only or 64-bit devices fall back to ARM32.
|
|
||||||
ndk {
|
|
||||||
abiFilters.add("armeabi-v7a")
|
|
||||||
abiFilters.add("arm64-v8a")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
signingConfigs {
|
|
||||||
create("release") {
|
|
||||||
storeFile = file(System.getProperty("user.home") + "/my-release-key.jks")
|
|
||||||
storePassword = releaseCred("RELEASE_STORE_PASSWORD")
|
|
||||||
keyAlias = releaseCred("RELEASE_KEY_ALIAS").ifEmpty { "look4sat" }
|
|
||||||
keyPassword = releaseCred("RELEASE_KEY_PASSWORD")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
buildTypes {
|
|
||||||
release {
|
|
||||||
signingConfig = signingConfigs.getByName("release")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -13,7 +13,6 @@
|
|||||||
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" />
|
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" />
|
||||||
<uses-permission android:name="android.permission.INTERNET" />
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:name=".MainApplication"
|
android:name=".MainApplication"
|
||||||
@@ -26,7 +25,6 @@
|
|||||||
<activity
|
<activity
|
||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
android:screenOrientation="portrait"
|
|
||||||
android:theme="@style/Theme.Look4Sat.SplashScreen">
|
android:theme="@style/Theme.Look4Sat.SplashScreen">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.MAIN" />
|
<action android:name="android.intent.action.MAIN" />
|
||||||
@@ -44,17 +42,7 @@
|
|||||||
|
|
||||||
<meta-data
|
<meta-data
|
||||||
android:name="android.telephony.PROPERTY_SATELLITE_DATA_OPTIMIZED"
|
android:name="android.telephony.PROPERTY_SATELLITE_DATA_OPTIMIZED"
|
||||||
android:value="cn.ba7opf.look4sat" />
|
android:value="com.rtbishop.look4sat" />
|
||||||
|
|
||||||
<provider
|
|
||||||
android:name="androidx.core.content.FileProvider"
|
|
||||||
android:authorities="${applicationId}.fileprovider"
|
|
||||||
android:exported="false"
|
|
||||||
android:grantUriPermissions="true">
|
|
||||||
<meta-data
|
|
||||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
|
||||||
android:resource="@xml/file_paths" />
|
|
||||||
</provider>
|
|
||||||
|
|
||||||
</application>
|
</application>
|
||||||
</manifest>
|
</manifest>
|
||||||
@@ -27,9 +27,9 @@ import android.view.View
|
|||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import androidx.activity.enableEdgeToEdge
|
import androidx.activity.enableEdgeToEdge
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
|
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
|
||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.lifecycle.lifecycleScope
|
||||||
import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
|
import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
|
||||||
import com.rtbishop.look4sat.core.presentation.MainTheme
|
import com.rtbishop.look4sat.core.presentation.MainTheme
|
||||||
@@ -49,11 +49,10 @@ class MainActivity : ComponentActivity() {
|
|||||||
installSplashScreen()
|
installSplashScreen()
|
||||||
enableEdgeToEdge()
|
enableEdgeToEdge()
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
val settingsRepo = (applicationContext as IContainerProvider).getMainContainer().settingsRepo
|
|
||||||
observeNightFilterState()
|
observeNightFilterState()
|
||||||
|
val settingsRepo = (applicationContext as IContainerProvider).getMainContainer().settingsRepo
|
||||||
setContent {
|
setContent {
|
||||||
val otherSettings by settingsRepo.otherSettings.collectAsStateWithLifecycle()
|
val otherSettings by settingsRepo.otherSettings.collectAsState()
|
||||||
// Light theme is opt-in: the app stays dark until the user turns it on.
|
|
||||||
MainTheme(isDarkTheme = !otherSettings.stateOfLightTheme) { NavRoot() }
|
MainTheme(isDarkTheme = !otherSettings.stateOfLightTheme) { NavRoot() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,21 +17,10 @@
|
|||||||
*/
|
*/
|
||||||
package com.rtbishop.look4sat
|
package com.rtbishop.look4sat
|
||||||
|
|
||||||
import android.app.Activity
|
|
||||||
import android.app.Application
|
import android.app.Application
|
||||||
import android.app.Application.ActivityLifecycleCallbacks
|
|
||||||
import android.os.Bundle
|
|
||||||
import com.rtbishop.look4sat.core.data.injection.MainContainer
|
import com.rtbishop.look4sat.core.data.injection.MainContainer
|
||||||
import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
|
import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
|
||||||
import com.rtbishop.look4sat.core.domain.repository.IMainContainer
|
import com.rtbishop.look4sat.core.domain.repository.IMainContainer
|
||||||
import com.rtbishop.look4sat.core.domain.repository.LoTWSyncMode
|
|
||||||
import com.rtbishop.look4sat.core.domain.repository.LoTWResult
|
|
||||||
import com.rtbishop.look4sat.core.domain.logbook.toConfirmedRecord
|
|
||||||
import com.rtbishop.look4sat.core.domain.repository.applyLoTWGridResult
|
|
||||||
import com.rtbishop.look4sat.core.domain.repository.lotwCursorApi
|
|
||||||
import com.rtbishop.look4sat.core.domain.repository.lotwSyncToday
|
|
||||||
import com.rtbishop.look4sat.core.domain.repository.resolveLoTWSyncMode
|
|
||||||
import com.rtbishop.look4sat.core.domain.repository.shouldAutoSyncLoTW
|
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import java.text.SimpleDateFormat
|
import java.text.SimpleDateFormat
|
||||||
import java.util.Date
|
import java.util.Date
|
||||||
@@ -46,42 +35,12 @@ class MainApplication : Application(), IContainerProvider {
|
|||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
super.onCreate()
|
super.onCreate()
|
||||||
container = MainContainer(this)
|
container = MainContainer(this)
|
||||||
clearAmSatCacheOnAppBackground()
|
|
||||||
// trigger automatic update every 48 hours
|
// trigger automatic update every 48 hours
|
||||||
container.appScope.launch { checkAutoUpdate() }
|
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
|
// load satellite data on every app start
|
||||||
container.appScope.launch { container.satelliteRepo.initRepository() }
|
container.appScope.launch { container.satelliteRepo.initRepository() }
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun clearAmSatCacheOnAppBackground() {
|
|
||||||
registerActivityLifecycleCallbacks(object : ActivityLifecycleCallbacks {
|
|
||||||
private var startedActivityCount = 0
|
|
||||||
|
|
||||||
override fun onActivityStarted(activity: Activity) {
|
|
||||||
if (startedActivityCount == 0) {
|
|
||||||
container.appScope.launch { container.amSatRepo.prefetchStatus() }
|
|
||||||
}
|
|
||||||
startedActivityCount += 1
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onActivityStopped(activity: Activity) {
|
|
||||||
startedActivityCount = (startedActivityCount - 1).coerceAtLeast(0)
|
|
||||||
if (startedActivityCount == 0) container.amSatRepo.clearStatusCache()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) = Unit
|
|
||||||
override fun onActivityResumed(activity: Activity) = Unit
|
|
||||||
override fun onActivityPaused(activity: Activity) = Unit
|
|
||||||
override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) = Unit
|
|
||||||
override fun onActivityDestroyed(activity: Activity) = Unit
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun checkAutoUpdate(timeNow: Long = System.currentTimeMillis()) {
|
private suspend fun checkAutoUpdate(timeNow: Long = System.currentTimeMillis()) {
|
||||||
if (container.settingsRepo.otherSettings.value.stateOfAutoUpdate) {
|
if (container.settingsRepo.otherSettings.value.stateOfAutoUpdate) {
|
||||||
val timeDelta = timeNow - container.settingsRepo.databaseState.value.updateTimestamp
|
val timeDelta = timeNow - container.settingsRepo.databaseState.value.updateTimestamp
|
||||||
@@ -92,45 +51,4 @@ class MainApplication : Application(), IContainerProvider {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Automatic LoTW grid sync, mirroring [checkAutoUpdate]: checked on every
|
|
||||||
* app start, but only pulls when due — LoTW credentials configured, the
|
|
||||||
* LoTW auto-sync toggle on, at least one prior (manual) sync, and not
|
|
||||||
* already synced today (ARRL limits report pulls, ~once a day per account).
|
|
||||||
* Incremental when the callsign is unchanged; a callsign change falls back
|
|
||||||
* to a full replace so no stale grids from another account linger.
|
|
||||||
* Failures are silent and simply retried on the next app start — the manual
|
|
||||||
* sync button still surfaces the explicit cause to the user.
|
|
||||||
*/
|
|
||||||
private suspend fun checkLoTWAutoSync(timeNow: Long = System.currentTimeMillis()) {
|
|
||||||
val settingsRepo = container.settingsRepo
|
|
||||||
// One-of-two rule: while Wavelog is configured the app uploads/syncs through
|
|
||||||
// Wavelog only — the automatic LoTW sync stays off.
|
|
||||||
if (settingsRepo.wavelogUploadSettings.value.isReady) return
|
|
||||||
val lotwSettings = settingsRepo.lotwSettings.value
|
|
||||||
if (!shouldAutoSyncLoTW(
|
|
||||||
isConfigured = lotwSettings.isConfigured,
|
|
||||||
autoLotwSyncEnabled = settingsRepo.otherSettings.value.stateOfAutoLotwSync,
|
|
||||||
lastSyncDate = settingsRepo.getLastLotwSyncDate(),
|
|
||||||
today = lotwSyncToday(timeNow)
|
|
||||||
)
|
|
||||||
) return
|
|
||||||
val callsign = lotwSettings.callsign.trim().uppercase()
|
|
||||||
val mode = resolveLoTWSyncMode(settingsRepo.getLastLotwSyncCallsign(), callsign, requested = null)
|
|
||||||
val since = if (mode == LoTWSyncMode.Incremental) lotwCursorApi(settingsRepo.getLastLotwSyncDate()) else ""
|
|
||||||
println("Started periodic LoTW grid sync (${mode.name.lowercase()})")
|
|
||||||
val result = container.lotwRepo.fetchConfirmedGridQsos(callsign, lotwSettings.password, since)
|
|
||||||
if (result is LoTWResult.Success) {
|
|
||||||
val count = applyLoTWGridResult(settingsRepo, result, mode, callsign, timeNow)
|
|
||||||
// Feed the logbook so confirmations appear and local uploads get matched.
|
|
||||||
runCatching {
|
|
||||||
val confirmed = result.qsos.values.flatten().map { it.toConfirmedRecord(callsign) }
|
|
||||||
container.qsoRepository.mergeLoTW(confirmed)
|
|
||||||
}
|
|
||||||
println("Periodic LoTW grid sync finished: $count worked grids")
|
|
||||||
} else {
|
|
||||||
println("Periodic LoTW grid sync skipped (${result::class.simpleName})")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -51,8 +51,6 @@ import androidx.compose.runtime.Composable
|
|||||||
import androidx.compose.runtime.CompositionLocalProvider
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
@@ -63,35 +61,27 @@ import androidx.compose.ui.res.stringResource
|
|||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import androidx.lifecycle.ViewModelStoreOwner
|
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
|
||||||
import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator
|
import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator
|
||||||
import androidx.navigation3.runtime.entryProvider
|
import androidx.navigation3.runtime.entryProvider
|
||||||
import androidx.navigation3.runtime.rememberNavBackStack
|
import androidx.navigation3.runtime.rememberNavBackStack
|
||||||
import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator
|
import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator
|
||||||
import androidx.navigation3.ui.NavDisplay
|
import androidx.navigation3.ui.NavDisplay
|
||||||
import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
|
import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
|
||||||
import com.rtbishop.look4sat.core.domain.repository.MutualPassData
|
import com.rtbishop.look4sat.core.presentation.R
|
||||||
|
import com.rtbishop.look4sat.core.domain.repository.RadioTrackingState
|
||||||
import com.rtbishop.look4sat.core.presentation.DeeplinkResolver
|
import com.rtbishop.look4sat.core.presentation.DeeplinkResolver
|
||||||
import com.rtbishop.look4sat.core.presentation.ElevationThresholds
|
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.LocalElevationThresholds
|
||||||
import com.rtbishop.look4sat.core.presentation.LoTWUploadDestination
|
|
||||||
import com.rtbishop.look4sat.core.presentation.RadarDestination
|
import com.rtbishop.look4sat.core.presentation.RadarDestination
|
||||||
import com.rtbishop.look4sat.core.presentation.Screen
|
import com.rtbishop.look4sat.core.presentation.Screen
|
||||||
import com.rtbishop.look4sat.core.presentation.hasEnoughHeight
|
import com.rtbishop.look4sat.core.presentation.hasEnoughHeight
|
||||||
import com.rtbishop.look4sat.core.presentation.hasEnoughWidth
|
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.MapDestination
|
||||||
import com.rtbishop.look4sat.feature.map.MapFilterViewModel
|
|
||||||
import com.rtbishop.look4sat.feature.mutual.MutualScreen
|
|
||||||
import com.rtbishop.look4sat.feature.mutual.MutualViewModel
|
|
||||||
import com.rtbishop.look4sat.feature.passes.PassesDestination
|
import com.rtbishop.look4sat.feature.passes.PassesDestination
|
||||||
import com.rtbishop.look4sat.feature.radar.RadarDestination
|
import com.rtbishop.look4sat.feature.radar.RadarDestination
|
||||||
import com.rtbishop.look4sat.feature.satellites.SatellitesDestination
|
import com.rtbishop.look4sat.feature.satellites.SatellitesDestination
|
||||||
import com.rtbishop.look4sat.feature.settings.SettingsDestination
|
import com.rtbishop.look4sat.feature.settings.SettingsDestination
|
||||||
import com.rtbishop.look4sat.feature.settings.LoTWUploadDestination
|
|
||||||
import com.rtbishop.look4sat.feature.status.SatStatusDestination
|
import com.rtbishop.look4sat.feature.status.SatStatusDestination
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -103,8 +93,6 @@ fun NavRoot(deeplink: String? = null) {
|
|||||||
}
|
}
|
||||||
val navigateBack: () -> Unit = { rootBackStack.removeLastOrNull() }
|
val navigateBack: () -> Unit = { rootBackStack.removeLastOrNull() }
|
||||||
val navigateToRadar: () -> Unit = { rootBackStack.add(RadarDestination) }
|
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)
|
// Incoming screen slides in from the right, outgoing drifts left at 1/3 speed (API35+ style)
|
||||||
val pushTransition = slideInHorizontally(tween(300)) { it } togetherWith
|
val pushTransition = slideInHorizontally(tween(300)) { it } togetherWith
|
||||||
slideOutHorizontally(tween(300)) { -it / 3 }
|
slideOutHorizontally(tween(300)) { -it / 3 }
|
||||||
@@ -136,37 +124,13 @@ fun NavRoot(deeplink: String? = null) {
|
|||||||
rememberViewModelStoreNavEntryDecorator()
|
rememberViewModelStoreNavEntryDecorator()
|
||||||
),
|
),
|
||||||
entryProvider = entryProvider {
|
entryProvider = entryProvider {
|
||||||
entry<Screen.Passes> {
|
entry<Screen.Passes> { MainScreen(navigateToRadar = navigateToRadar) }
|
||||||
MainScreen(
|
|
||||||
navigateToRadar = navigateToRadar,
|
|
||||||
navigateToGridFinder = navigateToGridFinder
|
|
||||||
)
|
|
||||||
}
|
|
||||||
entry<RadarDestination> {
|
entry<RadarDestination> {
|
||||||
Surface(
|
Surface(
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
color = MaterialTheme.colorScheme.background
|
color = MaterialTheme.colorScheme.background
|
||||||
) {
|
) {
|
||||||
RadarDestination(navigateUp = navigateBack, onOpenLoTWStation = navigateToLoTWStation)
|
RadarDestination(navigateUp = navigateBack)
|
||||||
}
|
|
||||||
}
|
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -175,202 +139,139 @@ fun NavRoot(deeplink: String? = null) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun MainScreen(
|
private fun MainScreen(navigateToRadar: () -> Unit = {}) {
|
||||||
navigateToRadar: () -> Unit = {},
|
|
||||||
navigateToGridFinder: () -> Unit = {}
|
|
||||||
) {
|
|
||||||
val backStack = rememberNavBackStack(Screen.Passes)
|
|
||||||
val currentKey = backStack.lastOrNull()
|
|
||||||
val navigateBack: () -> Unit = { backStack.removeLastOrNull() }
|
|
||||||
val fadeTransition = fadeIn(animationSpec = tween(350)) togetherWith fadeOut(animationSpec = tween(350))
|
|
||||||
// Map sits at position 4 from the left, matching the upstream nav order
|
|
||||||
// (Satellites, Passes, AMSAT/Status, Map, ...), with the fork's Mutual tab
|
|
||||||
// appended before Settings.
|
|
||||||
val navItems = listOf(Screen.Satellites, Screen.Passes, Screen.AMSAT, Screen.Map, Screen.Mutual, Screen.Settings)
|
|
||||||
|
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val container = (context.applicationContext as IContainerProvider).getMainContainer()
|
val container = (context.applicationContext as IContainerProvider).getMainContainer()
|
||||||
val trackingState by container.radioTrackingService.state.collectAsStateWithLifecycle()
|
val trackingState by container.radioTrackingService.state.collectAsStateWithLifecycle()
|
||||||
val otherSettings by container.settingsRepo.otherSettings.collectAsStateWithLifecycle()
|
|
||||||
// Activity-scoped so the mutual query results survive navigation to Radar and back
|
|
||||||
val mutualViewModel: MutualViewModel = viewModel(
|
|
||||||
viewModelStoreOwner = context as ViewModelStoreOwner,
|
|
||||||
factory = MutualViewModel.factory(container)
|
|
||||||
)
|
|
||||||
// Activity-scoped so the map's award filter survives page switches;
|
|
||||||
// resets to VUCC only on cold start (fresh process).
|
|
||||||
val mapFilterViewModel: MapFilterViewModel = viewModel(
|
|
||||||
viewModelStoreOwner = context as ViewModelStoreOwner,
|
|
||||||
factory = MapFilterViewModel.factory()
|
|
||||||
)
|
|
||||||
|
|
||||||
// Map grid-QSO dialog "Match" button: pre-fill the match page for that
|
val backStack = rememberNavBackStack(Screen.Passes)
|
||||||
// grid and run the query BEFORE navigating, so the page's first frame
|
val currentKey = backStack.lastOrNull()
|
||||||
// already contains the results (time-range card at the top, pass curves
|
val navigateBack: () -> Unit = { backStack.removeLastOrNull() }
|
||||||
// right below — no scroll flicker). Navigation happens once the query
|
val fadeTransition = fadeIn(animationSpec = tween(300)) togetherWith
|
||||||
// finishes (see the collector below).
|
fadeOut(animationSpec = tween(300))
|
||||||
val mutualState by mutualViewModel.uiState.collectAsStateWithLifecycle()
|
val navItems =
|
||||||
val matchCalculating = mutualState.pendingNavigation && mutualState.isCalculating
|
listOf(Screen.Satellites, Screen.Passes, Screen.Status, Screen.Map, Screen.Settings)
|
||||||
|
|
||||||
LaunchedEffect(mutualViewModel) {
|
NavigationSuiteScaffold(
|
||||||
mutualViewModel.uiState.collect { state ->
|
navigationSuiteItems = {
|
||||||
if (state.pendingNavigation && !state.isCalculating) {
|
navItems.forEach { screen ->
|
||||||
mutualViewModel.consumePendingNavigation()
|
val isSelected = when (currentKey) {
|
||||||
// Push Mutual on top of the Map entry instead of replacing the
|
is Screen.Satellites -> screen is Screen.Satellites
|
||||||
// stack (bottom-nav style): the system back gesture then pops
|
is Screen.Passes -> screen is Screen.Passes
|
||||||
// back to the map page, which is the page the user came from.
|
is Screen.Status -> screen is Screen.Status
|
||||||
if (backStack.lastOrNull() !is Screen.Mutual) {
|
is Screen.Map -> screen is Screen.Map
|
||||||
backStack.add(Screen.Mutual)
|
is Screen.Settings -> screen is Screen.Settings
|
||||||
|
else -> false
|
||||||
}
|
}
|
||||||
}
|
item(
|
||||||
}
|
icon = {
|
||||||
}
|
Icon(
|
||||||
|
painter = painterResource(screen.iconResId),
|
||||||
CompositionLocalProvider(
|
contentDescription = stringResource(screen.titleResId)
|
||||||
LocalElevationThresholds provides ElevationThresholds(
|
)
|
||||||
low = otherSettings.lowElevation,
|
},
|
||||||
high = otherSettings.highElevation
|
label = { Text(stringResource(screen.titleResId)) },
|
||||||
)
|
selected = isSelected,
|
||||||
) {
|
onClick = {
|
||||||
NavigationSuiteScaffold(
|
if (isSelected) return@item
|
||||||
navigationSuiteItems = {
|
while (backStack.size > 1) backStack.removeAt(backStack.size - 1)
|
||||||
navItems.forEach { screen ->
|
if (screen !is Screen.Passes) backStack.add(screen)
|
||||||
val isSelected = when (currentKey) {
|
|
||||||
is Screen.Satellites -> screen is Screen.Satellites
|
|
||||||
is Screen.Passes -> screen is Screen.Passes
|
|
||||||
is Screen.Map -> screen is Screen.Map
|
|
||||||
is Screen.AMSAT -> screen is Screen.AMSAT
|
|
||||||
is Screen.Mutual -> screen is Screen.Mutual
|
|
||||||
is Screen.Settings -> screen is Screen.Settings
|
|
||||||
else -> false
|
|
||||||
}
|
}
|
||||||
item(
|
)
|
||||||
icon = { Icon(painterResource(screen.iconResId), stringResource(screen.titleResId)) },
|
|
||||||
label = { Text(stringResource(screen.titleResId)) },
|
|
||||||
selected = isSelected,
|
|
||||||
onClick = {
|
|
||||||
if (isSelected) return@item
|
|
||||||
while (backStack.size > 1) backStack.removeAt(backStack.size - 1)
|
|
||||||
if (screen !is Screen.Passes) backStack.add(screen)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
navigationSuiteColors = NavigationSuiteDefaults.colors(
|
|
||||||
navigationRailContainerColor = MaterialTheme.colorScheme.surfaceContainer
|
|
||||||
),
|
|
||||||
layoutType = when {
|
|
||||||
!hasEnoughHeight() && hasEnoughWidth() -> NavigationSuiteType.NavigationRail
|
|
||||||
!hasEnoughWidth() -> NavigationSuiteType.ShortNavigationBarCompact
|
|
||||||
else -> NavigationSuiteType.ShortNavigationBarMedium
|
|
||||||
}
|
}
|
||||||
) {
|
},
|
||||||
Column {
|
navigationSuiteColors = NavigationSuiteDefaults.colors(
|
||||||
NavDisplay(
|
navigationRailContainerColor = MaterialTheme.colorScheme.surfaceContainer
|
||||||
backStack = backStack,
|
),
|
||||||
modifier = Modifier.weight(1f),
|
layoutType = when {
|
||||||
onBack = navigateBack,
|
!hasEnoughHeight() && hasEnoughWidth() -> NavigationSuiteType.NavigationRail
|
||||||
transitionSpec = { fadeTransition },
|
!hasEnoughWidth() -> NavigationSuiteType.ShortNavigationBarCompact
|
||||||
popTransitionSpec = { fadeTransition },
|
else -> NavigationSuiteType.ShortNavigationBarMedium
|
||||||
predictivePopTransitionSpec = { fadeTransition },
|
}
|
||||||
entryDecorators = listOf(
|
) {
|
||||||
// Required for saving Compose state per entry
|
Column {
|
||||||
rememberSaveableStateHolderNavEntryDecorator(),
|
NavDisplay(
|
||||||
// Required for ViewModel scoping per entry
|
backStack = backStack,
|
||||||
rememberViewModelStoreNavEntryDecorator()
|
modifier = Modifier.weight(1f),
|
||||||
),
|
onBack = navigateBack,
|
||||||
entryProvider = entryProvider {
|
transitionSpec = { fadeTransition },
|
||||||
entry<Screen.Satellites> {
|
popTransitionSpec = { fadeTransition },
|
||||||
SatellitesDestination(navigateUp = navigateBack)
|
predictivePopTransitionSpec = { fadeTransition },
|
||||||
|
entryDecorators = listOf(
|
||||||
|
rememberSaveableStateHolderNavEntryDecorator(),
|
||||||
|
rememberViewModelStoreNavEntryDecorator()
|
||||||
|
),
|
||||||
|
entryProvider = entryProvider {
|
||||||
|
entry<Screen.Satellites> {
|
||||||
|
SatellitesDestination(navigateUp = navigateBack)
|
||||||
|
}
|
||||||
|
entry<Screen.Passes> {
|
||||||
|
PassesDestination { catNum, aosTime ->
|
||||||
|
container.satelliteRepo.selectPass(catNum, aosTime)
|
||||||
|
navigateToRadar()
|
||||||
}
|
}
|
||||||
entry<Screen.Passes> {
|
}
|
||||||
PassesDestination(
|
entry<Screen.Status> { SatStatusDestination() }
|
||||||
navigateToRadar = { catNum, aosTime ->
|
entry<Screen.Map> { MapDestination() }
|
||||||
container.setMutualPassData(MutualPassData())
|
entry<Screen.Settings> { SettingsDestination() }
|
||||||
container.satelliteRepo.selectPass(catNum, aosTime)
|
}
|
||||||
navigateToRadar()
|
)
|
||||||
}
|
if (trackingState.isActive) {
|
||||||
)
|
TrackingBanner(
|
||||||
}
|
state = trackingState,
|
||||||
entry<Screen.Map> {
|
onClick = {
|
||||||
MapDestination(
|
val pass = trackingState.currentPass
|
||||||
mapFilterViewModel = mapFilterViewModel,
|
if (pass != null) {
|
||||||
matchCalculating = matchCalculating,
|
container.satelliteRepo.selectPass(pass.catNum, pass.aosTime)
|
||||||
onMatchGrid = { grid ->
|
navigateToRadar()
|
||||||
// Grid-QSO dialog "Match" button: pre-fill
|
|
||||||
// the match page for that grid and start the
|
|
||||||
// query. Navigation to the match page is
|
|
||||||
// handled by the uiState collector above,
|
|
||||||
// once the query finishes.
|
|
||||||
mutualViewModel.prefillMatchFromGrid(grid)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
entry<Screen.Mutual> {
|
|
||||||
MutualScreen(
|
|
||||||
viewModel = mutualViewModel,
|
|
||||||
navigateToRadar = { catNum, aosTime, pass ->
|
|
||||||
container.setMutualPassData(pass ?: MutualPassData())
|
|
||||||
container.satelliteRepo.selectPass(catNum, aosTime)
|
|
||||||
navigateToRadar()
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
entry<Screen.AMSAT> { SatStatusDestination() }
|
|
||||||
entry<Screen.Settings> {
|
|
||||||
SettingsDestination(onOpenGridFinder = navigateToGridFinder)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
// Radio tracking status banner
|
|
||||||
if (trackingState.isActive) {
|
|
||||||
val infiniteTransition = rememberInfiniteTransition(label = "trackingPulse")
|
|
||||||
val alpha by infiniteTransition.animateFloat(
|
|
||||||
initialValue = 1f, targetValue = 0.4f,
|
|
||||||
animationSpec = infiniteRepeatable(
|
|
||||||
animation = tween(1000, easing = LinearEasing),
|
|
||||||
repeatMode = RepeatMode.Reverse
|
|
||||||
), label = "pulseAlpha"
|
|
||||||
)
|
|
||||||
Row(
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.background(MaterialTheme.colorScheme.primaryContainer)
|
|
||||||
.clickable {
|
|
||||||
val pass = trackingState.currentPass
|
|
||||||
if (pass != null) {
|
|
||||||
container.setMutualPassData(MutualPassData())
|
|
||||||
container.satelliteRepo.selectPass(pass.catNum, pass.aosTime)
|
|
||||||
navigateToRadar()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.padding(horizontal = 12.dp, vertical = 6.dp)
|
|
||||||
) {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.size(8.dp)
|
|
||||||
.clip(CircleShape)
|
|
||||||
.background(Color(0xFF4CAF50).copy(alpha = alpha))
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.width(8.dp))
|
|
||||||
Text(
|
|
||||||
text = "Tracking: ${trackingState.currentPass?.name ?: ""}",
|
|
||||||
fontSize = 13.sp,
|
|
||||||
fontWeight = FontWeight.Medium,
|
|
||||||
color = MaterialTheme.colorScheme.onPrimaryContainer,
|
|
||||||
modifier = Modifier.weight(1f)
|
|
||||||
)
|
|
||||||
val txOk = if (trackingState.txConnected) "TX" else ""
|
|
||||||
val rxOk = if (trackingState.rxConnected) "RX" else ""
|
|
||||||
Text(
|
|
||||||
text = listOf(txOk, rxOk).filter { it.isNotBlank() }.joinToString("/"),
|
|
||||||
fontSize = 12.sp,
|
|
||||||
color = MaterialTheme.colorScheme.onPrimaryContainer
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun TrackingBanner(state: RadioTrackingState, onClick: () -> Unit) {
|
||||||
|
val infiniteTransition = rememberInfiniteTransition(label = "trackingPulse")
|
||||||
|
val alpha by infiniteTransition.animateFloat(
|
||||||
|
initialValue = 1f,
|
||||||
|
targetValue = 0.4f,
|
||||||
|
animationSpec = infiniteRepeatable(
|
||||||
|
animation = tween(1000, easing = LinearEasing), repeatMode = RepeatMode.Reverse
|
||||||
|
),
|
||||||
|
label = "pulseAlpha"
|
||||||
|
)
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.background(MaterialTheme.colorScheme.primaryContainer)
|
||||||
|
.clickable(onClick = onClick)
|
||||||
|
.padding(horizontal = 12.dp, vertical = 6.dp)
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(8.dp)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(Color(0xFF4CAF50).copy(alpha = alpha))
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.tracking_format, state.currentPass?.name ?: ""),
|
||||||
|
fontSize = 13.sp,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
color = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
val connections = listOfNotNull(
|
||||||
|
"TX".takeIf { state.txConnected }, "RX".takeIf { state.rxConnected }
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = connections.joinToString("/"),
|
||||||
|
fontSize = 12.sp,
|
||||||
|
color = MaterialTheme.colorScheme.onPrimaryContainer
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<paths xmlns:android="http://schemas.android.com/apk/res/android">
|
|
||||||
<cache-path name="updates" path="." />
|
|
||||||
</paths>
|
|
||||||
-2
@@ -32,8 +32,6 @@ internal class ApplicationPlugin : Plugin<Project> {
|
|||||||
implementation(project(":core:domain"))
|
implementation(project(":core:domain"))
|
||||||
implementation(project(":core:presentation"))
|
implementation(project(":core:presentation"))
|
||||||
implementation(project(":feature:map"))
|
implementation(project(":feature:map"))
|
||||||
implementation(project(":feature:gridfinder"))
|
|
||||||
implementation(project(":feature:mutual"))
|
|
||||||
implementation(project(":feature:passes"))
|
implementation(project(":feature:passes"))
|
||||||
implementation(project(":feature:radar"))
|
implementation(project(":feature:radar"))
|
||||||
implementation(project(":feature:satellites"))
|
implementation(project(":feature:satellites"))
|
||||||
|
|||||||
-3
@@ -35,9 +35,6 @@ internal class CoreDataPlugin : Plugin<Project> {
|
|||||||
ksp(libs.androidx.room.compiler)
|
ksp(libs.androidx.room.compiler)
|
||||||
implementation(libs.kotlin.coroutines)
|
implementation(libs.kotlin.coroutines)
|
||||||
implementation(libs.other.okhttp)
|
implementation(libs.other.okhttp)
|
||||||
// android.jar's org.json is a stub ("not mocked") in unit tests;
|
|
||||||
// provide a real implementation so JSON parsing is testable.
|
|
||||||
testImplementation(libs.test.json)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+1
-7
@@ -74,13 +74,7 @@ internal fun Project.setupAndroidApp() {
|
|||||||
}
|
}
|
||||||
androidResources {
|
androidResources {
|
||||||
generateLocaleConfig = true
|
generateLocaleConfig = true
|
||||||
// "zh" alone is stripped down to the Hans config: without the Traditional
|
localeFilters.addAll(listOf("en", "es", "ru", "si", "tr", "uk", "zh"))
|
||||||
// region entries below, values-b+zh+Hant is filtered out of the APK and a
|
|
||||||
// zh-TW / zh-HK / zh-MO device falls back to English. aapt2 only accepts
|
|
||||||
// the legacy "zh-rTW" spelling here ("zh-Hant" fails the build).
|
|
||||||
localeFilters.addAll(
|
|
||||||
listOf("en", "es", "ru", "si", "tr", "uk", "zh", "zh-rTW", "zh-rHK", "zh-rMO")
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
packaging { resources { excludes += listOf("META-INF/*") } }
|
packaging { resources { excludes += listOf("META-INF/*") } }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,46 @@ plugins {
|
|||||||
alias(libs.plugins.google.ksp) apply false
|
alias(libs.plugins.google.ksp) apply false
|
||||||
alias(libs.plugins.kotlin.jvm) apply false
|
alias(libs.plugins.kotlin.jvm) apply false
|
||||||
alias(libs.plugins.kotlin.serialization) apply false
|
alias(libs.plugins.kotlin.serialization) apply false
|
||||||
|
alias(libs.plugins.other.detekt)
|
||||||
|
}
|
||||||
|
|
||||||
|
val detektCheck = tasks.register<io.gitlab.arturbosch.detekt.Detekt>("detektCheck") {
|
||||||
|
description = "Checks that source code satisfies detekt rules."
|
||||||
|
autoCorrect = false
|
||||||
|
}
|
||||||
|
|
||||||
|
val detektApply = tasks.register<io.gitlab.arturbosch.detekt.Detekt>("detektApply") {
|
||||||
|
description = "Applies code formatting rules to source code in-place."
|
||||||
|
autoCorrect = true
|
||||||
|
}
|
||||||
|
|
||||||
|
configure(listOf(detektCheck, detektApply)) {
|
||||||
|
configure {
|
||||||
|
group = "verification"
|
||||||
|
parallel = true
|
||||||
|
ignoreFailures = false
|
||||||
|
setSource(file(rootDir))
|
||||||
|
|
||||||
|
// Custom detekt config
|
||||||
|
config.setFrom("$projectDir/config/detekt/detekt.yml")
|
||||||
|
|
||||||
|
// Use default configuration on top of custom config
|
||||||
|
// (new detect rules will work out of the box after upgrading detekt version)
|
||||||
|
buildUponDefaultConfig = true
|
||||||
|
|
||||||
|
// Runs detekt for all files in the Gradle project and all subprojects without
|
||||||
|
// a need to configure detekt plugin in every subproject.
|
||||||
|
include("**/*.kt", "**/*.kts")
|
||||||
|
exclude("**/resources/**", "**/build/**", "**/generated/**", "**/testing/**")
|
||||||
|
|
||||||
|
reports {
|
||||||
|
html.required.set(true)
|
||||||
|
xml.required.set(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dependencies {
|
||||||
|
detektPlugins(libs.other.detekt.formatting)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
tasks.register("clean", Delete::class.java) {
|
tasks.register("clean", Delete::class.java) {
|
||||||
|
|||||||
@@ -0,0 +1,808 @@
|
|||||||
|
build:
|
||||||
|
maxIssues: 0
|
||||||
|
excludeCorrectable: false
|
||||||
|
weights:
|
||||||
|
# complexity: 2
|
||||||
|
# LongParameterList: 1
|
||||||
|
# style: 1
|
||||||
|
# comments: 1
|
||||||
|
|
||||||
|
config:
|
||||||
|
validation: true
|
||||||
|
warningsAsErrors: false
|
||||||
|
checkExhaustiveness: false
|
||||||
|
# when writing own rules with new properties, exclude the property path e.g.: 'my_rule_set,.*>.*>[my_property]'
|
||||||
|
excludes: ''
|
||||||
|
|
||||||
|
processors:
|
||||||
|
active: true
|
||||||
|
exclude:
|
||||||
|
- 'DetektProgressListener'
|
||||||
|
# - 'KtFileCountProcessor'
|
||||||
|
# - 'PackageCountProcessor'
|
||||||
|
# - 'ClassCountProcessor'
|
||||||
|
# - 'FunctionCountProcessor'
|
||||||
|
# - 'PropertyCountProcessor'
|
||||||
|
# - 'ProjectComplexityProcessor'
|
||||||
|
# - 'ProjectCognitiveComplexityProcessor'
|
||||||
|
# - 'ProjectLLOCProcessor'
|
||||||
|
# - 'ProjectCLOCProcessor'
|
||||||
|
# - 'ProjectLOCProcessor'
|
||||||
|
# - 'ProjectSLOCProcessor'
|
||||||
|
# - 'LicenseHeaderLoaderExtension'
|
||||||
|
|
||||||
|
console-reports:
|
||||||
|
active: true
|
||||||
|
exclude:
|
||||||
|
- 'ProjectStatisticsReport'
|
||||||
|
- 'ComplexityReport'
|
||||||
|
- 'NotificationReport'
|
||||||
|
- 'FindingsReport'
|
||||||
|
- 'FileBasedFindingsReport'
|
||||||
|
# - 'LiteFindingsReport'
|
||||||
|
|
||||||
|
output-reports:
|
||||||
|
active: true
|
||||||
|
exclude:
|
||||||
|
# - 'TxtOutputReport'
|
||||||
|
# - 'XmlOutputReport'
|
||||||
|
- 'HtmlOutputReport'
|
||||||
|
# - 'MdOutputReport'
|
||||||
|
# - 'SarifOutputReport'
|
||||||
|
|
||||||
|
comments:
|
||||||
|
active: true
|
||||||
|
AbsentOrWrongFileLicense:
|
||||||
|
active: false
|
||||||
|
licenseTemplateFile: 'license.template'
|
||||||
|
licenseTemplateIsRegex: false
|
||||||
|
CommentOverPrivateFunction:
|
||||||
|
active: false
|
||||||
|
CommentOverPrivateProperty:
|
||||||
|
active: false
|
||||||
|
DeprecatedBlockTag:
|
||||||
|
active: false
|
||||||
|
EndOfSentenceFormat:
|
||||||
|
active: false
|
||||||
|
endOfSentenceFormat: '([.?!][ \t\n\r\f<])|([.?!:]$)'
|
||||||
|
KDocReferencesNonPublicProperty:
|
||||||
|
active: false
|
||||||
|
excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**' ]
|
||||||
|
OutdatedDocumentation:
|
||||||
|
active: false
|
||||||
|
matchTypeParameters: true
|
||||||
|
matchDeclarationsOrder: true
|
||||||
|
allowParamOnConstructorProperties: false
|
||||||
|
UndocumentedPublicClass:
|
||||||
|
active: false
|
||||||
|
excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**' ]
|
||||||
|
searchInNestedClass: true
|
||||||
|
searchInInnerClass: true
|
||||||
|
searchInInnerObject: true
|
||||||
|
searchInInnerInterface: true
|
||||||
|
searchInProtectedClass: false
|
||||||
|
UndocumentedPublicFunction:
|
||||||
|
active: false
|
||||||
|
excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**' ]
|
||||||
|
searchProtectedFunction: false
|
||||||
|
UndocumentedPublicProperty:
|
||||||
|
active: false
|
||||||
|
excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**' ]
|
||||||
|
searchProtectedProperty: false
|
||||||
|
|
||||||
|
complexity:
|
||||||
|
active: true
|
||||||
|
CognitiveComplexMethod:
|
||||||
|
active: false
|
||||||
|
threshold: 15
|
||||||
|
ComplexCondition:
|
||||||
|
active: true
|
||||||
|
threshold: 4
|
||||||
|
ComplexInterface:
|
||||||
|
active: false
|
||||||
|
threshold: 10
|
||||||
|
includeStaticDeclarations: false
|
||||||
|
includePrivateDeclarations: false
|
||||||
|
ignoreOverloaded: false
|
||||||
|
CyclomaticComplexMethod:
|
||||||
|
active: true
|
||||||
|
threshold: 15
|
||||||
|
ignoreSingleWhenExpression: false
|
||||||
|
ignoreSimpleWhenEntries: false
|
||||||
|
ignoreNestingFunctions: false
|
||||||
|
nestingFunctions:
|
||||||
|
- 'also'
|
||||||
|
- 'apply'
|
||||||
|
- 'forEach'
|
||||||
|
- 'isNotNull'
|
||||||
|
- 'ifNull'
|
||||||
|
- 'let'
|
||||||
|
- 'run'
|
||||||
|
- 'use'
|
||||||
|
- 'with'
|
||||||
|
# Composables describe UI trees declaratively and naturally accumulate more branches than
|
||||||
|
# regular functions. Exclude them here and rely on code review + decomposition instead.
|
||||||
|
excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**', '**/*Screen.kt', '**/*Slots.kt' ]
|
||||||
|
LabeledExpression:
|
||||||
|
active: false
|
||||||
|
ignoredLabels: [ ]
|
||||||
|
LargeClass:
|
||||||
|
active: true
|
||||||
|
excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**', '**/*Screen.kt', '**/*Slots.kt' ]
|
||||||
|
threshold: 600
|
||||||
|
LongMethod:
|
||||||
|
active: true
|
||||||
|
# Composable UI functions are declarative trees; 60 lines is too strict for them.
|
||||||
|
# Screen/Slots files are excluded — keep them reasonable via code review.
|
||||||
|
excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**', '**/*Screen.kt', '**/*Slots.kt' ]
|
||||||
|
threshold: 60
|
||||||
|
LongParameterList:
|
||||||
|
active: false
|
||||||
|
functionThreshold: 6
|
||||||
|
constructorThreshold: 7
|
||||||
|
ignoreDefaultParameters: false
|
||||||
|
ignoreDataClasses: true
|
||||||
|
ignoreAnnotatedParameter: [ ]
|
||||||
|
MethodOverloading:
|
||||||
|
active: false
|
||||||
|
threshold: 6
|
||||||
|
NamedArguments:
|
||||||
|
active: false
|
||||||
|
threshold: 3
|
||||||
|
ignoreArgumentsMatchingNames: false
|
||||||
|
NestedBlockDepth:
|
||||||
|
active: true
|
||||||
|
threshold: 4
|
||||||
|
NestedScopeFunctions:
|
||||||
|
active: false
|
||||||
|
threshold: 1
|
||||||
|
functions:
|
||||||
|
- 'kotlin.apply'
|
||||||
|
- 'kotlin.run'
|
||||||
|
- 'kotlin.with'
|
||||||
|
- 'kotlin.let'
|
||||||
|
- 'kotlin.also'
|
||||||
|
ReplaceSafeCallChainWithRun:
|
||||||
|
active: false
|
||||||
|
StringLiteralDuplication:
|
||||||
|
active: false
|
||||||
|
excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**' ]
|
||||||
|
threshold: 3
|
||||||
|
ignoreAnnotation: true
|
||||||
|
excludeStringsWithLessThan5Characters: true
|
||||||
|
ignoreStringsRegex: '$^'
|
||||||
|
TooManyFunctions:
|
||||||
|
active: false
|
||||||
|
excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**' ]
|
||||||
|
thresholdInFiles: 11
|
||||||
|
thresholdInClasses: 11
|
||||||
|
thresholdInInterfaces: 11
|
||||||
|
thresholdInObjects: 11
|
||||||
|
thresholdInEnums: 11
|
||||||
|
ignoreDeprecated: false
|
||||||
|
ignorePrivate: false
|
||||||
|
ignoreOverridden: false
|
||||||
|
|
||||||
|
coroutines:
|
||||||
|
active: true
|
||||||
|
GlobalCoroutineUsage:
|
||||||
|
active: false
|
||||||
|
InjectDispatcher:
|
||||||
|
active: true
|
||||||
|
dispatcherNames:
|
||||||
|
- 'IO'
|
||||||
|
- 'Default'
|
||||||
|
- 'Unconfined'
|
||||||
|
RedundantSuspendModifier:
|
||||||
|
active: true
|
||||||
|
SleepInsteadOfDelay:
|
||||||
|
active: true
|
||||||
|
SuspendFunSwallowedCancellation:
|
||||||
|
active: false
|
||||||
|
SuspendFunWithCoroutineScopeReceiver:
|
||||||
|
active: false
|
||||||
|
SuspendFunWithFlowReturnType:
|
||||||
|
active: true
|
||||||
|
|
||||||
|
empty-blocks:
|
||||||
|
active: true
|
||||||
|
EmptyCatchBlock:
|
||||||
|
active: true
|
||||||
|
allowedExceptionNameRegex: '_|(ignore|expected).*'
|
||||||
|
EmptyClassBlock:
|
||||||
|
active: true
|
||||||
|
EmptyDefaultConstructor:
|
||||||
|
active: true
|
||||||
|
EmptyDoWhileBlock:
|
||||||
|
active: true
|
||||||
|
EmptyElseBlock:
|
||||||
|
active: true
|
||||||
|
EmptyFinallyBlock:
|
||||||
|
active: true
|
||||||
|
EmptyForBlock:
|
||||||
|
active: true
|
||||||
|
EmptyFunctionBlock:
|
||||||
|
active: true
|
||||||
|
ignoreOverridden: true
|
||||||
|
EmptyIfBlock:
|
||||||
|
active: true
|
||||||
|
EmptyInitBlock:
|
||||||
|
active: true
|
||||||
|
EmptyKtFile:
|
||||||
|
active: true
|
||||||
|
EmptySecondaryConstructor:
|
||||||
|
active: true
|
||||||
|
EmptyTryBlock:
|
||||||
|
active: true
|
||||||
|
EmptyWhenBlock:
|
||||||
|
active: true
|
||||||
|
EmptyWhileBlock:
|
||||||
|
active: true
|
||||||
|
|
||||||
|
exceptions:
|
||||||
|
active: true
|
||||||
|
ExceptionRaisedInUnexpectedLocation:
|
||||||
|
active: true
|
||||||
|
methodNames:
|
||||||
|
- 'equals'
|
||||||
|
- 'finalize'
|
||||||
|
- 'hashCode'
|
||||||
|
- 'toString'
|
||||||
|
InstanceOfCheckForException:
|
||||||
|
active: true
|
||||||
|
excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**' ]
|
||||||
|
NotImplementedDeclaration:
|
||||||
|
active: false
|
||||||
|
ObjectExtendsThrowable:
|
||||||
|
active: false
|
||||||
|
PrintStackTrace:
|
||||||
|
active: true
|
||||||
|
RethrowCaughtException:
|
||||||
|
active: true
|
||||||
|
ReturnFromFinally:
|
||||||
|
active: true
|
||||||
|
ignoreLabeled: false
|
||||||
|
SwallowedException:
|
||||||
|
active: true
|
||||||
|
ignoredExceptionTypes:
|
||||||
|
- 'InterruptedException'
|
||||||
|
- 'MalformedURLException'
|
||||||
|
- 'NumberFormatException'
|
||||||
|
- 'ParseException'
|
||||||
|
allowedExceptionNameRegex: '_|(ignore|expected).*'
|
||||||
|
ThrowingExceptionFromFinally:
|
||||||
|
active: true
|
||||||
|
ThrowingExceptionInMain:
|
||||||
|
active: false
|
||||||
|
ThrowingExceptionsWithoutMessageOrCause:
|
||||||
|
active: true
|
||||||
|
excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**' ]
|
||||||
|
exceptions:
|
||||||
|
- 'ArrayIndexOutOfBoundsException'
|
||||||
|
- 'Exception'
|
||||||
|
- 'IllegalArgumentException'
|
||||||
|
- 'IllegalMonitorStateException'
|
||||||
|
- 'IllegalStateException'
|
||||||
|
- 'IndexOutOfBoundsException'
|
||||||
|
- 'NullPointerException'
|
||||||
|
- 'RuntimeException'
|
||||||
|
- 'Throwable'
|
||||||
|
ThrowingNewInstanceOfSameException:
|
||||||
|
active: true
|
||||||
|
TooGenericExceptionCaught:
|
||||||
|
active: true
|
||||||
|
excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**' ]
|
||||||
|
exceptionNames:
|
||||||
|
- 'ArrayIndexOutOfBoundsException'
|
||||||
|
- 'Error'
|
||||||
|
- 'Exception'
|
||||||
|
- 'IllegalMonitorStateException'
|
||||||
|
- 'IndexOutOfBoundsException'
|
||||||
|
- 'NullPointerException'
|
||||||
|
- 'RuntimeException'
|
||||||
|
- 'Throwable'
|
||||||
|
allowedExceptionNameRegex: '_|(ignore|expected).*'
|
||||||
|
TooGenericExceptionThrown:
|
||||||
|
active: true
|
||||||
|
exceptionNames:
|
||||||
|
- 'Error'
|
||||||
|
- 'Exception'
|
||||||
|
- 'RuntimeException'
|
||||||
|
- 'Throwable'
|
||||||
|
|
||||||
|
formatting:
|
||||||
|
active: true
|
||||||
|
ArgumentListWrapping:
|
||||||
|
active: false
|
||||||
|
ParameterListWrapping:
|
||||||
|
active: false
|
||||||
|
NoWildcardImports:
|
||||||
|
active: false
|
||||||
|
MaximumLineLength:
|
||||||
|
active: true
|
||||||
|
excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**' ]
|
||||||
|
|
||||||
|
naming:
|
||||||
|
active: true
|
||||||
|
BooleanPropertyNaming:
|
||||||
|
active: false
|
||||||
|
allowedPattern: '^(is|has|are)'
|
||||||
|
ClassNaming:
|
||||||
|
active: true
|
||||||
|
classPattern: '[A-Z][a-zA-Z0-9_]*'
|
||||||
|
ConstructorParameterNaming:
|
||||||
|
active: true
|
||||||
|
parameterPattern: '[a-z][A-Za-z0-9]*'
|
||||||
|
privateParameterPattern: '[a-z][A-Za-z0-9]*'
|
||||||
|
excludeClassPattern: '$^'
|
||||||
|
EnumNaming:
|
||||||
|
active: true
|
||||||
|
enumEntryPattern: '[A-Z][_a-zA-Z0-9]*'
|
||||||
|
ForbiddenClassName:
|
||||||
|
active: false
|
||||||
|
forbiddenName: [ ]
|
||||||
|
FunctionMaxLength:
|
||||||
|
active: false
|
||||||
|
maximumFunctionNameLength: 30
|
||||||
|
FunctionMinLength:
|
||||||
|
active: false
|
||||||
|
minimumFunctionNameLength: 3
|
||||||
|
FunctionNaming:
|
||||||
|
active: true
|
||||||
|
excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**' ]
|
||||||
|
functionPattern: '[a-zA-Z][a-zA-Z0-9]*'
|
||||||
|
excludeClassPattern: '$^'
|
||||||
|
FunctionParameterNaming:
|
||||||
|
active: true
|
||||||
|
parameterPattern: '[a-zA-Z][A-Za-z0-9]*'
|
||||||
|
excludeClassPattern: '$^'
|
||||||
|
InvalidPackageDeclaration:
|
||||||
|
active: true
|
||||||
|
rootPackage: ''
|
||||||
|
requireRootInDeclaration: false
|
||||||
|
LambdaParameterNaming:
|
||||||
|
active: false
|
||||||
|
parameterPattern: '[a-z][A-Za-z0-9]*|_'
|
||||||
|
MatchingDeclarationName:
|
||||||
|
active: false
|
||||||
|
mustBeFirst: false
|
||||||
|
MemberNameEqualsClassName:
|
||||||
|
active: true
|
||||||
|
ignoreOverridden: true
|
||||||
|
NoNameShadowing:
|
||||||
|
active: true
|
||||||
|
NonBooleanPropertyPrefixedWithIs:
|
||||||
|
active: false
|
||||||
|
ObjectPropertyNaming:
|
||||||
|
active: true
|
||||||
|
constantPattern: '[A-Za-z][_A-Za-z0-9]*'
|
||||||
|
propertyPattern: '[A-Za-z][_A-Za-z0-9]*'
|
||||||
|
privatePropertyPattern: '(_)?[A-Za-z][_A-Za-z0-9]*'
|
||||||
|
PackageNaming:
|
||||||
|
active: true
|
||||||
|
packagePattern: '[a-z]+(\.[a-z][A-Za-z0-9]*)*'
|
||||||
|
TopLevelPropertyNaming:
|
||||||
|
active: true
|
||||||
|
constantPattern: '[A-Z][_A-Z0-9]*'
|
||||||
|
propertyPattern: '[A-Za-z][_A-Za-z0-9]*'
|
||||||
|
privatePropertyPattern: '_?[A-Za-z][_A-Za-z0-9]*'
|
||||||
|
VariableMaxLength:
|
||||||
|
active: false
|
||||||
|
maximumVariableNameLength: 64
|
||||||
|
VariableMinLength:
|
||||||
|
active: false
|
||||||
|
minimumVariableNameLength: 1
|
||||||
|
VariableNaming:
|
||||||
|
active: true
|
||||||
|
variablePattern: '[a-zA-Z][A-Za-z0-9]*'
|
||||||
|
privateVariablePattern: '(_)?[a-zA-Z][A-Za-z0-9]*'
|
||||||
|
excludeClassPattern: '$^'
|
||||||
|
|
||||||
|
performance:
|
||||||
|
active: true
|
||||||
|
ArrayPrimitive:
|
||||||
|
active: true
|
||||||
|
CouldBeSequence:
|
||||||
|
active: false
|
||||||
|
threshold: 3
|
||||||
|
ForEachOnRange:
|
||||||
|
active: true
|
||||||
|
excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**' ]
|
||||||
|
SpreadOperator:
|
||||||
|
active: true
|
||||||
|
excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**' ]
|
||||||
|
UnnecessaryPartOfBinaryExpression:
|
||||||
|
active: false
|
||||||
|
UnnecessaryTemporaryInstantiation:
|
||||||
|
active: true
|
||||||
|
|
||||||
|
potential-bugs:
|
||||||
|
active: true
|
||||||
|
AvoidReferentialEquality:
|
||||||
|
active: true
|
||||||
|
forbiddenTypePatterns:
|
||||||
|
- 'kotlin.String'
|
||||||
|
CastNullableToNonNullableType:
|
||||||
|
active: false
|
||||||
|
CastToNullableType:
|
||||||
|
active: false
|
||||||
|
Deprecation:
|
||||||
|
active: false
|
||||||
|
DontDowncastCollectionTypes:
|
||||||
|
active: false
|
||||||
|
DoubleMutabilityForCollection:
|
||||||
|
active: true
|
||||||
|
mutableTypes:
|
||||||
|
- 'kotlin.collections.MutableList'
|
||||||
|
- 'kotlin.collections.MutableMap'
|
||||||
|
- 'kotlin.collections.MutableSet'
|
||||||
|
- 'java.util.ArrayList'
|
||||||
|
- 'java.util.LinkedHashSet'
|
||||||
|
- 'java.util.HashSet'
|
||||||
|
- 'java.util.LinkedHashMap'
|
||||||
|
- 'java.util.HashMap'
|
||||||
|
ElseCaseInsteadOfExhaustiveWhen:
|
||||||
|
active: false
|
||||||
|
ignoredSubjectTypes: [ ]
|
||||||
|
EqualsAlwaysReturnsTrueOrFalse:
|
||||||
|
active: true
|
||||||
|
EqualsWithHashCodeExist:
|
||||||
|
active: true
|
||||||
|
ExitOutsideMain:
|
||||||
|
active: false
|
||||||
|
ExplicitGarbageCollectionCall:
|
||||||
|
active: true
|
||||||
|
HasPlatformType:
|
||||||
|
active: true
|
||||||
|
IgnoredReturnValue:
|
||||||
|
active: true
|
||||||
|
restrictToConfig: true
|
||||||
|
returnValueAnnotations:
|
||||||
|
- 'CheckResult'
|
||||||
|
- '*.CheckResult'
|
||||||
|
- 'CheckReturnValue'
|
||||||
|
- '*.CheckReturnValue'
|
||||||
|
ignoreReturnValueAnnotations:
|
||||||
|
- 'CanIgnoreReturnValue'
|
||||||
|
- '*.CanIgnoreReturnValue'
|
||||||
|
returnValueTypes:
|
||||||
|
- 'kotlin.sequences.Sequence'
|
||||||
|
- 'kotlinx.coroutines.flow.*Flow'
|
||||||
|
- 'java.util.stream.*Stream'
|
||||||
|
ignoreFunctionCall: [ ]
|
||||||
|
ImplicitDefaultLocale:
|
||||||
|
active: true
|
||||||
|
ImplicitUnitReturnType:
|
||||||
|
active: false
|
||||||
|
allowExplicitReturnType: true
|
||||||
|
InvalidRange:
|
||||||
|
active: true
|
||||||
|
IteratorHasNextCallsNextMethod:
|
||||||
|
active: true
|
||||||
|
IteratorNotThrowingNoSuchElementException:
|
||||||
|
active: true
|
||||||
|
LateinitUsage:
|
||||||
|
active: false
|
||||||
|
excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**' ]
|
||||||
|
ignoreOnClassesPattern: ''
|
||||||
|
MapGetWithNotNullAssertionOperator:
|
||||||
|
active: true
|
||||||
|
MissingPackageDeclaration:
|
||||||
|
active: false
|
||||||
|
excludes: [ '**/*.kts' ]
|
||||||
|
NullCheckOnMutableProperty:
|
||||||
|
active: false
|
||||||
|
NullableToStringCall:
|
||||||
|
active: false
|
||||||
|
PropertyUsedBeforeDeclaration:
|
||||||
|
active: false
|
||||||
|
UnconditionalJumpStatementInLoop:
|
||||||
|
active: false
|
||||||
|
UnnecessaryNotNullCheck:
|
||||||
|
active: false
|
||||||
|
UnnecessaryNotNullOperator:
|
||||||
|
active: true
|
||||||
|
UnnecessarySafeCall:
|
||||||
|
active: true
|
||||||
|
UnreachableCatchBlock:
|
||||||
|
active: true
|
||||||
|
UnreachableCode:
|
||||||
|
active: true
|
||||||
|
UnsafeCallOnNullableType:
|
||||||
|
active: true
|
||||||
|
excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**' ]
|
||||||
|
UnsafeCast:
|
||||||
|
active: true
|
||||||
|
UnusedUnaryOperator:
|
||||||
|
active: true
|
||||||
|
UselessPostfixExpression:
|
||||||
|
active: true
|
||||||
|
WrongEqualsTypeParameter:
|
||||||
|
active: true
|
||||||
|
|
||||||
|
style:
|
||||||
|
active: true
|
||||||
|
AlsoCouldBeApply:
|
||||||
|
active: false
|
||||||
|
BracesOnIfStatements:
|
||||||
|
active: false
|
||||||
|
singleLine: 'never'
|
||||||
|
multiLine: 'always'
|
||||||
|
BracesOnWhenStatements:
|
||||||
|
active: false
|
||||||
|
singleLine: 'necessary'
|
||||||
|
multiLine: 'consistent'
|
||||||
|
CanBeNonNullable:
|
||||||
|
active: false
|
||||||
|
CascadingCallWrapping:
|
||||||
|
active: false
|
||||||
|
includeElvis: true
|
||||||
|
ClassOrdering:
|
||||||
|
active: false
|
||||||
|
CollapsibleIfStatements:
|
||||||
|
active: false
|
||||||
|
DataClassContainsFunctions:
|
||||||
|
active: false
|
||||||
|
conversionFunctionPrefix:
|
||||||
|
- 'to'
|
||||||
|
allowOperators: false
|
||||||
|
DataClassShouldBeImmutable:
|
||||||
|
active: false
|
||||||
|
DestructuringDeclarationWithTooManyEntries:
|
||||||
|
active: true
|
||||||
|
maxDestructuringEntries: 3
|
||||||
|
DoubleNegativeLambda:
|
||||||
|
active: false
|
||||||
|
negativeFunctions:
|
||||||
|
- reason: 'Use `takeIf` instead.'
|
||||||
|
value: 'takeUnless'
|
||||||
|
- reason: 'Use `all` instead.'
|
||||||
|
value: 'none'
|
||||||
|
negativeFunctionNameParts:
|
||||||
|
- 'not'
|
||||||
|
- 'non'
|
||||||
|
EqualsNullCall:
|
||||||
|
active: true
|
||||||
|
EqualsOnSignatureLine:
|
||||||
|
active: false
|
||||||
|
ExplicitCollectionElementAccessMethod:
|
||||||
|
active: false
|
||||||
|
ExplicitItLambdaParameter:
|
||||||
|
active: true
|
||||||
|
ExpressionBodySyntax:
|
||||||
|
active: false
|
||||||
|
includeLineWrapping: false
|
||||||
|
ForbiddenAnnotation:
|
||||||
|
active: false
|
||||||
|
annotations:
|
||||||
|
- reason: 'it is a java annotation. Use `Suppress` instead.'
|
||||||
|
value: 'java.lang.SuppressWarnings'
|
||||||
|
- reason: 'it is a java annotation. Use `kotlin.Deprecated` instead.'
|
||||||
|
value: 'java.lang.Deprecated'
|
||||||
|
- reason: 'it is a java annotation. Use `kotlin.annotation.MustBeDocumented` instead.'
|
||||||
|
value: 'java.lang.annotation.Documented'
|
||||||
|
- reason: 'it is a java annotation. Use `kotlin.annotation.Target` instead.'
|
||||||
|
value: 'java.lang.annotation.Target'
|
||||||
|
- reason: 'it is a java annotation. Use `kotlin.annotation.Retention` instead.'
|
||||||
|
value: 'java.lang.annotation.Retention'
|
||||||
|
- reason: 'it is a java annotation. Use `kotlin.annotation.Repeatable` instead.'
|
||||||
|
value: 'java.lang.annotation.Repeatable'
|
||||||
|
- reason: 'Kotlin does not support @Inherited annotation, see https://youtrack.jetbrains.com/issue/KT-22265'
|
||||||
|
value: 'java.lang.annotation.Inherited'
|
||||||
|
ForbiddenComment:
|
||||||
|
active: true
|
||||||
|
comments:
|
||||||
|
- reason: 'Forbidden FIXME todo marker in comment, please fix the problem.'
|
||||||
|
value: 'FIXME:'
|
||||||
|
- reason: 'Forbidden STOPSHIP todo marker in comment, please address the problem before shipping the code.'
|
||||||
|
value: 'STOPSHIP:'
|
||||||
|
- reason: 'Forbidden TODO todo marker in comment, please do the changes.'
|
||||||
|
value: 'TODO:'
|
||||||
|
allowedPatterns: ''
|
||||||
|
ForbiddenImport:
|
||||||
|
active: false
|
||||||
|
imports: [ ]
|
||||||
|
forbiddenPatterns: ''
|
||||||
|
ForbiddenMethodCall:
|
||||||
|
active: false
|
||||||
|
methods:
|
||||||
|
- reason: 'print does not allow you to configure the output stream. Use a logger instead.'
|
||||||
|
value: 'kotlin.io.print'
|
||||||
|
- reason: 'println does not allow you to configure the output stream. Use a logger instead.'
|
||||||
|
value: 'kotlin.io.println'
|
||||||
|
ForbiddenSuppress:
|
||||||
|
active: false
|
||||||
|
rules: [ ]
|
||||||
|
ForbiddenVoid:
|
||||||
|
active: true
|
||||||
|
ignoreOverridden: false
|
||||||
|
ignoreUsageInGenerics: false
|
||||||
|
FunctionOnlyReturningConstant:
|
||||||
|
active: true
|
||||||
|
ignoreOverridableFunction: true
|
||||||
|
ignoreActualFunction: true
|
||||||
|
excludedFunctions: [ ]
|
||||||
|
LoopWithTooManyJumpStatements:
|
||||||
|
active: true
|
||||||
|
maxJumpCount: 1
|
||||||
|
MagicNumber:
|
||||||
|
active: false
|
||||||
|
excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**', '**/*.kts' ]
|
||||||
|
ignoreNumbers:
|
||||||
|
- '-1'
|
||||||
|
- '0'
|
||||||
|
- '1'
|
||||||
|
- '2'
|
||||||
|
ignoreHashCodeFunction: true
|
||||||
|
ignorePropertyDeclaration: false
|
||||||
|
ignoreLocalVariableDeclaration: false
|
||||||
|
ignoreConstantDeclaration: true
|
||||||
|
ignoreCompanionObjectPropertyDeclaration: true
|
||||||
|
ignoreAnnotation: false
|
||||||
|
ignoreNamedArgument: true
|
||||||
|
ignoreEnums: false
|
||||||
|
ignoreRanges: false
|
||||||
|
ignoreExtensionFunctions: true
|
||||||
|
MandatoryBracesLoops:
|
||||||
|
active: false
|
||||||
|
MaxChainedCallsOnSameLine:
|
||||||
|
active: false
|
||||||
|
maxChainedCalls: 5
|
||||||
|
MaxLineLength:
|
||||||
|
active: true
|
||||||
|
excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**', '**/*.kts' ]
|
||||||
|
maxLineLength: 120
|
||||||
|
excludePackageStatements: true
|
||||||
|
excludeImportStatements: true
|
||||||
|
excludeCommentStatements: true
|
||||||
|
excludeRawStrings: true
|
||||||
|
MayBeConst:
|
||||||
|
active: true
|
||||||
|
ModifierOrder:
|
||||||
|
active: true
|
||||||
|
MultilineLambdaItParameter:
|
||||||
|
active: false
|
||||||
|
MultilineRawStringIndentation:
|
||||||
|
active: false
|
||||||
|
indentSize: 4
|
||||||
|
trimmingMethods:
|
||||||
|
- 'trimIndent'
|
||||||
|
- 'trimMargin'
|
||||||
|
NestedClassesVisibility:
|
||||||
|
active: true
|
||||||
|
NewLineAtEndOfFile:
|
||||||
|
active: true
|
||||||
|
NoTabs:
|
||||||
|
active: false
|
||||||
|
NullableBooleanCheck:
|
||||||
|
active: false
|
||||||
|
ObjectLiteralToLambda:
|
||||||
|
active: true
|
||||||
|
OptionalAbstractKeyword:
|
||||||
|
active: true
|
||||||
|
OptionalUnit:
|
||||||
|
active: false
|
||||||
|
OptionalWhenBraces:
|
||||||
|
active: false
|
||||||
|
PreferToOverPairSyntax:
|
||||||
|
active: false
|
||||||
|
ProtectedMemberInFinalClass:
|
||||||
|
active: true
|
||||||
|
RedundantExplicitType:
|
||||||
|
active: false
|
||||||
|
RedundantHigherOrderMapUsage:
|
||||||
|
active: true
|
||||||
|
RedundantVisibilityModifierRule:
|
||||||
|
active: false
|
||||||
|
ReturnCount:
|
||||||
|
active: true
|
||||||
|
max: 3
|
||||||
|
excludedFunctions:
|
||||||
|
- 'equals'
|
||||||
|
excludeLabeled: false
|
||||||
|
excludeReturnFromLambda: true
|
||||||
|
# Guard clauses (early returns for preconditions) are idiomatic Kotlin and should not
|
||||||
|
# count toward the return limit. E.g. `if (x == null) return null` at function top.
|
||||||
|
excludeGuardClauses: true
|
||||||
|
SafeCast:
|
||||||
|
active: true
|
||||||
|
SerialVersionUIDInSerializableClass:
|
||||||
|
active: true
|
||||||
|
SpacingBetweenPackageAndImports:
|
||||||
|
active: false
|
||||||
|
StringShouldBeRawString:
|
||||||
|
active: false
|
||||||
|
maxEscapedCharacterCount: 2
|
||||||
|
ignoredCharacters: [ ]
|
||||||
|
ThrowsCount:
|
||||||
|
active: true
|
||||||
|
max: 2
|
||||||
|
excludeGuardClauses: false
|
||||||
|
TrailingWhitespace:
|
||||||
|
active: false
|
||||||
|
TrimMultilineRawString:
|
||||||
|
active: false
|
||||||
|
trimmingMethods:
|
||||||
|
- 'trimIndent'
|
||||||
|
- 'trimMargin'
|
||||||
|
UnderscoresInNumericLiterals:
|
||||||
|
active: false
|
||||||
|
acceptableLength: 4
|
||||||
|
allowNonStandardGrouping: false
|
||||||
|
UnnecessaryAbstractClass:
|
||||||
|
active: true
|
||||||
|
UnnecessaryAnnotationUseSiteTarget:
|
||||||
|
active: false
|
||||||
|
UnnecessaryApply:
|
||||||
|
active: true
|
||||||
|
UnnecessaryBackticks:
|
||||||
|
active: false
|
||||||
|
UnnecessaryBracesAroundTrailingLambda:
|
||||||
|
active: false
|
||||||
|
UnnecessaryFilter:
|
||||||
|
active: true
|
||||||
|
UnnecessaryInheritance:
|
||||||
|
active: true
|
||||||
|
UnnecessaryInnerClass:
|
||||||
|
active: false
|
||||||
|
UnnecessaryLet:
|
||||||
|
active: false
|
||||||
|
UnnecessaryParentheses:
|
||||||
|
active: false
|
||||||
|
allowForUnclearPrecedence: false
|
||||||
|
UntilInsteadOfRangeTo:
|
||||||
|
active: false
|
||||||
|
UnusedImports:
|
||||||
|
active: false
|
||||||
|
UnusedParameter:
|
||||||
|
active: true
|
||||||
|
allowedNames: 'ignored|expected'
|
||||||
|
UnusedPrivateClass:
|
||||||
|
active: true
|
||||||
|
UnusedPrivateMember:
|
||||||
|
active: true
|
||||||
|
allowedNames: '[A-Z][a-zA-Z0-9]*Preview'
|
||||||
|
UnusedPrivateProperty:
|
||||||
|
active: true
|
||||||
|
allowedNames: '_|ignored|expected|serialVersionUID'
|
||||||
|
UseAnyOrNoneInsteadOfFind:
|
||||||
|
active: true
|
||||||
|
UseArrayLiteralsInAnnotations:
|
||||||
|
active: true
|
||||||
|
UseCheckNotNull:
|
||||||
|
active: true
|
||||||
|
UseCheckOrError:
|
||||||
|
active: true
|
||||||
|
UseDataClass:
|
||||||
|
active: false
|
||||||
|
allowVars: false
|
||||||
|
UseEmptyCounterpart:
|
||||||
|
active: false
|
||||||
|
UseIfEmptyOrIfBlank:
|
||||||
|
active: false
|
||||||
|
UseIfInsteadOfWhen:
|
||||||
|
active: false
|
||||||
|
ignoreWhenContainingVariableDeclaration: false
|
||||||
|
UseIsNullOrEmpty:
|
||||||
|
active: true
|
||||||
|
UseLet:
|
||||||
|
active: false
|
||||||
|
UseOrEmpty:
|
||||||
|
active: true
|
||||||
|
UseRequire:
|
||||||
|
active: true
|
||||||
|
UseRequireNotNull:
|
||||||
|
active: true
|
||||||
|
UseSumOfInsteadOfFlatMapSize:
|
||||||
|
active: false
|
||||||
|
UselessCallOnNotNull:
|
||||||
|
active: true
|
||||||
|
UtilityClassWithPublicConstructor:
|
||||||
|
active: true
|
||||||
|
VarCouldBeVal:
|
||||||
|
active: true
|
||||||
|
ignoreLateinitVar: false
|
||||||
|
WildcardImport:
|
||||||
|
active: false
|
||||||
|
excludeImports:
|
||||||
|
- 'java.util.*'
|
||||||
Binary file not shown.
@@ -58,22 +58,14 @@ interface Look4SatDao {
|
|||||||
@Query("DELETE FROM entries")
|
@Query("DELETE FROM entries")
|
||||||
suspend fun deleteEntries()
|
suspend fun deleteEntries()
|
||||||
|
|
||||||
@Query("SELECT catnum FROM radios WHERE downlinkMode IN (:modes) AND isAlive = 1")
|
@Query(
|
||||||
|
"""
|
||||||
|
SELECT DISTINCT catnum FROM radios WHERE isAlive = 1
|
||||||
|
AND (downlinkMode IN (:modes) OR uplinkMode IN (:modes))
|
||||||
|
"""
|
||||||
|
)
|
||||||
suspend fun getIdsWithModes(modes: List<String>): List<Int>
|
suspend fun getIdsWithModes(modes: List<String>): List<Int>
|
||||||
|
|
||||||
/** Like [getIdsWithModes] but only matches transponder records with an uplink,
|
|
||||||
* excluding downlink-only beacons/telemetry that share the same mode label
|
|
||||||
* (e.g. CW beacons, FM voice-synthesis beacons) from FM/Linear filter fallback. */
|
|
||||||
@Query("SELECT catnum FROM radios WHERE downlinkMode IN (:modes) AND uplinkLow IS NOT NULL AND isAlive = 1")
|
|
||||||
suspend fun getIdsWithModesAndUplink(modes: List<String>): List<Int>
|
|
||||||
|
|
||||||
/** Like [getIdsWithModes] but only matches records whose service class is
|
|
||||||
* "Amateur", so the SSTV filter never matches weather birds (TIROS),
|
|
||||||
* launcher debris or other non-amateur transmitters that happen to carry
|
|
||||||
* an SSTV-labelled downlink. */
|
|
||||||
@Query("SELECT catnum FROM radios WHERE downlinkMode IN (:modes) AND service = 'Amateur' AND isAlive = 1")
|
|
||||||
suspend fun getIdsWithModesAndAmateur(modes: List<String>): List<Int>
|
|
||||||
|
|
||||||
@Query("SELECT COUNT(*) FROM radios")
|
@Query("SELECT COUNT(*) FROM radios")
|
||||||
suspend fun getRadiosTotal(): Int
|
suspend fun getRadiosTotal(): Int
|
||||||
|
|
||||||
|
|||||||
@@ -24,7 +24,9 @@ import androidx.sqlite.db.SupportSQLiteDatabase
|
|||||||
import com.rtbishop.look4sat.core.data.database.entity.SatEntry
|
import com.rtbishop.look4sat.core.data.database.entity.SatEntry
|
||||||
import com.rtbishop.look4sat.core.data.database.entity.SatRadio
|
import com.rtbishop.look4sat.core.data.database.entity.SatRadio
|
||||||
|
|
||||||
@Database(entities = [SatEntry::class, SatRadio::class], version = 3, exportSchema = false)
|
const val DATABASE_NAME = "Look4SatDBv400"
|
||||||
|
|
||||||
|
@Database(entities = [SatEntry::class, SatRadio::class], version = 2, exportSchema = false)
|
||||||
abstract class Look4SatDb : RoomDatabase() {
|
abstract class Look4SatDb : RoomDatabase() {
|
||||||
abstract fun look4SatDao(): Look4SatDao
|
abstract fun look4SatDao(): Look4SatDao
|
||||||
}
|
}
|
||||||
@@ -37,11 +39,3 @@ val MIGRATION_1_2 = object : Migration(1, 2) {
|
|||||||
db.execSQL("ALTER TABLE radios ADD COLUMN isCustom INTEGER NOT NULL DEFAULT 0")
|
db.execSQL("ALTER TABLE radios ADD COLUMN isCustom INTEGER NOT NULL DEFAULT 0")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Adds the transceiver service class ("Amateur" etc.), used by the SSTV virtual
|
|
||||||
* filter to tell amateur satellites apart from debris/weather/launcher stages. */
|
|
||||||
val MIGRATION_2_3 = object : Migration(2, 3) {
|
|
||||||
override fun migrate(db: SupportSQLiteDatabase) {
|
|
||||||
db.execSQL("ALTER TABLE radios ADD COLUMN service TEXT")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
/*
|
|
||||||
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
|
||||||
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
|
||||||
*
|
|
||||||
* This program is free software: you can redistribute it and/or modify
|
|
||||||
* it under the terms of the GNU General Public License as published by
|
|
||||||
* the Free Software Foundation, either version 3 of the License, or
|
|
||||||
* (at your option) any later version.
|
|
||||||
*/
|
|
||||||
package com.rtbishop.look4sat.core.data.database
|
|
||||||
|
|
||||||
import androidx.room.Dao
|
|
||||||
import androidx.room.Insert
|
|
||||||
import androidx.room.OnConflictStrategy
|
|
||||||
import androidx.room.Query
|
|
||||||
import androidx.room.Transaction
|
|
||||||
import com.rtbishop.look4sat.core.data.database.entity.QsoEntity
|
|
||||||
import kotlinx.coroutines.flow.Flow
|
|
||||||
|
|
||||||
@Dao
|
|
||||||
interface QsoDao {
|
|
||||||
@Query("SELECT * FROM qso_records ORDER BY startUtcMillis DESC, id DESC")
|
|
||||||
fun observeAll(): Flow<List<QsoEntity>>
|
|
||||||
|
|
||||||
@Query("SELECT * FROM qso_records WHERE id = :id LIMIT 1")
|
|
||||||
suspend fun find(id: Long): QsoEntity?
|
|
||||||
|
|
||||||
@Query("SELECT * FROM qso_records ORDER BY startUtcMillis DESC, id DESC")
|
|
||||||
suspend fun getAll(): List<QsoEntity>
|
|
||||||
|
|
||||||
@Query("SELECT dedupeKey FROM qso_records")
|
|
||||||
suspend fun getDedupeKeys(): List<String>
|
|
||||||
|
|
||||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
|
||||||
suspend fun save(record: QsoEntity): Long
|
|
||||||
|
|
||||||
@Insert(onConflict = OnConflictStrategy.IGNORE)
|
|
||||||
suspend fun importRecords(records: List<QsoEntity>): List<Long>
|
|
||||||
|
|
||||||
@Transaction
|
|
||||||
suspend fun saveBatch(records: List<QsoEntity>) {
|
|
||||||
records.forEach { save(it) }
|
|
||||||
}
|
|
||||||
|
|
||||||
@Query("DELETE FROM qso_records WHERE id = :id")
|
|
||||||
suspend fun delete(id: Long)
|
|
||||||
|
|
||||||
@Query("UPDATE qso_records SET lotwUploaded = 1 WHERE id IN (:ids)")
|
|
||||||
suspend fun markUploaded(ids: List<Long>)
|
|
||||||
|
|
||||||
@Query("UPDATE qso_records SET wavelogUploaded = 1, wavelogStation = CASE WHEN :stationId = '' THEN wavelogStation ELSE :stationId END WHERE id IN (:ids)")
|
|
||||||
suspend fun markWavelogUploaded(ids: List<Long>, stationId: String)
|
|
||||||
}
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
/*
|
|
||||||
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
|
||||||
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
|
||||||
*
|
|
||||||
* This program is free software: you can redistribute it and/or modify
|
|
||||||
* it under the terms of the GNU General Public License as published by
|
|
||||||
* the Free Software Foundation, either version 3 of the License, or
|
|
||||||
* (at your option) any later version.
|
|
||||||
*/
|
|
||||||
package com.rtbishop.look4sat.core.data.database
|
|
||||||
|
|
||||||
import androidx.room.Database
|
|
||||||
import androidx.room.RoomDatabase
|
|
||||||
import androidx.room.migration.Migration
|
|
||||||
import androidx.sqlite.db.SupportSQLiteDatabase
|
|
||||||
import com.rtbishop.look4sat.core.data.database.entity.QsoEntity
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Separate database for the logbook (QSO records, LoTW confirmations and
|
|
||||||
* upload receipts). Kept apart from [Look4SatDb] so existing installations
|
|
||||||
* never need a migration of the satellite database.
|
|
||||||
*/
|
|
||||||
@Database(entities = [QsoEntity::class], version = 5, exportSchema = false)
|
|
||||||
abstract class QsoDatabase : RoomDatabase() {
|
|
||||||
abstract fun qsoDao(): QsoDao
|
|
||||||
}
|
|
||||||
|
|
||||||
/** v1 → v2: track whether a QSO was uploaded to LoTW (distinct from confirmed). */
|
|
||||||
val MIGRATION_QSO_1_2 = object : Migration(1, 2) {
|
|
||||||
override fun migrate(db: SupportSQLiteDatabase) {
|
|
||||||
db.execSQL("ALTER TABLE qso_records ADD COLUMN lotwUploaded INTEGER NOT NULL DEFAULT 0")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** v2 → v3: keep the OPPOSITE station's grid set (multi-grid confirmations as LoTW reports them). */
|
|
||||||
val MIGRATION_QSO_2_3 = object : Migration(2, 3) {
|
|
||||||
override fun migrate(db: SupportSQLiteDatabase) {
|
|
||||||
db.execSQL("ALTER TABLE qso_records ADD COLUMN theirVuccGrids TEXT NOT NULL DEFAULT ''")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** v3 → v4: track whether a QSO was uploaded to Wavelog (independent of the LoTW state). */
|
|
||||||
val MIGRATION_QSO_3_4 = object : Migration(3, 4) {
|
|
||||||
override fun migrate(db: SupportSQLiteDatabase) {
|
|
||||||
db.execSQL("ALTER TABLE qso_records ADD COLUMN wavelogUploaded INTEGER NOT NULL DEFAULT 0")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** v4 → v5: remember which Wavelog station profile (台址) a QSO belongs to. */
|
|
||||||
val MIGRATION_QSO_4_5 = object : Migration(4, 5) {
|
|
||||||
override fun migrate(db: SupportSQLiteDatabase) {
|
|
||||||
db.execSQL("ALTER TABLE qso_records ADD COLUMN wavelogStation TEXT NOT NULL DEFAULT ''")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
/*
|
|
||||||
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
|
||||||
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
|
||||||
*
|
|
||||||
* This program is free software: you can redistribute it and/or modify
|
|
||||||
* it under the terms of the GNU General Public License as published by
|
|
||||||
* the Free Software Foundation, either version 3 of the License, or
|
|
||||||
* (at your option) any later version.
|
|
||||||
*/
|
|
||||||
package com.rtbishop.look4sat.core.data.database.entity
|
|
||||||
|
|
||||||
import androidx.room.ColumnInfo
|
|
||||||
import androidx.room.Entity
|
|
||||||
import androidx.room.Index
|
|
||||||
import androidx.room.PrimaryKey
|
|
||||||
|
|
||||||
@Entity(
|
|
||||||
tableName = "qso_records",
|
|
||||||
indices = [Index(value = ["dedupeKey"])]
|
|
||||||
)
|
|
||||||
data class QsoEntity(
|
|
||||||
@PrimaryKey(autoGenerate = true) val id: Long = 0L,
|
|
||||||
val startUtcMillis: Long,
|
|
||||||
val endUtcMillis: Long?,
|
|
||||||
val theirCallsign: String,
|
|
||||||
val myCallsign: String,
|
|
||||||
val theirGrid: String,
|
|
||||||
val myGrid: String,
|
|
||||||
val sentReport: String,
|
|
||||||
val receivedReport: String,
|
|
||||||
val txFrequencyHz: Long?,
|
|
||||||
val rxFrequencyHz: Long?,
|
|
||||||
val band: String,
|
|
||||||
val rxBand: String,
|
|
||||||
@ColumnInfo(defaultValue = "'MFSK'") val mode: String,
|
|
||||||
@ColumnInfo(defaultValue = "'FT4'") val submode: String,
|
|
||||||
val satelliteName: String,
|
|
||||||
val transponderName: String,
|
|
||||||
val satelliteMode: String,
|
|
||||||
val passAosUtcMillis: Long?,
|
|
||||||
val automatic: Boolean,
|
|
||||||
val status: String,
|
|
||||||
@ColumnInfo(defaultValue = "''") val dedupeKey: String,
|
|
||||||
@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 = "''") val wavelogStation: String = "",
|
|
||||||
@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,
|
|
||||||
@ColumnInfo(defaultValue = "''") val region: String = "",
|
|
||||||
@ColumnInfo(defaultValue = "''") val comment: String = ""
|
|
||||||
)
|
|
||||||
@@ -33,10 +33,6 @@ data class SatRadio(
|
|||||||
val uplinkMode: String?,
|
val uplinkMode: String?,
|
||||||
val isInverted: Boolean,
|
val isInverted: Boolean,
|
||||||
val catnum: Int?,
|
val catnum: Int?,
|
||||||
/** Service class from the transceiver source ("Amateur"/"Unknown" etc.).
|
|
||||||
* Used to tell amateur satellites apart from debris / weather birds
|
|
||||||
* (TIROS) / launcher stages (Ariane 6 R/B) in the SSTV virtual filter. */
|
|
||||||
val service: String? = null,
|
|
||||||
/** Set for manually imported transceivers, which remote updates must not replace. */
|
/** Set for manually imported transceivers, which remote updates must not replace. */
|
||||||
val isCustom: Boolean = false
|
val isCustom: Boolean = false
|
||||||
)
|
)
|
||||||
+2
-4
@@ -67,7 +67,7 @@ class NetworkReporter(
|
|||||||
.replace($$"$AZ", azimuth.toString())
|
.replace($$"$AZ", azimuth.toString())
|
||||||
.replace($$"$EL", el.toString())
|
.replace($$"$EL", el.toString())
|
||||||
.unescapeControlChars()
|
.unescapeControlChars()
|
||||||
write(rotatorSocket, command) { resetRotatorConnection() }
|
write(rotatorSocket, command) { rotatorConnected = false }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -154,7 +154,5 @@ class NetworkReporter(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun String.unescapeControlChars(): String =
|
private fun String.unescapeControlChars(): String =
|
||||||
replace("""\r""", 13.toChar().toString())
|
replace("\\r", "\r").replace("\\n", "\n").replace("\\t", "\t")
|
||||||
.replace("""\n""", 10.toChar().toString())
|
|
||||||
.replace("""\t""", 9.toChar().toString())
|
|
||||||
}
|
}
|
||||||
+24
-80
@@ -17,66 +17,43 @@
|
|||||||
*/
|
*/
|
||||||
package com.rtbishop.look4sat.core.data.injection
|
package com.rtbishop.look4sat.core.data.injection
|
||||||
|
|
||||||
import android.Manifest
|
|
||||||
import android.bluetooth.BluetoothManager
|
import android.bluetooth.BluetoothManager
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.pm.PackageManager
|
|
||||||
import android.hardware.SensorManager
|
import android.hardware.SensorManager
|
||||||
import android.hardware.display.DisplayManager
|
import android.hardware.display.DisplayManager
|
||||||
import android.location.LocationManager
|
import android.location.LocationManager
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.pm.PackageInfoCompat
|
||||||
import androidx.room.Room
|
import androidx.room.Room
|
||||||
import com.rtbishop.look4sat.core.data.database.Look4SatDb
|
import com.rtbishop.look4sat.core.data.database.DATABASE_NAME
|
||||||
import com.rtbishop.look4sat.core.data.database.MIGRATION_1_2
|
import com.rtbishop.look4sat.core.data.database.MIGRATION_1_2
|
||||||
import com.rtbishop.look4sat.core.data.database.MIGRATION_2_3
|
import com.rtbishop.look4sat.core.data.database.Look4SatDb
|
||||||
import com.rtbishop.look4sat.core.data.database.MIGRATION_QSO_1_2
|
|
||||||
import com.rtbishop.look4sat.core.data.database.MIGRATION_QSO_2_3
|
|
||||||
import com.rtbishop.look4sat.core.data.database.MIGRATION_QSO_3_4
|
|
||||||
import com.rtbishop.look4sat.core.data.database.MIGRATION_QSO_4_5
|
|
||||||
import com.rtbishop.look4sat.core.data.database.QsoDatabase
|
|
||||||
import com.rtbishop.look4sat.core.data.framework.BluetoothReporter
|
import com.rtbishop.look4sat.core.data.framework.BluetoothReporter
|
||||||
import com.rtbishop.look4sat.core.data.framework.Ft817Controller
|
import com.rtbishop.look4sat.core.data.framework.Ft817Controller
|
||||||
import com.rtbishop.look4sat.core.data.framework.Ic705Controller
|
import com.rtbishop.look4sat.core.data.framework.Ic705Controller
|
||||||
import com.rtbishop.look4sat.core.data.framework.NetworkReporter
|
import com.rtbishop.look4sat.core.data.framework.NetworkReporter
|
||||||
import com.rtbishop.look4sat.core.data.framework.RadioTrackingService
|
import com.rtbishop.look4sat.core.data.framework.RadioTrackingService
|
||||||
import com.rtbishop.look4sat.core.data.lotw.LoTWUploadRepository
|
|
||||||
import com.rtbishop.look4sat.core.data.repository.AmSatRepository
|
import com.rtbishop.look4sat.core.data.repository.AmSatRepository
|
||||||
import com.rtbishop.look4sat.core.data.repository.DatabaseRepo
|
import com.rtbishop.look4sat.core.data.repository.DatabaseRepo
|
||||||
import com.rtbishop.look4sat.core.data.repository.QsoRepository
|
|
||||||
import com.rtbishop.look4sat.core.data.repository.SatelliteRepo
|
import com.rtbishop.look4sat.core.data.repository.SatelliteRepo
|
||||||
import com.rtbishop.look4sat.core.data.repository.SelectionRepo
|
import com.rtbishop.look4sat.core.data.repository.SelectionRepo
|
||||||
import com.rtbishop.look4sat.core.data.repository.SensorsRepo
|
import com.rtbishop.look4sat.core.data.repository.SensorsRepo
|
||||||
import com.rtbishop.look4sat.core.data.repository.SettingsRepo
|
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.WavelogUploadRepository
|
|
||||||
import com.rtbishop.look4sat.core.data.repository.WavelogSyncRepository
|
|
||||||
import com.rtbishop.look4sat.core.data.source.LocalSource
|
import com.rtbishop.look4sat.core.data.source.LocalSource
|
||||||
import com.rtbishop.look4sat.core.data.source.RemoteSource
|
import com.rtbishop.look4sat.core.data.source.RemoteSource
|
||||||
import com.rtbishop.look4sat.core.data.usecase.AddToCalendar
|
import com.rtbishop.look4sat.core.data.usecase.AddToCalendar
|
||||||
import com.rtbishop.look4sat.core.data.usecase.AudioCapture
|
import com.rtbishop.look4sat.core.data.usecase.AudioCapture
|
||||||
import com.rtbishop.look4sat.core.data.usecase.SaveImage
|
import com.rtbishop.look4sat.core.data.usecase.SaveImage
|
||||||
import com.rtbishop.look4sat.core.data.usecase.ShowToast
|
import com.rtbishop.look4sat.core.data.usecase.ShowToast
|
||||||
import com.rtbishop.look4sat.core.domain.logbook.IQsoRepository
|
|
||||||
import com.rtbishop.look4sat.core.domain.model.RadioControlSettings
|
import com.rtbishop.look4sat.core.domain.model.RadioControlSettings
|
||||||
import com.rtbishop.look4sat.core.domain.repository.IDatabaseRepo
|
import com.rtbishop.look4sat.core.domain.repository.IDatabaseRepo
|
||||||
import com.rtbishop.look4sat.core.domain.repository.ILoTWUploadRepository
|
|
||||||
import com.rtbishop.look4sat.core.domain.repository.IMainContainer
|
import com.rtbishop.look4sat.core.domain.repository.IMainContainer
|
||||||
import com.rtbishop.look4sat.core.domain.repository.IRadioController
|
import com.rtbishop.look4sat.core.domain.repository.IRadioController
|
||||||
import com.rtbishop.look4sat.core.domain.repository.IRadioTrackingService
|
import com.rtbishop.look4sat.core.domain.repository.IRadioTrackingService
|
||||||
import com.rtbishop.look4sat.core.domain.repository.IReporter
|
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.ISatelliteRepo
|
||||||
import com.rtbishop.look4sat.core.domain.repository.ISelectionRepo
|
import com.rtbishop.look4sat.core.domain.repository.ISelectionRepo
|
||||||
import com.rtbishop.look4sat.core.domain.repository.ISensorsRepo
|
import com.rtbishop.look4sat.core.domain.repository.ISensorsRepo
|
||||||
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
|
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
|
||||||
|
|
||||||
import com.rtbishop.look4sat.core.domain.repository.IWavelogUploadRepository
|
|
||||||
import com.rtbishop.look4sat.core.domain.repository.IWavelogSyncRepository
|
|
||||||
import com.rtbishop.look4sat.core.domain.repository.MutualPassData
|
|
||||||
import com.rtbishop.look4sat.core.domain.source.ILocalSource
|
import com.rtbishop.look4sat.core.domain.source.ILocalSource
|
||||||
import com.rtbishop.look4sat.core.domain.source.IRemoteSource
|
import com.rtbishop.look4sat.core.domain.source.IRemoteSource
|
||||||
import com.rtbishop.look4sat.core.domain.usecase.IAddToCalendar
|
import com.rtbishop.look4sat.core.domain.usecase.IAddToCalendar
|
||||||
@@ -88,11 +65,7 @@ import kotlinx.coroutines.CoroutineExceptionHandler
|
|||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.SupervisorJob
|
import kotlinx.coroutines.SupervisorJob
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import java.util.concurrent.TimeUnit
|
|
||||||
|
|
||||||
class MainContainer(private val context: Context) : IMainContainer {
|
class MainContainer(private val context: Context) : IMainContainer {
|
||||||
|
|
||||||
@@ -104,43 +77,11 @@ class MainContainer(private val context: Context) : IMainContainer {
|
|||||||
override val selectionRepo = provideSelectionRepo()
|
override val selectionRepo = provideSelectionRepo()
|
||||||
override val satelliteRepo = provideSatelliteRepo()
|
override val satelliteRepo = provideSatelliteRepo()
|
||||||
override val databaseRepo = provideDatabaseRepo()
|
override val databaseRepo = provideDatabaseRepo()
|
||||||
override val amSatRepo by lazy { AmSatRepository(remoteSource, appScope) }
|
override val amSatRepo by lazy { AmSatRepository(remoteSource) }
|
||||||
override val updateRepo by lazy { UpdateRepository(remoteSource) }
|
|
||||||
|
|
||||||
override val wavelogUploadRepository: IWavelogUploadRepository by lazy { WavelogUploadRepository() }
|
|
||||||
override val wavelogSyncRepository: IWavelogSyncRepository by lazy { WavelogSyncRepository() }
|
|
||||||
override val lotwRepo: ILoTWRepository by lazy { LoTWRepository() }
|
|
||||||
override val qsoRepository: IQsoRepository by lazy {
|
|
||||||
val database = Room.databaseBuilder(context, QsoDatabase::class.java, "Look4SatQsoDB")
|
|
||||||
.addMigrations(MIGRATION_QSO_1_2, MIGRATION_QSO_2_3, MIGRATION_QSO_3_4)
|
|
||||||
.addMigrations(MIGRATION_QSO_4_5)
|
|
||||||
.build()
|
|
||||||
QsoRepository(database.qsoDao(), Dispatchers.IO)
|
|
||||||
}
|
|
||||||
override val lotwUploadRepository: ILoTWUploadRepository by lazy { LoTWUploadRepository(context) }
|
|
||||||
override val radioTrackingService: IRadioTrackingService by lazy {
|
override val radioTrackingService: IRadioTrackingService by lazy {
|
||||||
val manager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
|
val manager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
|
||||||
RadioTrackingService(appScope, manager, satelliteRepo, settingsRepo)
|
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)
|
override fun provideAddToCalendar(): IAddToCalendar = AddToCalendar(context)
|
||||||
|
|
||||||
@@ -201,32 +142,33 @@ class MainContainer(private val context: Context) : IMainContainer {
|
|||||||
return SensorsRepo(manager, displayManager)
|
return SensorsRepo(manager, displayManager)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun providePairedBluetoothDevices(): List<Pair<String, String>> = buildList {
|
||||||
|
try {
|
||||||
|
val manager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
|
||||||
|
manager.adapter?.bondedDevices?.forEach { add(Pair(it.name ?: "Unknown", it.address ?: "")) }
|
||||||
|
} catch (_: SecurityException) {}
|
||||||
|
}
|
||||||
|
|
||||||
private fun provideDatabaseRepo(): IDatabaseRepo {
|
private fun provideDatabaseRepo(): IDatabaseRepo {
|
||||||
val dbDispatcher = Dispatchers.Default
|
val dbDispatcher = Dispatchers.Default
|
||||||
val dataParser = DataParser(dbDispatcher)
|
val dataParser = DataParser(dbDispatcher)
|
||||||
val remoteSource = provideRemoteSource()
|
|
||||||
return DatabaseRepo(dbDispatcher, dataParser, localSource, remoteSource, settingsRepo)
|
return DatabaseRepo(dbDispatcher, dataParser, localSource, remoteSource, settingsRepo)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun provideLocalSource(): ILocalSource {
|
private fun provideLocalSource(): ILocalSource {
|
||||||
val builder = Room.databaseBuilder(context, Look4SatDb::class.java, "Look4SatDBv400")
|
val builder = Room.databaseBuilder(context, Look4SatDb::class.java, DATABASE_NAME)
|
||||||
val database = builder
|
val database = builder.addMigrations(MIGRATION_1_2).fallbackToDestructiveMigration(false).build()
|
||||||
.addMigrations(MIGRATION_1_2, MIGRATION_2_3)
|
|
||||||
.fallbackToDestructiveMigration(false)
|
|
||||||
.build()
|
|
||||||
return LocalSource(database.look4SatDao())
|
return LocalSource(database.look4SatDao())
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun provideRemoteSource(): IRemoteSource {
|
private fun provideRemoteSource(): IRemoteSource {
|
||||||
// amsat.org live pages are slow (7-10s+ from some networks); the
|
val version = context.packageManager.getPackageInfo(context.packageName, 0).versionName ?: "4.0.4"
|
||||||
// OkHttp defaults (10s read timeout) made every AMSAT Live update
|
val userAgent = "Look4Sat/$version (+https://github.com/rt-bishop/Look4Sat)"
|
||||||
// time out, silently keeping stale FM/Linear lists forever.
|
// Data providers ask clients to identify themselves, so that they can reach out to the
|
||||||
val client = OkHttpClient.Builder()
|
// developer instead of silently blocking every user of the app behind a misbehaving request
|
||||||
.connectTimeout(20, TimeUnit.SECONDS)
|
val client = OkHttpClient.Builder().addInterceptor { chain ->
|
||||||
.readTimeout(30, TimeUnit.SECONDS)
|
chain.proceed(chain.request().newBuilder().header("User-Agent", userAgent).build())
|
||||||
.writeTimeout(30, TimeUnit.SECONDS)
|
}.build()
|
||||||
.callTimeout(60, TimeUnit.SECONDS)
|
|
||||||
.build()
|
|
||||||
return RemoteSource(Dispatchers.IO, context.contentResolver, client)
|
return RemoteSource(Dispatchers.IO, context.contentResolver, client)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -242,7 +184,9 @@ class MainContainer(private val context: Context) : IMainContainer {
|
|||||||
val manager = context.getSystemService(Context.LOCATION_SERVICE) as LocationManager
|
val manager = context.getSystemService(Context.LOCATION_SERVICE) as LocationManager
|
||||||
val appPrefsFileName = "${context.packageName}_preferences"
|
val appPrefsFileName = "${context.packageName}_preferences"
|
||||||
val appPreferences = context.getSharedPreferences(appPrefsFileName, Context.MODE_PRIVATE)
|
val appPreferences = context.getSharedPreferences(appPrefsFileName, Context.MODE_PRIVATE)
|
||||||
val appVersionName = context.packageManager.getPackageInfo(context.packageName, 0).versionName ?: "4.0.4"
|
val packageInfo = context.packageManager.getPackageInfo(context.packageName, 0)
|
||||||
return SettingsRepo(manager, appPreferences, appVersionName)
|
val appVersionName = packageInfo.versionName ?: "4.0.4"
|
||||||
|
val appVersionCode = PackageInfoCompat.getLongVersionCode(packageInfo)
|
||||||
|
return SettingsRepo(manager, appPreferences, appVersionName, appVersionCode)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,195 +0,0 @@
|
|||||||
/*
|
|
||||||
* 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
|
|
||||||
import com.rtbishop.look4sat.core.domain.repository.LoTWRegionOption
|
|
||||||
import com.rtbishop.look4sat.core.domain.repository.LoTWStation
|
|
||||||
import com.rtbishop.look4sat.core.domain.repository.LoTWStationMeta
|
|
||||||
import com.rtbishop.look4sat.core.domain.repository.LoTWZonePair
|
|
||||||
import org.w3c.dom.Element
|
|
||||||
import org.xml.sax.InputSource
|
|
||||||
import org.xml.sax.SAXException
|
|
||||||
import java.io.InputStream
|
|
||||||
import java.io.StringReader
|
|
||||||
import java.util.Locale
|
|
||||||
import java.util.zip.GZIPInputStream
|
|
||||||
import javax.xml.parsers.DocumentBuilderFactory
|
|
||||||
|
|
||||||
/** Bundled ARRL configuration 11.35 from https://lotw.arrl.org/lotw/config.tq6. */
|
|
||||||
internal class LoTWConfig(input: InputStream) {
|
|
||||||
private val root = input.use { source -> GZIPInputStream(source).use { gzip ->
|
|
||||||
val xml = gzip.bufferedReader(Charsets.UTF_8).readText()
|
|
||||||
require(!xml.contains("<!DOCTYPE", true) && !xml.contains("<!ENTITY", true))
|
|
||||||
// Android's DOM provider does not support Xerces-specific setFeature flags.
|
|
||||||
DocumentBuilderFactory.newInstance().apply { isExpandEntityReferences = false }.newDocumentBuilder().apply {
|
|
||||||
setEntityResolver { _, _ -> throw SAXException("External entities are not permitted") }
|
|
||||||
}.parse(InputSource(StringReader(xml))).documentElement
|
|
||||||
} }
|
|
||||||
private val config = root.elements("tqslconfig").single()
|
|
||||||
val version = config.getAttribute("majorversion") + "." + config.getAttribute("minorversion")
|
|
||||||
private val spec = config.elements("sigspec").single { it.getAttribute("version") == "2.0" }
|
|
||||||
val stationOrder = spec.elements("tSTATION").single().childElements().map { it.tagName }
|
|
||||||
val contactOrder = spec.elements("tCONTACT").single().childElements().map { it.tagName }
|
|
||||||
private val primaryRegionFields = setOf("US_STATE", "CA_PROVINCE", "RU_OBLAST", "CN_PROVINCE", "AU_STATE", "JA_PREFECTURE", "FI_KUNTA")
|
|
||||||
private val secondaryRegionFields = setOf("US_COUNTY", "JA_CITY_GUN_KU")
|
|
||||||
private val bands = config.elements("bands").single().elements("band")
|
|
||||||
private val satellites = config.elements("satellite").associateBy { it.getAttribute("name").uppercase(Locale.US) }
|
|
||||||
private val modes = config.elements("modes").single().elements("mode").map { it.textContent }.toSet()
|
|
||||||
|
|
||||||
fun mode(record: QsoRecord): String {
|
|
||||||
val display = record.displayMode
|
|
||||||
if (display in modes) return display
|
|
||||||
val mapped = config.elements("adifmode").firstOrNull { it.textContent.equals(display, true) }?.getAttribute("mode")
|
|
||||||
return mapped ?: fail(LoTWProblem.MODE, display)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun band(explicit: String, frequency: Long?, required: Boolean): String {
|
|
||||||
if (frequency != null && frequency <= 0) fail(LoTWProblem.BAND)
|
|
||||||
val frequencyBand = frequency?.let { hz -> bands.firstOrNull { band ->
|
|
||||||
val unit = if (band.getAttribute("spectrum") == "HF") 1_000.0 else 1_000_000.0
|
|
||||||
val mhz = hz / unit
|
|
||||||
mhz >= band.getAttribute("low").toDouble() && mhz <= band.getAttribute("high").toDouble()
|
|
||||||
}?.textContent }
|
|
||||||
val value = explicit.trim().uppercase(Locale.US).ifBlank { frequencyBand.orEmpty() }
|
|
||||||
if (value.isBlank() && !required && frequency == null) return ""
|
|
||||||
if (bands.none { it.textContent == value } || (frequency != null && frequencyBand != value)) fail(LoTWProblem.BAND, value)
|
|
||||||
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)
|
|
||||||
// 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)
|
|
||||||
return sat.getAttribute("name").uppercase(Locale.US)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun propagation(value: String): String {
|
|
||||||
val normalized = value.trim().uppercase(Locale.US)
|
|
||||||
if (normalized.isNotBlank() && config.elements("propmode").none { it.getAttribute("name") == normalized || it.textContent == normalized }) {
|
|
||||||
fail(LoTWProblem.INVALID_CONTACT, normalized)
|
|
||||||
}
|
|
||||||
return normalized
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Region-field metadata and the national zonemap for one DXCC entity. */
|
|
||||||
fun stationMeta(dxcc: Int): LoTWStationMeta {
|
|
||||||
val pageFields = config.elements("page").filter { it.getAttribute("dependency") == dxcc.toString() }
|
|
||||||
.flatMap { it.elements("pageField") }.map { it.textContent }
|
|
||||||
val primary = pageFields.firstOrNull { it in primaryRegionFields }
|
|
||||||
val regionField = primary?.let { id ->
|
|
||||||
val field = config.elements("field").single { it.getAttribute("Id") == id }
|
|
||||||
val dependency = if (field.getAttribute("dependsOn") == "DXCC") dxcc.toString() else null
|
|
||||||
val options = field.elements("enums").filter {
|
|
||||||
it.getAttribute("dependency").isBlank() || it.getAttribute("dependency") == dependency
|
|
||||||
}.flatMap { it.elements("enum") }.map { enum ->
|
|
||||||
LoTWRegionOption(
|
|
||||||
code = enum.getAttribute("value").uppercase(Locale.US),
|
|
||||||
name = enum.textContent.trim().ifBlank { enum.getAttribute("value") },
|
|
||||||
zones = parseZonemap(enum.getAttribute("zonemap"))
|
|
||||||
)
|
|
||||||
}
|
|
||||||
LoTWRegionField(id = id, label = field.getAttribute("label"), options = options)
|
|
||||||
}
|
|
||||||
val entity = config.elements("dxcc").flatMap { it.elements("entity") }
|
|
||||||
.firstOrNull { it.getAttribute("arrlId") == dxcc.toString() }
|
|
||||||
val countryZones = entity?.getAttribute("zonemap")?.let(::parseZonemap).orEmpty()
|
|
||||||
return LoTWStationMeta(regionField, countryZones)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun parseZonemap(zonemap: String): List<LoTWZonePair> = zonemap.split(',').mapNotNull { pair ->
|
|
||||||
val parts = pair.split(':')
|
|
||||||
if (parts.size != 2) return@mapNotNull null
|
|
||||||
val itu = parts[0].trim().toIntOrNull() ?: return@mapNotNull null
|
|
||||||
val cq = parts[1].trim().toIntOrNull() ?: return@mapNotNull null
|
|
||||||
LoTWZonePair(itu, cq)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun stationFields(station: LoTWStation, dxcc: Int): Map<String, String> {
|
|
||||||
fun normalized(value: String) = value.trim().uppercase(Locale.US)
|
|
||||||
val grids = station.grid.split(',').map(::normalized).filter(String::isNotBlank).distinct()
|
|
||||||
if (grids.size !in 1..4 || grids.any { !it.matches(Regex("[A-R]{2}[0-9]{2}([A-X]{2}([0-9]{2})?)?")) }) {
|
|
||||||
fail(LoTWProblem.STATION_GRID)
|
|
||||||
}
|
|
||||||
// The primary grid goes into GRIDSQUARE (the field the config's tSTATION
|
|
||||||
// order signs); the full multi-grid set goes into MY_VUCC_GRIDS, an extra
|
|
||||||
// station field that TQSL stores but does not sign (same precedent as IOTA).
|
|
||||||
val fields = linkedMapOf("GRIDSQUARE" to grids.first())
|
|
||||||
if (grids.size > 1) fields["MY_VUCC_GRIDS"] = grids.joinToString(",")
|
|
||||||
fun zone(name: String, value: String, maximum: Int) {
|
|
||||||
if (value.isBlank()) return
|
|
||||||
val number = value.trim().toIntOrNull()?.takeIf { it in 1..maximum } ?: fail(LoTWProblem.STATION_ZONE)
|
|
||||||
fields[name] = number.toString()
|
|
||||||
}
|
|
||||||
zone("CQZ", station.cqZone, 40)
|
|
||||||
zone("ITUZ", station.ituZone, 90)
|
|
||||||
if (station.iota.isNotBlank()) {
|
|
||||||
val iota = normalized(station.iota)
|
|
||||||
if (!iota.matches(Regex("(AF|AN|AS|EU|NA|OC|SA)-[0-9]{3}"))) fail(LoTWProblem.STATION_IOTA)
|
|
||||||
fields["IOTA"] = iota
|
|
||||||
}
|
|
||||||
val pageFields = config.elements("page").filter { it.getAttribute("dependency") == dxcc.toString() }
|
|
||||||
.flatMap { it.elements("pageField") }.map { it.textContent }
|
|
||||||
val primary = pageFields.firstOrNull { it in primaryRegionFields }
|
|
||||||
val secondary = pageFields.firstOrNull { it in secondaryRegionFields }
|
|
||||||
fun region(name: String?, value: String) {
|
|
||||||
if (value.isBlank()) return
|
|
||||||
if (name == null) fail(LoTWProblem.STATION_REGION)
|
|
||||||
val field = config.elements("field").single { it.getAttribute("Id") == name }
|
|
||||||
val dependency = when (field.getAttribute("dependsOn")) {
|
|
||||||
"DXCC" -> dxcc.toString()
|
|
||||||
else -> fields[field.getAttribute("dependsOn")]
|
|
||||||
}
|
|
||||||
val options = field.elements("enums").filter {
|
|
||||||
it.getAttribute("dependency").isBlank() || it.getAttribute("dependency") == dependency
|
|
||||||
}.flatMap { it.elements("enum") }.map { it.getAttribute("value").uppercase(Locale.US) }
|
|
||||||
val code = normalized(value)
|
|
||||||
if (code !in options) fail(LoTWProblem.STATION_REGION, name)
|
|
||||||
fields[name] = code
|
|
||||||
}
|
|
||||||
region(primary, station.region)
|
|
||||||
region(secondary, station.county)
|
|
||||||
return fields
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun Element.elements(tag: String): List<Element> = getElementsByTagName(tag).let { nodes ->
|
|
||||||
(0 until nodes.length).map { nodes.item(it) as Element }
|
|
||||||
}
|
|
||||||
private fun Element.childElements(): List<Element> = (0 until childNodes.length).mapNotNull { childNodes.item(it) as? Element }
|
|
||||||
@@ -1,208 +0,0 @@
|
|||||||
/*
|
|
||||||
* 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
|
|
||||||
import com.rtbishop.look4sat.core.domain.repository.LoTWOperationException
|
|
||||||
import com.rtbishop.look4sat.core.domain.repository.LoTWProblem
|
|
||||||
import java.security.KeyStore
|
|
||||||
import java.security.PrivateKey
|
|
||||||
import java.security.Signature
|
|
||||||
import java.security.cert.X509Certificate
|
|
||||||
import java.text.SimpleDateFormat
|
|
||||||
import java.util.Collections
|
|
||||||
import java.util.Date
|
|
||||||
import java.util.Locale
|
|
||||||
import java.util.TimeZone
|
|
||||||
|
|
||||||
internal data class LoTWKeyMaterial(val key: PrivateKey, val certificate: X509Certificate, val info: LoTWCertificate) {
|
|
||||||
companion object {
|
|
||||||
/**
|
|
||||||
* Reads the private key and certificate out of a TrustedQSL .p12 backup.
|
|
||||||
*
|
|
||||||
* The platform PKCS12 parser is tried first because it resolves the certificate that
|
|
||||||
* belongs to the key entry. When it cannot deliver a usable pair — it may refuse the file
|
|
||||||
* outright (older Android cannot read PBES2 at all) or accept the container yet expose no
|
|
||||||
* key entry (Conscrypt/OpenSSL reads PBES2 but fails to recover the key of, for example,
|
|
||||||
* an empty-password backup) — our own PBES2 reader is consulted before reporting a problem,
|
|
||||||
* so a valid certificate is not rejected as "not a valid LoTW certificate file".
|
|
||||||
*
|
|
||||||
* [platformStore] is a seam that lets tests simulate a platform which accepts the file
|
|
||||||
* without exposing any key entry.
|
|
||||||
*/
|
|
||||||
internal fun read(
|
|
||||||
bytes: ByteArray,
|
|
||||||
password: CharArray,
|
|
||||||
now: Long,
|
|
||||||
platformStore: (ByteArray, CharArray) -> KeyStore? = { data, pass -> loadPlatformStore(data, pass) },
|
|
||||||
): LoTWKeyMaterial {
|
|
||||||
if (bytes.isEmpty() || bytes.size > MAX_CERTIFICATE_BYTES) fail(LoTWProblem.CERTIFICATE_INVALID)
|
|
||||||
val key: PrivateKey
|
|
||||||
val cert: X509Certificate
|
|
||||||
val outcome = platformStore(bytes, password)?.let { platformKeyAndCertificate(it, password) }
|
|
||||||
if (outcome is PlatformOutcome.Ok) {
|
|
||||||
key = outcome.key
|
|
||||||
cert = outcome.certificate
|
|
||||||
} else if (isPbes2(bytes)) {
|
|
||||||
val parsed = readWithPbes2Reader(bytes, password)
|
|
||||||
key = parsed.first
|
|
||||||
cert = parsed.second
|
|
||||||
} else {
|
|
||||||
fail((outcome as? PlatformOutcome.Unusable)?.problem ?: LoTWProblem.CERTIFICATE_PASSWORD)
|
|
||||||
}
|
|
||||||
if (key.algorithm != "RSA" || cert.publicKey.algorithm != "RSA") fail(LoTWProblem.CERTIFICATE_INVALID)
|
|
||||||
try { cert.checkValidity(Date(now)) } catch (_: Exception) { fail(LoTWProblem.CERTIFICATE_EXPIRED) }
|
|
||||||
val info = try { metadata(cert) } catch (_: Exception) { fail(LoTWProblem.CERTIFICATE_INVALID) }
|
|
||||||
// Use the platform's crypto implementation, including PKCS#1 padding. No custom cryptography.
|
|
||||||
val challenge = "Look4Sat LoTW certificate key check".toByteArray(Charsets.US_ASCII)
|
|
||||||
val signed = Signature.getInstance("SHA1withRSA").run { initSign(key); update(challenge); sign() }
|
|
||||||
val valid = Signature.getInstance("SHA1withRSA").run { initVerify(cert); update(challenge); verify(signed) }
|
|
||||||
if (!valid) fail(LoTWProblem.CERTIFICATE_INVALID)
|
|
||||||
return LoTWKeyMaterial(key, cert, info)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** What the platform PKCS12 parser managed to deliver for the key entry. */
|
|
||||||
private sealed interface PlatformOutcome {
|
|
||||||
data class Ok(val key: PrivateKey, val certificate: X509Certificate) : PlatformOutcome
|
|
||||||
|
|
||||||
/** The problem the platform path would have reported on its own. */
|
|
||||||
data class Unusable(val problem: LoTWProblem) : PlatformOutcome
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The platform PKCS12 keystore, or null when it refuses to read the file at all. */
|
|
||||||
private fun loadPlatformStore(bytes: ByteArray, password: CharArray): KeyStore? = try {
|
|
||||||
KeyStore.getInstance("PKCS12").apply { bytes.inputStream().use { load(it, password) } }
|
|
||||||
} catch (_: Exception) {
|
|
||||||
null
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Resolves the single key entry the platform offers, keeping its own failure reasons. */
|
|
||||||
private fun platformKeyAndCertificate(store: KeyStore, password: CharArray): PlatformOutcome {
|
|
||||||
val aliases = Collections.list(store.aliases()).filter { store.isKeyEntry(it) }
|
|
||||||
val alias = aliases.singleOrNull() ?: return PlatformOutcome.Unusable(LoTWProblem.CERTIFICATE_INVALID)
|
|
||||||
val key = try {
|
|
||||||
store.getKey(alias, password) as? PrivateKey
|
|
||||||
} catch (_: Exception) {
|
|
||||||
return PlatformOutcome.Unusable(LoTWProblem.CERTIFICATE_PASSWORD)
|
|
||||||
} ?: return PlatformOutcome.Unusable(LoTWProblem.CERTIFICATE_INVALID)
|
|
||||||
val certificate = store.getCertificate(alias) as? X509Certificate
|
|
||||||
?: return PlatformOutcome.Unusable(LoTWProblem.CERTIFICATE_INVALID)
|
|
||||||
return PlatformOutcome.Ok(key, certificate)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Our own PBES2 reader, mapping failures onto the message the operator should see. */
|
|
||||||
private fun readWithPbes2Reader(bytes: ByteArray, password: CharArray): Pair<PrivateKey, X509Certificate> = try {
|
|
||||||
Pkcs12Reader.read(bytes, password)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
// Classify by exception type so the user sees the right message:
|
|
||||||
// - BadPadding (BAD_DECRYPT) -> wrong password
|
|
||||||
// - IllegalStateException (error()) -> unsupported algorithm,
|
|
||||||
// message is the algorithm name
|
|
||||||
// - IllegalArgumentException (require()) -> structurally invalid file
|
|
||||||
fail(
|
|
||||||
when {
|
|
||||||
e is javax.crypto.BadPaddingException -> LoTWProblem.CERTIFICATE_PASSWORD
|
|
||||||
e is IllegalStateException && password.isNotEmpty() -> LoTWProblem.CERTIFICATE_FORMAT
|
|
||||||
else -> LoTWProblem.CERTIFICATE_INVALID
|
|
||||||
},
|
|
||||||
if (e is IllegalStateException) (e.message ?: "") else ""
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** True when the PKCS12 uses PBES2 (OID 1.2.840.113549.1.5.13), the default
|
|
||||||
* algorithm of OpenSSL 3 / modern TQSL. Android's legacy BC parser can't read it. */
|
|
||||||
private fun isPbes2(bytes: ByteArray): Boolean {
|
|
||||||
val oid = byteArrayOf(0x2a, 0x86.toByte(), 0x48, 0x86.toByte(), 0xf7.toByte(), 0x0d, 0x01, 0x05, 0x0d)
|
|
||||||
if (bytes.size < oid.size) return false
|
|
||||||
outer@ for (i in 0..bytes.size - oid.size) {
|
|
||||||
for (j in oid.indices) if (bytes[i + j] != oid[j]) continue@outer
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun metadata(cert: X509Certificate): LoTWCertificate {
|
|
||||||
val oid = byteArrayOf(0x2b, 0x06, 0x01, 0x04, 0x01, 0xe0.toByte(), 0x3c, 0x01, 0x01)
|
|
||||||
val subject = DerValue.read(cert.subjectX500Principal.encoded).single()
|
|
||||||
require(subject.tag == 0x30)
|
|
||||||
val calls = subject.children().flatMap { it.children() }.mapNotNull { attribute ->
|
|
||||||
val pair = attribute.children()
|
|
||||||
if (pair.size == 2 && pair[0].tag == 6 && pair[0].data.contentEquals(oid)) pair[1].text() else null
|
|
||||||
}
|
|
||||||
val call = calls.single().trim().uppercase(Locale.US)
|
|
||||||
require(call.matches(Regex("[A-Z0-9]+(/[A-Z0-9]+)*")) && call.any(Char::isDigit) && call.any(Char::isLetter))
|
|
||||||
val first = extension(cert, "2")
|
|
||||||
val last = extension(cert, "3").takeUnless { it == "0000-00-00" }.orEmpty()
|
|
||||||
require(validDate(first) && (last.isBlank() || validDate(last)) && (last.isBlank() || last >= first))
|
|
||||||
val dxcc = extension(cert, "4").toInt()
|
|
||||||
require(dxcc > 0)
|
|
||||||
return LoTWCertificate(call, dxcc, cert.serialNumber.toString(16), utc(cert.notAfter.time, "yyyy-MM-dd"), first, last)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun extension(cert: X509Certificate, suffix: String): String {
|
|
||||||
val bytes = cert.getExtensionValue("1.3.6.1.4.1.12348.1.$suffix") ?: return ""
|
|
||||||
val outer = DerValue.read(bytes).single()
|
|
||||||
require(outer.tag == 4)
|
|
||||||
// TrustedQSL uses raw ASCII extension values. Also accept DER string-wrapped values.
|
|
||||||
return if (outer.data.all { it.toInt() in 32..126 || it.toInt() == 0 }) {
|
|
||||||
outer.data.toString(Charsets.US_ASCII).trim('\u0000', ' ')
|
|
||||||
} else DerValue.read(outer.data).single().text().trim()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
internal const val MAX_CERTIFICATE_BYTES = 1024 * 1024
|
|
||||||
internal fun fail(problem: LoTWProblem, detail: String = ""): Nothing = throw LoTWOperationException(problem, detail)
|
|
||||||
internal fun utc(millis: Long, pattern: String): String = SimpleDateFormat(pattern, Locale.US).apply {
|
|
||||||
timeZone = TimeZone.getTimeZone("UTC")
|
|
||||||
}.format(Date(millis))
|
|
||||||
internal fun validDate(value: String): Boolean = value.matches(Regex("[0-9]{4}-[0-9]{2}-[0-9]{2}")) && runCatching {
|
|
||||||
SimpleDateFormat("yyyy-MM-dd", Locale.US).apply { isLenient = false }.parse(value) != null
|
|
||||||
}.getOrDefault(false)
|
|
||||||
|
|
||||||
/** Only bounded DER metadata decoding; cryptographic verification is delegated to JCA. */
|
|
||||||
internal data class DerValue(val tag: Int, val data: ByteArray) {
|
|
||||||
fun children(): List<DerValue> = read(data)
|
|
||||||
fun text(): String = when (tag) {
|
|
||||||
12, 19, 20, 22, 23, 24 -> data.toString(Charsets.UTF_8)
|
|
||||||
30 -> data.toString(Charsets.UTF_16BE)
|
|
||||||
else -> error("Not an ASN.1 string")
|
|
||||||
}
|
|
||||||
companion object {
|
|
||||||
fun read(bytes: ByteArray): List<DerValue> {
|
|
||||||
val result = mutableListOf<DerValue>()
|
|
||||||
var offset = 0
|
|
||||||
while (offset < bytes.size) {
|
|
||||||
require(bytes.size - offset >= 2)
|
|
||||||
val tag = bytes[offset++].toInt() and 255
|
|
||||||
var length = bytes[offset++].toInt() and 255
|
|
||||||
if (length and 128 != 0) {
|
|
||||||
val size = length and 127
|
|
||||||
require(size in 1..3 && size <= bytes.size - offset)
|
|
||||||
length = 0
|
|
||||||
repeat(size) { length = (length shl 8) or (bytes[offset++].toInt() and 255) }
|
|
||||||
}
|
|
||||||
require(length <= bytes.size - offset)
|
|
||||||
result += DerValue(tag, bytes.copyOfRange(offset, offset + length))
|
|
||||||
offset += length
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,110 +0,0 @@
|
|||||||
/*
|
|
||||||
* 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.QsoStatus
|
|
||||||
import com.rtbishop.look4sat.core.domain.logbook.isSatellite
|
|
||||||
import com.rtbishop.look4sat.core.domain.repository.LoTWProblem
|
|
||||||
import java.io.ByteArrayOutputStream
|
|
||||||
import java.math.BigDecimal
|
|
||||||
import java.security.MessageDigest
|
|
||||||
import java.security.Signature
|
|
||||||
import java.util.Locale
|
|
||||||
import java.util.zip.GZIPOutputStream
|
|
||||||
import kotlin.io.encoding.Base64
|
|
||||||
|
|
||||||
internal data class LoTWContact(val record: QsoRecord, val fields: Map<String, String>, val signData: String, val fingerprint: String)
|
|
||||||
|
|
||||||
internal class LoTWSigner(private val config: LoTWConfig) {
|
|
||||||
fun contact(record: QsoRecord, key: LoTWKeyMaterial, station: Map<String, String>, now: Long): LoTWContact {
|
|
||||||
val call = record.theirCallsign.trim().uppercase(Locale.US)
|
|
||||||
if (record.status != QsoStatus.COMPLETE || !call.matches(Regex("[A-Z0-9]+(/[A-Z0-9]+)*")) ||
|
|
||||||
!call.any(Char::isLetter) || !call.any(Char::isDigit)) fail(LoTWProblem.INVALID_CONTACT, call)
|
|
||||||
// Report the two callsigns the operator has to reconcile (record vs certificate). A generic
|
|
||||||
// sample — the opposite station's call — says nothing about which side is wrong, which is
|
|
||||||
// exactly what a "MY callsign does not match the certificate" message must tell.
|
|
||||||
if (!record.myCallsign.trim().equals(key.info.callsign, true)) {
|
|
||||||
fail(LoTWProblem.CALLSIGN_MISMATCH, "${record.myCallsign.trim()} ≠ ${key.info.callsign}")
|
|
||||||
}
|
|
||||||
val date = utc(record.startUtcMillis, "yyyy-MM-dd")
|
|
||||||
// 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)
|
|
||||||
}
|
|
||||||
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),
|
|
||||||
"BAND_RX" to config.band(record.rxBand, record.rxFrequencyHz, false),
|
|
||||||
"CALL" to call,
|
|
||||||
"FREQ" to mhz(record.txFrequencyHz),
|
|
||||||
"FREQ_RX" to mhz(record.rxFrequencyHz),
|
|
||||||
"MODE" to config.mode(record),
|
|
||||||
"PROP_MODE" to if (record.isSatellite) "SAT" else config.propagation(record.propagationMode),
|
|
||||||
"QSO_DATE" to date,
|
|
||||||
"QSO_TIME" to utc(record.startUtcMillis, "HH:mm:ss'Z'"),
|
|
||||||
"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()) +
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun sign(contacts: List<LoTWContact>, key: LoTWKeyMaterial, station: Map<String, String>): ByteArray {
|
|
||||||
val text = buildString {
|
|
||||||
append(field("TQSL_IDENT", "Look4Sat LoTW Config: V${config.version} AllowDupes: false"))
|
|
||||||
append(field("REC_TYPE", "tCERT")); append(field("CERT_UID", "1"))
|
|
||||||
append(field("CERTIFICATE", Base64.Default.encode(key.certificate.encoded).chunked(64).joinToString("\n")))
|
|
||||||
append("<eor>\n")
|
|
||||||
append(field("REC_TYPE", "tSTATION")); append(field("STATION_UID", "1")); append(field("CERT_UID", "1"))
|
|
||||||
append(field("CALL", key.info.callsign)); append(field("DXCC", key.info.dxcc.toString()))
|
|
||||||
station.forEach { (name, value) -> append(field(name, value)) }
|
|
||||||
append("<eor>\n")
|
|
||||||
contacts.forEach { contact ->
|
|
||||||
append(field("REC_TYPE", "tCONTACT")); append(field("STATION_UID", "1"))
|
|
||||||
contact.fields.forEach { (name, value) -> append(field(name, value)) }
|
|
||||||
// SHA-1 is required by the LoTW V2.0 wire format, not chosen for general-purpose signing.
|
|
||||||
val signature = Signature.getInstance("SHA1withRSA").run {
|
|
||||||
initSign(key.key); update(contact.signData.toByteArray(Charsets.UTF_8)); sign()
|
|
||||||
}
|
|
||||||
val encoded = Base64.Default.encode(signature).chunked(64).joinToString("\n")
|
|
||||||
append("<SIGN_LOTW_V2.0:${encoded.length}:6>$encoded\n")
|
|
||||||
append(field("SIGNDATA", contact.signData)); append("<eor>\n")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return ByteArrayOutputStream().also { output ->
|
|
||||||
GZIPOutputStream(output).use { it.write(text.toByteArray(Charsets.UTF_8)) }
|
|
||||||
}.toByteArray()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun field(name: String, value: String): String = "<$name:${value.toByteArray(Charsets.UTF_8).size}>$value\n"
|
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
/*
|
|
||||||
* 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 android.security.keystore.KeyGenParameterSpec
|
|
||||||
import android.security.keystore.KeyProperties
|
|
||||||
import android.util.AtomicFile
|
|
||||||
import java.io.File
|
|
||||||
import java.security.KeyStore
|
|
||||||
import javax.crypto.Cipher
|
|
||||||
import javax.crypto.KeyGenerator
|
|
||||||
import javax.crypto.SecretKey
|
|
||||||
import javax.crypto.spec.GCMParameterSpec
|
|
||||||
|
|
||||||
internal interface LoTWStorage {
|
|
||||||
fun read(name: String): ByteArray?
|
|
||||||
fun write(name: String, data: ByteArray)
|
|
||||||
fun delete(name: String)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** P12 backups and their saved passwords are encrypted at rest and excluded from Android/cloud backup. */
|
|
||||||
internal class AndroidLoTWStorage(context: Context) : LoTWStorage {
|
|
||||||
private val directory = File(context.noBackupFilesDir, "lotw")
|
|
||||||
private val store by lazy { KeyStore.getInstance("AndroidKeyStore").apply { load(null) } }
|
|
||||||
private fun file(name: String): AtomicFile {
|
|
||||||
require(name.matches(Regex("[a-z]+")))
|
|
||||||
check(directory.isDirectory || directory.mkdirs())
|
|
||||||
return AtomicFile(File(directory, name))
|
|
||||||
}
|
|
||||||
private fun key(): SecretKey = (store.getKey(ALIAS, null) as? SecretKey) ?: KeyGenerator.getInstance(
|
|
||||||
KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore"
|
|
||||||
).run {
|
|
||||||
init(KeyGenParameterSpec.Builder(ALIAS, KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT)
|
|
||||||
.setBlockModes(KeyProperties.BLOCK_MODE_GCM).setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
|
|
||||||
.setKeySize(256).build())
|
|
||||||
generateKey()
|
|
||||||
}
|
|
||||||
@Synchronized override fun read(name: String): ByteArray? {
|
|
||||||
val file = file(name)
|
|
||||||
if (!file.baseFile.exists() && !File(file.baseFile.path + ".bak").exists()) return null
|
|
||||||
val bytes = file.readFully()
|
|
||||||
require(bytes.size in 29..(16 * 1024 * 1024) && bytes[0] == 1.toByte())
|
|
||||||
return Cipher.getInstance("AES/GCM/NoPadding").run {
|
|
||||||
init(Cipher.DECRYPT_MODE, key(), GCMParameterSpec(128, bytes.copyOfRange(1, 13)))
|
|
||||||
updateAAD(name.toByteArray(Charsets.UTF_8))
|
|
||||||
doFinal(bytes, 13, bytes.size - 13)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@Synchronized override fun write(name: String, data: ByteArray) {
|
|
||||||
val cipher = Cipher.getInstance("AES/GCM/NoPadding").apply {
|
|
||||||
init(Cipher.ENCRYPT_MODE, key())
|
|
||||||
updateAAD(name.toByteArray(Charsets.UTF_8))
|
|
||||||
}
|
|
||||||
val bytes = byteArrayOf(1) + cipher.iv + cipher.doFinal(data)
|
|
||||||
val file = file(name)
|
|
||||||
val stream = file.startWrite()
|
|
||||||
try { stream.write(bytes); file.finishWrite(stream) }
|
|
||||||
catch (error: Exception) { file.failWrite(stream); throw error }
|
|
||||||
}
|
|
||||||
@Synchronized override fun delete(name: String) = file(name).delete()
|
|
||||||
private companion object { const val ALIAS = "look4sat.lotw.storage.v1" }
|
|
||||||
}
|
|
||||||
@@ -1,498 +0,0 @@
|
|||||||
/*
|
|
||||||
* 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
|
|
||||||
import com.rtbishop.look4sat.core.domain.repository.LoTWOperationException
|
|
||||||
import com.rtbishop.look4sat.core.domain.repository.LoTWProblem
|
|
||||||
import com.rtbishop.look4sat.core.domain.repository.LoTWStation
|
|
||||||
import com.rtbishop.look4sat.core.domain.repository.LoTWStationMeta
|
|
||||||
import com.rtbishop.look4sat.core.domain.repository.LoTWUploadAudit
|
|
||||||
import com.rtbishop.look4sat.core.domain.repository.LoTWUploadPreview
|
|
||||||
import com.rtbishop.look4sat.core.domain.repository.LoTWUploadResult
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.ensureActive
|
|
||||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
|
||||||
import kotlinx.coroutines.sync.Mutex
|
|
||||||
import kotlinx.coroutines.sync.withLock
|
|
||||||
import kotlinx.coroutines.withContext
|
|
||||||
import okhttp3.Call
|
|
||||||
import okhttp3.Callback
|
|
||||||
import okhttp3.MediaType.Companion.toMediaType
|
|
||||||
import okhttp3.MultipartBody
|
|
||||||
import okhttp3.OkHttpClient
|
|
||||||
import okhttp3.Request
|
|
||||||
import okhttp3.RequestBody.Companion.toRequestBody
|
|
||||||
import okhttp3.Response
|
|
||||||
import java.io.ByteArrayOutputStream
|
|
||||||
import java.io.DataInputStream
|
|
||||||
import java.io.DataOutputStream
|
|
||||||
import java.io.IOException
|
|
||||||
import java.nio.ByteBuffer
|
|
||||||
import java.nio.CharBuffer
|
|
||||||
import java.util.Locale
|
|
||||||
import java.util.UUID
|
|
||||||
import java.util.concurrent.TimeUnit
|
|
||||||
import kotlin.coroutines.coroutineContext
|
|
||||||
import kotlin.coroutines.resume
|
|
||||||
|
|
||||||
private fun readBoundedBody(response: Response, limit: Int): String = response.body.charStream().use { reader ->
|
|
||||||
val result = StringBuilder()
|
|
||||||
val buffer = CharArray(8192)
|
|
||||||
while (true) {
|
|
||||||
val count = reader.read(buffer)
|
|
||||||
if (count < 0) break
|
|
||||||
require(result.length <= limit - count) { "Response exceeds size limit" }
|
|
||||||
result.append(buffer, 0, count)
|
|
||||||
}
|
|
||||||
result.toString()
|
|
||||||
}
|
|
||||||
|
|
||||||
class LoTWUploadRepository internal constructor(
|
|
||||||
private val storage: LoTWStorage,
|
|
||||||
private val config: () -> LoTWConfig,
|
|
||||||
client: OkHttpClient = OkHttpClient(),
|
|
||||||
private val now: () -> Long = System::currentTimeMillis
|
|
||||||
) : ILoTWUploadRepository {
|
|
||||||
constructor(context: Context) : this(AndroidLoTWStorage(context), { LoTWConfig(context.assets.open("lotw/config.tq6")) })
|
|
||||||
private val client = client.newBuilder().connectTimeout(30, TimeUnit.SECONDS).readTimeout(120, TimeUnit.SECONDS)
|
|
||||||
.callTimeout(180, TimeUnit.SECONDS).retryOnConnectionFailure(false).followRedirects(false).followSslRedirects(false).build()
|
|
||||||
private val mutex = Mutex()
|
|
||||||
@Volatile private var pending: Pending? = null
|
|
||||||
private data class Pending(val preview: LoTWUploadPreview, val data: ByteArray, val hashes: List<String>, val created: Long)
|
|
||||||
private data class SigningContext(val key: LoTWKeyMaterial, val signer: LoTWSigner, val location: Map<String, String>)
|
|
||||||
|
|
||||||
override suspend fun certificate(): LoTWCertificate? = withContext(Dispatchers.IO) {
|
|
||||||
mutex.withLock {
|
|
||||||
val bytes = storage.read("certificate") ?: return@withLock null
|
|
||||||
try {
|
|
||||||
readBundle(bytes).let { bundle ->
|
|
||||||
bundle.p12.fill(0)
|
|
||||||
bundle.password?.fill(0)
|
|
||||||
bundle.info.copy(passwordSaved = bundle.password != null)
|
|
||||||
}
|
|
||||||
} finally { bytes.fill(0) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun importCertificate(data: ByteArray, password: CharArray): LoTWCertificate = withContext(Dispatchers.IO) {
|
|
||||||
mutex.withLock {
|
|
||||||
val passwordBytes = encodePassword(password)
|
|
||||||
try {
|
|
||||||
if (passwordBytes.size > MAX_CERTIFICATE_PASSWORD_BYTES) fail(LoTWProblem.CERTIFICATE_PASSWORD)
|
|
||||||
val key = LoTWKeyMaterial.read(data, password, now())
|
|
||||||
val bytes = encodeBundle(key.info, data, passwordBytes)
|
|
||||||
try { storage.write("certificate", bytes) } finally { bytes.fill(0) }
|
|
||||||
discardPreview()
|
|
||||||
key.info.copy(passwordSaved = true)
|
|
||||||
} finally {
|
|
||||||
passwordBytes.fill(0)
|
|
||||||
password.fill('\u0000')
|
|
||||||
data.fill(0)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun previewCertificate(data: ByteArray, password: CharArray): LoTWCertificate = withContext(Dispatchers.IO) {
|
|
||||||
mutex.withLock {
|
|
||||||
try {
|
|
||||||
if (data.isEmpty() || data.size > MAX_CERTIFICATE_BYTES) fail(LoTWProblem.CERTIFICATE_INVALID)
|
|
||||||
LoTWKeyMaterial.read(data, password, now()).info
|
|
||||||
} finally {
|
|
||||||
// Never keep the operator's password; the p12 bytes stay the caller's
|
|
||||||
// own copy (the dialog re-reads the file for every preview attempt).
|
|
||||||
password.fill('\u0000')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun saveCertificatePassword(password: CharArray): LoTWCertificate = withContext(Dispatchers.IO) {
|
|
||||||
mutex.withLock {
|
|
||||||
val stored = storage.read("certificate") ?: fail(LoTWProblem.CERTIFICATE_MISSING)
|
|
||||||
val bundle = try { readBundle(stored) } finally { stored.fill(0) }
|
|
||||||
val passwordBytes = encodePassword(password)
|
|
||||||
try {
|
|
||||||
if (passwordBytes.size > MAX_CERTIFICATE_PASSWORD_BYTES) fail(LoTWProblem.CERTIFICATE_PASSWORD)
|
|
||||||
val key = LoTWKeyMaterial.read(bundle.p12, password, now())
|
|
||||||
val bytes = encodeBundle(key.info, bundle.p12, passwordBytes)
|
|
||||||
try { storage.write("certificate", bytes) } finally { bytes.fill(0) }
|
|
||||||
discardPreview()
|
|
||||||
key.info.copy(passwordSaved = true)
|
|
||||||
} finally {
|
|
||||||
bundle.p12.fill(0)
|
|
||||||
bundle.password?.fill(0)
|
|
||||||
passwordBytes.fill(0)
|
|
||||||
password.fill('\u0000')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun station(): LoTWStation? = withContext(Dispatchers.IO) {
|
|
||||||
mutex.withLock { readStation() }
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun saveStation(station: LoTWStation): LoTWStation = withContext(Dispatchers.IO) {
|
|
||||||
mutex.withLock {
|
|
||||||
val stored = storage.read("certificate") ?: fail(LoTWProblem.CERTIFICATE_MISSING)
|
|
||||||
val bundle = try { readBundle(stored) } finally { stored.fill(0) }
|
|
||||||
val normalized = station.normalized()
|
|
||||||
try {
|
|
||||||
config().stationFields(normalized, bundle.info.dxcc)
|
|
||||||
} catch (e: LoTWOperationException) {
|
|
||||||
// A region saved under a different certificate no longer applies to
|
|
||||||
// this DXCC entity — drop it instead of failing the whole save.
|
|
||||||
if (e.reason != LoTWProblem.STATION_REGION) throw e
|
|
||||||
val cleaned = normalized.copy(region = "", county = "")
|
|
||||||
config().stationFields(cleaned, bundle.info.dxcc)
|
|
||||||
writeStation(cleaned)
|
|
||||||
discardPreview()
|
|
||||||
return@withLock cleaned
|
|
||||||
} finally {
|
|
||||||
bundle.p12.fill(0)
|
|
||||||
bundle.password?.fill(0)
|
|
||||||
}
|
|
||||||
writeStation(normalized)
|
|
||||||
discardPreview()
|
|
||||||
normalized
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun removeCertificate() = withContext(Dispatchers.IO) {
|
|
||||||
mutex.withLock { discardPreview(); storage.delete("certificate") }
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun stationMeta(dxcc: Int): LoTWStationMeta = withContext(Dispatchers.IO) {
|
|
||||||
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()
|
|
||||||
val ledger = ledger()
|
|
||||||
val unique = hashSetOf<String>()
|
|
||||||
var pendingCount = 0
|
|
||||||
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++
|
|
||||||
incomplete++
|
|
||||||
}
|
|
||||||
|
|
||||||
record.lotwReceived || record.lotwUploaded -> uploaded++
|
|
||||||
else -> {
|
|
||||||
val contact = try {
|
|
||||||
signing.signer.contact(record, signing.key, signing.location, now())
|
|
||||||
} 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(duplicateKey(contact.record)) -> {
|
|
||||||
unavailable++
|
|
||||||
duplicates++
|
|
||||||
}
|
|
||||||
|
|
||||||
ledger[contact.fingerprint] == "accepted" -> uploaded++
|
|
||||||
ledger[contact.fingerprint] == "unknown" -> unknown++
|
|
||||||
else -> pendingCount++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
LoTWUploadAudit(
|
|
||||||
records.size, pendingCount, uploaded, unknown, unavailable,
|
|
||||||
reasons, details, duplicates, incomplete
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun prepare(records: List<QsoRecord>, resubmit: Boolean): LoTWUploadPreview = withContext(Dispatchers.IO) {
|
|
||||||
mutex.withLock {
|
|
||||||
discardPreview()
|
|
||||||
if (records.size > 10_000) fail(LoTWProblem.TOO_MANY_CONTACTS)
|
|
||||||
val signing = signingContext()
|
|
||||||
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 certificateCallsign = signing.key.info.callsign
|
|
||||||
val substitutedIds = mutableSetOf<Long>()
|
|
||||||
val callsignConflicts = mutableListOf<Long>()
|
|
||||||
val contacts = records.sortedBy { it.startUtcMillis }.mapNotNull { record ->
|
|
||||||
coroutineContext.ensureActive()
|
|
||||||
if (record.status != QsoStatus.COMPLETE || ((record.lotwReceived || record.lotwUploaded) && !resubmit)) { skipped++; return@mapNotNull null }
|
|
||||||
// A record logged while no certificate was installed carries no own callsign: sign
|
|
||||||
// it with the certificate's callsign (the preview says so, and an accepted upload
|
|
||||||
// writes the callsign into the record). A record naming a DIFFERENT callsign may be
|
|
||||||
// someone else's contact, so it stays refused — the conflict list lets the operator
|
|
||||||
// rewrite those records or import the other certificate.
|
|
||||||
val signable = if (record.myCallsign.isBlank()) {
|
|
||||||
substitutedIds += record.id
|
|
||||||
record.copy(myCallsign = certificateCallsign)
|
|
||||||
} else record
|
|
||||||
val contact = try {
|
|
||||||
signing.signer.contact(signable, 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
|
|
||||||
if (e.reason == LoTWProblem.CALLSIGN_MISMATCH) callsignConflicts += record.id
|
|
||||||
return@mapNotNull null
|
|
||||||
}
|
|
||||||
val previous = ledger[contact.fingerprint]
|
|
||||||
when {
|
|
||||||
(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 submittedIds = contacts.map { it.record.id }
|
|
||||||
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,
|
|
||||||
unavailable,
|
|
||||||
reasons.toMap(),
|
|
||||||
duplicates,
|
|
||||||
submittedIds,
|
|
||||||
locationGrids,
|
|
||||||
resubmit,
|
|
||||||
// Only the callsign-filled records that actually made it into this batch are reported.
|
|
||||||
submittedIds.count { it in substitutedIds },
|
|
||||||
callsignConflicts
|
|
||||||
)
|
|
||||||
if (contacts.isNotEmpty()) pending = Pending(
|
|
||||||
preview,
|
|
||||||
signing.signer.sign(contacts, signing.key, signing.location),
|
|
||||||
contacts.map { it.fingerprint },
|
|
||||||
now()
|
|
||||||
)
|
|
||||||
preview
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun upload(previewId: String): LoTWUploadResult = withContext(Dispatchers.IO) {
|
|
||||||
mutex.withLock {
|
|
||||||
val payload = pending?.takeIf { it.preview.id == previewId && now() - it.created in 0..900_000 }
|
|
||||||
?: return@withLock LoTWUploadResult.ExpiredPreview
|
|
||||||
pending = null // A preview is a one-shot authorization, including across failures.
|
|
||||||
val ledger = ledger()
|
|
||||||
payload.hashes.forEach { ledger[it] = "unknown" }
|
|
||||||
saveLedger(ledger) // Persist BEFORE the POST, so process death/cancellation cannot cause an automatic retry.
|
|
||||||
try {
|
|
||||||
val result = post(payload.data, payload.preview.count)
|
|
||||||
when (result) {
|
|
||||||
is LoTWUploadResult.Accepted -> payload.hashes.forEach { ledger[it] = "accepted" }
|
|
||||||
is LoTWUploadResult.Rejected -> payload.hashes.forEach { ledger.remove(it) }
|
|
||||||
else -> Unit
|
|
||||||
}
|
|
||||||
saveLedger(ledger)
|
|
||||||
result
|
|
||||||
} finally { payload.data.fill(0) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun discardPreview() { pending?.data?.fill(0); pending = null }
|
|
||||||
|
|
||||||
private fun signingContext(): SigningContext {
|
|
||||||
val stored = storage.read("certificate") ?: fail(LoTWProblem.CERTIFICATE_MISSING)
|
|
||||||
val bundle = try { readBundle(stored) } finally { stored.fill(0) }
|
|
||||||
val passwordBytes = bundle.password ?: run {
|
|
||||||
bundle.p12.fill(0)
|
|
||||||
fail(LoTWProblem.CERTIFICATE_PASSWORD)
|
|
||||||
}
|
|
||||||
val password = decodePassword(passwordBytes)
|
|
||||||
return try {
|
|
||||||
val key = LoTWKeyMaterial.read(bundle.p12, password, now())
|
|
||||||
val tqslConfig = config()
|
|
||||||
val signer = LoTWSigner(tqslConfig)
|
|
||||||
val station = readStation() ?: fail(LoTWProblem.STATION_GRID)
|
|
||||||
SigningContext(key, signer, tqslConfig.stationFields(station, key.info.dxcc))
|
|
||||||
} finally {
|
|
||||||
bundle.p12.fill(0)
|
|
||||||
passwordBytes.fill(0)
|
|
||||||
password.fill('\u0000')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun readStation(): LoTWStation? = storage.read("station")?.inputStream()?.let { stream ->
|
|
||||||
DataInputStream(stream).use { input ->
|
|
||||||
require(input.readInt() == 1)
|
|
||||||
LoTWStation(input.readUTF(), input.readUTF(), input.readUTF(), input.readUTF(), input.readUTF(), input.readUTF())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun writeStation(station: LoTWStation) {
|
|
||||||
val bytes = ByteArrayOutputStream().also { stream -> DataOutputStream(stream).use { output ->
|
|
||||||
output.writeInt(1)
|
|
||||||
listOf(station.grid, station.cqZone, station.ituZone, station.region, station.county, station.iota).forEach(output::writeUTF)
|
|
||||||
} }.toByteArray()
|
|
||||||
try { storage.write("station", bytes) } finally { bytes.fill(0) }
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun post(data: ByteArray, count: Int): LoTWUploadResult = suspendCancellableCoroutine { continuation ->
|
|
||||||
val body = MultipartBody.Builder().setType(MultipartBody.FORM)
|
|
||||||
.addFormDataPart("upfile", "look4sat-${UUID.randomUUID()}.tq8", data.toRequestBody("application/octet-stream".toMediaType())).build()
|
|
||||||
val call = client.newCall(Request.Builder().url("https://lotw.arrl.org/lotw/upload").post(body).build())
|
|
||||||
continuation.invokeOnCancellation { call.cancel() }
|
|
||||||
call.enqueue(object : Callback {
|
|
||||||
override fun onFailure(call: Call, e: IOException) {
|
|
||||||
if (continuation.isActive) continuation.resume(LoTWUploadResult.Unknown)
|
|
||||||
}
|
|
||||||
override fun onResponse(call: Call, response: Response) {
|
|
||||||
val result = try {
|
|
||||||
response.use {
|
|
||||||
if (!it.isSuccessful) LoTWUploadResult.Unknown else parseLoTWUploadResponse(readBoundedBody(it, 256 * 1024), count)
|
|
||||||
}
|
|
||||||
} catch (_: Exception) { LoTWUploadResult.Unknown }
|
|
||||||
if (continuation.isActive) continuation.resume(result)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun ledger(): MutableMap<String, String> = storage.read("receipts")?.inputStream()?.let { stream ->
|
|
||||||
DataInputStream(stream).use { input ->
|
|
||||||
require(input.readInt() == 1)
|
|
||||||
val size = input.readInt().also { require(it in 0..200_000) }
|
|
||||||
val entries = linkedMapOf<String, String>()
|
|
||||||
repeat(size) { entries[input.readUTF()] = input.readUTF() }
|
|
||||||
entries
|
|
||||||
}
|
|
||||||
} ?: linkedMapOf()
|
|
||||||
|
|
||||||
private fun saveLedger(entries: Map<String, String>) {
|
|
||||||
val bytes = ByteArrayOutputStream().also { stream -> DataOutputStream(stream).use { output ->
|
|
||||||
output.writeInt(1); output.writeInt(entries.size)
|
|
||||||
entries.forEach { (hash, status) -> output.writeUTF(hash); output.writeUTF(status) }
|
|
||||||
} }.toByteArray()
|
|
||||||
storage.write("receipts", bytes)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
internal fun parseLoTWUploadResponse(body: String, count: Int): LoTWUploadResult {
|
|
||||||
val markers = Regex("<!--\\s*\\.UPL\\.\\s*(accepted|rejected)\\s*-->", RegexOption.IGNORE_CASE)
|
|
||||||
.findAll(body).map { it.groupValues[1].lowercase() }.toList()
|
|
||||||
return when (markers.singleOrNull()) {
|
|
||||||
"accepted" -> LoTWUploadResult.Accepted(count)
|
|
||||||
"rejected" -> {
|
|
||||||
val message = Regex("<!--\\s*\\.UPLMESSAGE\\.\\s*(.*?)-->", setOf(RegexOption.IGNORE_CASE, RegexOption.DOT_MATCHES_ALL))
|
|
||||||
.find(body)?.groupValues?.get(1).orEmpty().replace(Regex("<[^>]*>"), " ")
|
|
||||||
.replace(Regex("[\\p{Cc}\\s]+"), " ").trim().take(500)
|
|
||||||
LoTWUploadResult.Rejected(message)
|
|
||||||
}
|
|
||||||
else -> LoTWUploadResult.Unknown
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private data class StoredCertificate(val info: LoTWCertificate, val p12: ByteArray, val password: ByteArray?)
|
|
||||||
|
|
||||||
private fun encodeBundle(info: LoTWCertificate, p12: ByteArray, password: ByteArray): ByteArray = ByteArrayOutputStream().also { stream ->
|
|
||||||
DataOutputStream(stream).use { output ->
|
|
||||||
output.writeInt(2)
|
|
||||||
output.writeUTF(info.callsign); output.writeInt(info.dxcc); output.writeUTF(info.serial)
|
|
||||||
output.writeUTF(info.expires); output.writeUTF(info.firstQsoDate); output.writeUTF(info.lastQsoDate)
|
|
||||||
output.writeInt(password.size); output.write(password)
|
|
||||||
output.writeInt(p12.size); output.write(p12)
|
|
||||||
}
|
|
||||||
}.toByteArray()
|
|
||||||
|
|
||||||
private fun readBundle(bytes: ByteArray): StoredCertificate = DataInputStream(bytes.inputStream()).use { input ->
|
|
||||||
val version = input.readInt().also { require(it in 1..2) }
|
|
||||||
val info = LoTWCertificate(input.readUTF(), input.readInt(), input.readUTF(), input.readUTF(), input.readUTF(), input.readUTF())
|
|
||||||
val password = if (version >= 2) {
|
|
||||||
val size = input.readInt().also { require(it in 0..MAX_CERTIFICATE_PASSWORD_BYTES) }
|
|
||||||
ByteArray(size).also { input.readFully(it) }
|
|
||||||
} else null
|
|
||||||
val p12Size = input.readInt().also { require(it in 1..MAX_CERTIFICATE_BYTES) }
|
|
||||||
StoredCertificate(info, ByteArray(p12Size).also { input.readFully(it) }, password)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun encodePassword(password: CharArray): ByteArray {
|
|
||||||
val buffer = Charsets.UTF_8.encode(CharBuffer.wrap(password))
|
|
||||||
return ByteArray(buffer.remaining()).also(buffer::get)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun decodePassword(password: ByteArray): CharArray {
|
|
||||||
val buffer = Charsets.UTF_8.decode(ByteBuffer.wrap(password))
|
|
||||||
return CharArray(buffer.remaining()).also(buffer::get)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun LoTWStation.normalized() = LoTWStation(
|
|
||||||
grid.trim().uppercase(Locale.US),
|
|
||||||
cqZone.trim(),
|
|
||||||
ituZone.trim(),
|
|
||||||
region.trim().uppercase(Locale.US),
|
|
||||||
county.trim().uppercase(Locale.US),
|
|
||||||
iota.trim().uppercase(Locale.US)
|
|
||||||
)
|
|
||||||
|
|
||||||
private const val MAX_CERTIFICATE_PASSWORD_BYTES = 16 * 1024
|
|
||||||
@@ -1,301 +0,0 @@
|
|||||||
/*
|
|
||||||
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
|
||||||
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
|
||||||
*
|
|
||||||
* This program is free software: you can redistribute it and/or modify
|
|
||||||
* it under the terms of the GNU General Public License as published by
|
|
||||||
* the Free Software Foundation, either version 3 of the License, or
|
|
||||||
* (at your option) any later version.
|
|
||||||
*
|
|
||||||
* This program is distributed in the hope that it will be useful,
|
|
||||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
* GNU General Public License for more details.
|
|
||||||
*
|
|
||||||
* You should have received a copy of the GNU General Public License
|
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package com.rtbishop.look4sat.core.data.lotw
|
|
||||||
|
|
||||||
import java.io.ByteArrayInputStream
|
|
||||||
import java.security.KeyFactory
|
|
||||||
import java.security.PrivateKey
|
|
||||||
import java.security.cert.CertificateFactory
|
|
||||||
import java.security.cert.X509Certificate
|
|
||||||
import java.security.spec.PKCS8EncodedKeySpec
|
|
||||||
import javax.crypto.Cipher
|
|
||||||
import javax.crypto.SecretKeyFactory
|
|
||||||
import javax.crypto.spec.IvParameterSpec
|
|
||||||
import javax.crypto.spec.PBEKeySpec
|
|
||||||
import javax.crypto.spec.SecretKeySpec
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Minimal PKCS#12 reader for the PBES2/AES-CBC format that OpenSSL 3 and modern
|
|
||||||
* TQSL produce by default. Android's legacy bundled Bouncy Castle PKCS12 parser
|
|
||||||
* cannot handle PBES2, so we parse the DER structure ourselves and decrypt with
|
|
||||||
* the platform JCE (PBKDF2WithHmacSHA1/256 + AES/CBC), which ships on Android.
|
|
||||||
*
|
|
||||||
* Handles both layouts:
|
|
||||||
* - OpenSSL `-certpbe NONE`: plaintext certificate bags + a PBES2-shrouded key
|
|
||||||
* inside a plaintext `data` ContentInfo.
|
|
||||||
* - Modern TQSL: the certificate SafeContents wrapped in an `encryptedData`
|
|
||||||
* ContentInfo (PBES2), plus the PBES2-shrouded key in a plaintext `data`
|
|
||||||
* ContentInfo.
|
|
||||||
*/
|
|
||||||
internal object Pkcs12Reader {
|
|
||||||
|
|
||||||
private val OID_DATA = byteArrayOf(0x2a, 0x86.toByte(), 0x48, 0x86.toByte(), 0xf7.toByte(), 0x0d, 0x01, 0x07, 0x01)
|
|
||||||
private val OID_ENCRYPTED_DATA = byteArrayOf(0x2a, 0x86.toByte(), 0x48, 0x86.toByte(), 0xf7.toByte(), 0x0d, 0x01, 0x07, 0x06)
|
|
||||||
private val OID_PKCS8_SHROUDED_KEY_BAG = byteArrayOf(0x2a, 0x86.toByte(), 0x48, 0x86.toByte(), 0xf7.toByte(), 0x0d, 0x01, 0x0c, 0x0a, 0x01, 0x02)
|
|
||||||
private val OID_CERT_BAG = byteArrayOf(0x2a, 0x86.toByte(), 0x48, 0x86.toByte(), 0xf7.toByte(), 0x0d, 0x01, 0x0c, 0x0a, 0x01, 0x03)
|
|
||||||
private val OID_X509_CERT = byteArrayOf(0x2a, 0x86.toByte(), 0x48, 0x86.toByte(), 0xf7.toByte(), 0x0d, 0x01, 0x09, 0x16, 0x01)
|
|
||||||
private val OID_PBES2 = byteArrayOf(0x2a, 0x86.toByte(), 0x48, 0x86.toByte(), 0xf7.toByte(), 0x0d, 0x01, 0x05, 0x0d)
|
|
||||||
private val OID_PBKDF2 = byteArrayOf(0x2a, 0x86.toByte(), 0x48, 0x86.toByte(), 0xf7.toByte(), 0x0d, 0x01, 0x05, 0x0c)
|
|
||||||
private val OID_HMAC_SHA1 = byteArrayOf(0x2a, 0x86.toByte(), 0x48, 0x86.toByte(), 0xf7.toByte(), 0x0d, 0x02, 0x07)
|
|
||||||
private val OID_HMAC_SHA256 = byteArrayOf(0x2a, 0x86.toByte(), 0x48, 0x86.toByte(), 0xf7.toByte(), 0x0d, 0x02, 0x09)
|
|
||||||
private val OID_HMAC_SHA384 = byteArrayOf(0x2a, 0x86.toByte(), 0x48, 0x86.toByte(), 0xf7.toByte(), 0x0d, 0x02, 0x0a)
|
|
||||||
private val OID_HMAC_SHA512 = byteArrayOf(0x2a, 0x86.toByte(), 0x48, 0x86.toByte(), 0xf7.toByte(), 0x0d, 0x02, 0x0b)
|
|
||||||
private val OID_AES_256_CBC = byteArrayOf(0x60, 0x86.toByte(), 0x48, 0x01, 0x65, 0x03, 0x04, 0x01, 0x2a)
|
|
||||||
private val OID_AES_192_CBC = byteArrayOf(0x60, 0x86.toByte(), 0x48, 0x01, 0x65, 0x03, 0x04, 0x01, 0x16)
|
|
||||||
private val OID_AES_128_CBC = byteArrayOf(0x60, 0x86.toByte(), 0x48, 0x01, 0x65, 0x03, 0x04, 0x01, 0x02)
|
|
||||||
private val OID_DES_EDE3_CBC = byteArrayOf(0x2a, 0x86.toByte(), 0x48, 0x86.toByte(), 0xf7.toByte(), 0x0d, 0x03, 0x07)
|
|
||||||
|
|
||||||
fun read(bytes: ByteArray, password: CharArray): Pair<PrivateKey, X509Certificate> {
|
|
||||||
val pfx = DerReader.read(bytes)
|
|
||||||
require(pfx.tag == 0x30) { "not a PFX" }
|
|
||||||
val pfxChildren = DerReader.children(pfx.content)
|
|
||||||
require(pfxChildren.size >= 2) { "PFX too small" }
|
|
||||||
// authSafe ContentInfo
|
|
||||||
val authSafeCi = DerReader.children(pfxChildren[1].content)
|
|
||||||
require(authSafeCi.size >= 2) { "authSafe malformed" }
|
|
||||||
// content: [0] EXPLICIT OCTET STRING -> AuthenticatedSafe
|
|
||||||
val explicit = DerReader.children(authSafeCi[1].content).first()
|
|
||||||
val octet = DerReader.read(explicit.content)
|
|
||||||
// octet.content is the AuthenticatedSafe body: a sequence of ContentInfo.
|
|
||||||
var privateKey: PrivateKey? = null
|
|
||||||
val certs = mutableListOf<X509Certificate>()
|
|
||||||
for (info in DerReader.children(octet.content)) {
|
|
||||||
val parts = DerReader.children(info.content)
|
|
||||||
if (parts.isEmpty()) continue
|
|
||||||
when {
|
|
||||||
parts[0].content.contentEquals(OID_DATA) ->
|
|
||||||
parseSafeContentsContent(parts, password, certs) { privateKey = it }
|
|
||||||
parts[0].content.contentEquals(OID_ENCRYPTED_DATA) ->
|
|
||||||
parseEncryptedData(parts, password, certs)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
val key = privateKey ?: error("no private key bag found")
|
|
||||||
require(certs.isNotEmpty()) { "no certificate bag found" }
|
|
||||||
return key to certs[0]
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A plaintext ContentInfo: [0] EXPLICIT OCTET STRING -> full SafeContents DER. */
|
|
||||||
private fun parseSafeContentsContent(
|
|
||||||
parts: List<Der>,
|
|
||||||
password: CharArray,
|
|
||||||
certs: MutableList<X509Certificate>,
|
|
||||||
onKey: (PrivateKey) -> Unit
|
|
||||||
) {
|
|
||||||
val explicit = DerReader.children(parts[1].content).first()
|
|
||||||
parseSafeBags(explicit.content, password, certs, onKey)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** EncryptedData ContentInfo: version, EncryptedContentInfo{ oid, PBES2 alg, [0] IMPLICIT OCTET }. */
|
|
||||||
private fun parseEncryptedData(
|
|
||||||
parts: List<Der>,
|
|
||||||
password: CharArray,
|
|
||||||
certs: MutableList<X509Certificate>
|
|
||||||
) {
|
|
||||||
// parts[0] = encryptedData OID; parts[1] = [0] EXPLICIT EncryptedData SEQ
|
|
||||||
val explicit = DerReader.children(parts[1].content).first()
|
|
||||||
val eciParts = DerReader.children(explicit.content)
|
|
||||||
// eciParts = [version INT, EncryptedContentInfo SEQ]
|
|
||||||
require(eciParts.size >= 2) { "EncryptedData malformed" }
|
|
||||||
val eci = DerReader.children(eciParts[1].content)
|
|
||||||
// eci = [contentType OID, contentEncryptionAlgorithm, [0] IMPLICIT OCTET STRING]
|
|
||||||
require(eci.size >= 3) { "EncryptedContentInfo malformed" }
|
|
||||||
val alg = eci[1]
|
|
||||||
val ciphertext = eci[2].content // [0] IMPLICIT OCTET STRING -> raw bytes
|
|
||||||
val safeContents = decryptPbes2(alg, ciphertext, password)
|
|
||||||
// The decrypted SafeContents holds certificate bags.
|
|
||||||
parseSafeBags(safeContents, password, certs) {}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Decrypt a PBES2-encrypted blob given its AlgorithmIdentifier. */
|
|
||||||
private fun decryptPbes2(algorithm: Der, encrypted: ByteArray, password: CharArray): ByteArray {
|
|
||||||
val algParts = DerReader.children(algorithm.content)
|
|
||||||
require(algParts.size >= 2 && algParts[0].content.contentEquals(OID_PBES2)) { "not PBES2" }
|
|
||||||
// PBES2-params ::= SEQUENCE { kdf AlgorithmIdentifier, enc AlgorithmIdentifier }
|
|
||||||
val pbes2 = DerReader.children(algParts[1].content)
|
|
||||||
val kdf = DerReader.children(pbes2[0].content)
|
|
||||||
// KDF must be PBKDF2 (OpenSSL 3 also supports scrypt — not available on the
|
|
||||||
// platform JCE, so report it by name instead of a generic failure).
|
|
||||||
if (kdf.isEmpty() || !kdf[0].content.contentEquals(OID_PBKDF2)) {
|
|
||||||
error(oidName(kdf.firstOrNull()?.content))
|
|
||||||
}
|
|
||||||
val kdfParams = DerReader.children(kdf[1].content)
|
|
||||||
val salt = kdfParams[0].content
|
|
||||||
val iterations = readInt(kdfParams[1].content)
|
|
||||||
require(iterations in 1..10_000_000) { "implausible PBKDF2 iteration count" }
|
|
||||||
// Optional PBKDF2-params elements: keyLength (INTEGER) and/or prf (SEQUENCE),
|
|
||||||
// in either order. Distinguish by DER tag — mistaking prf for keyLength yields
|
|
||||||
// an absurd key size and a PBKDF2 that runs for hours.
|
|
||||||
var keyBits = -1 // -1 = not written; defaulted below from the cipher
|
|
||||||
var prfName = "PBKDF2WithHmacSHA1"
|
|
||||||
for (i in 2 until kdfParams.size) {
|
|
||||||
val param = kdfParams[i]
|
|
||||||
when (param.tag) {
|
|
||||||
0x02 -> keyBits = readInt(param.content) * 8
|
|
||||||
0x30 -> {
|
|
||||||
val prf = DerReader.children(param.content)
|
|
||||||
prfName = when {
|
|
||||||
prf.isEmpty() || prf[0].content.contentEquals(OID_HMAC_SHA1) -> "PBKDF2WithHmacSHA1"
|
|
||||||
prf[0].content.contentEquals(OID_HMAC_SHA256) -> "PBKDF2WithHmacSHA256"
|
|
||||||
prf[0].content.contentEquals(OID_HMAC_SHA384) -> "PBKDF2WithHmacSHA384"
|
|
||||||
prf[0].content.contentEquals(OID_HMAC_SHA512) -> "PBKDF2WithHmacSHA512"
|
|
||||||
else -> error(oidName(prf[0].content))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
val enc = DerReader.children(pbes2[1].content)
|
|
||||||
val encOid = enc[0].content
|
|
||||||
val iv = enc[1].content
|
|
||||||
val (cipherName, aesKeyBits) = when {
|
|
||||||
encOid.contentEquals(OID_AES_256_CBC) -> "AES/CBC/PKCS5Padding" to 256
|
|
||||||
encOid.contentEquals(OID_AES_192_CBC) -> "AES/CBC/PKCS5Padding" to 192
|
|
||||||
encOid.contentEquals(OID_AES_128_CBC) -> "AES/CBC/PKCS5Padding" to 128
|
|
||||||
encOid.contentEquals(OID_DES_EDE3_CBC) -> "DESede/CBC/PKCS5Padding" to 192
|
|
||||||
else -> error(oidName(encOid))
|
|
||||||
}
|
|
||||||
val finalKeyBits = if (keyBits > 0) keyBits else aesKeyBits
|
|
||||||
require(finalKeyBits in 128..512) { "implausible PBKDF2 key size" }
|
|
||||||
|
|
||||||
val spec = PBEKeySpec(password, salt, iterations, finalKeyBits)
|
|
||||||
val secretKey = try {
|
|
||||||
SecretKeyFactory.getInstance(prfName).generateSecret(spec)
|
|
||||||
} catch (e: java.security.NoSuchAlgorithmException) {
|
|
||||||
// e.g. PBKDF2WithHmacSHA512 needs API 26+; surface the real reason.
|
|
||||||
error(prfName.replace("PBKDF2WithHmac", "PBKDF2-HMAC-") + " (needs Android 8.0+)")
|
|
||||||
}
|
|
||||||
val cipher = Cipher.getInstance(cipherName)
|
|
||||||
cipher.init(
|
|
||||||
Cipher.DECRYPT_MODE,
|
|
||||||
if (cipherName == "AES/CBC/PKCS5Padding") SecretKeySpec(secretKey.encoded, "AES")
|
|
||||||
else SecretKeySpec(secretKey.encoded, "DESede"),
|
|
||||||
IvParameterSpec(iv)
|
|
||||||
)
|
|
||||||
return cipher.doFinal(encrypted)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Human-readable name for a known algorithm OID. */
|
|
||||||
private fun oidName(oid: ByteArray?): String = when {
|
|
||||||
oid == null -> "unknown algorithm"
|
|
||||||
oid.contentEquals(OID_PBKDF2) -> "PBKDF2"
|
|
||||||
oid.contentEquals(OID_HMAC_SHA1) -> "PBKDF2-HMAC-SHA1"
|
|
||||||
oid.contentEquals(OID_HMAC_SHA256) -> "PBKDF2-HMAC-SHA256"
|
|
||||||
oid.contentEquals(OID_HMAC_SHA384) -> "PBKDF2-HMAC-SHA384"
|
|
||||||
oid.contentEquals(OID_HMAC_SHA512) -> "PBKDF2-HMAC-SHA512"
|
|
||||||
oid.contentEquals(OID_AES_256_CBC) -> "AES-256-CBC"
|
|
||||||
oid.contentEquals(OID_AES_192_CBC) -> "AES-192-CBC"
|
|
||||||
oid.contentEquals(OID_AES_128_CBC) -> "AES-128-CBC"
|
|
||||||
oid.contentEquals(OID_DES_EDE3_CBC) -> "DES-EDE3-CBC"
|
|
||||||
else -> "unknown algorithm"
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun parseSafeBags(
|
|
||||||
safeContentsDer: ByteArray,
|
|
||||||
password: CharArray,
|
|
||||||
certs: MutableList<X509Certificate>,
|
|
||||||
onKey: (PrivateKey) -> Unit
|
|
||||||
) {
|
|
||||||
val safeContents = DerReader.read(safeContentsDer)
|
|
||||||
require(safeContents.tag == 0x30) { "SafeContents malformed" }
|
|
||||||
for (bag in DerReader.children(safeContents.content)) {
|
|
||||||
val bagParts = DerReader.children(bag.content)
|
|
||||||
if (bagParts.size < 2) continue
|
|
||||||
val bagOid = bagParts[0].content
|
|
||||||
val bagValue = DerReader.children(bagParts[1].content).first()
|
|
||||||
when {
|
|
||||||
bagOid.contentEquals(OID_PKCS8_SHROUDED_KEY_BAG) ->
|
|
||||||
onKey(decryptShroudedKeyBag(bagValue, password))
|
|
||||||
bagOid.contentEquals(OID_CERT_BAG) -> parseCertBag(bagValue, certs)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun decryptShroudedKeyBag(bagValue: Der, password: CharArray): PrivateKey {
|
|
||||||
// bagValue = the SafeBag value SEQ (EncryptedPrivateKeyInfo): children are
|
|
||||||
// [AlgorithmIdentifier, encryptedData OCTET STRING].
|
|
||||||
val parts = DerReader.children(bagValue.content)
|
|
||||||
require(parts.size == 2) { "EncryptedPrivateKeyInfo malformed" }
|
|
||||||
val pkcs8 = decryptPbes2(parts[0], parts[1].content, password)
|
|
||||||
return KeyFactory.getInstance("RSA").generatePrivate(PKCS8EncodedKeySpec(pkcs8))
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun parseCertBag(bagValue: Der, certs: MutableList<X509Certificate>) {
|
|
||||||
// bagValue = the SafeBag value SEQ (CertBag): children are
|
|
||||||
// [certType OID, [0] EXPLICIT OCTET STRING (full X.509 DER)].
|
|
||||||
val parts = DerReader.children(bagValue.content)
|
|
||||||
if (parts.size < 2) return
|
|
||||||
if (!parts[0].content.contentEquals(OID_X509_CERT)) return
|
|
||||||
// [0] EXPLICIT -> OCTET STRING -> full X.509 certificate DER.
|
|
||||||
val explicit = DerReader.children(parts[1].content).first()
|
|
||||||
val certDer = explicit.content
|
|
||||||
val factory = CertificateFactory.getInstance("X.509")
|
|
||||||
certs.add(factory.generateCertificate(ByteArrayInputStream(certDer)) as X509Certificate)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun readInt(bytes: ByteArray): Int {
|
|
||||||
var value = 0
|
|
||||||
var start = 0
|
|
||||||
if (bytes.size > 1 && bytes[0].toInt() == 0x00) start = 1 // strip leading zero for positive
|
|
||||||
for (i in start until bytes.size) value = (value shl 8) or (bytes[i].toInt() and 0xff)
|
|
||||||
return value
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Minimal DER element parser. */
|
|
||||||
private data class Der(val tag: Int, val content: ByteArray)
|
|
||||||
|
|
||||||
private object DerReader {
|
|
||||||
fun read(der: ByteArray, offset: Int = 0): Der {
|
|
||||||
require(offset < der.size) { "DER truncated" }
|
|
||||||
val tag = der[offset].toInt() and 0xff
|
|
||||||
var i = offset + 1
|
|
||||||
var len = der[i].toInt() and 0xff
|
|
||||||
i++
|
|
||||||
if (len and 0x80 != 0) {
|
|
||||||
val numBytes = len and 0x7f
|
|
||||||
len = 0
|
|
||||||
repeat(numBytes) {
|
|
||||||
len = (len shl 8) or (der[i].toInt() and 0xff)
|
|
||||||
i++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
require(i + len <= der.size) { "DER length overflow" }
|
|
||||||
return Der(tag, der.copyOfRange(i, i + len))
|
|
||||||
}
|
|
||||||
|
|
||||||
fun children(content: ByteArray): List<Der> {
|
|
||||||
val out = mutableListOf<Der>()
|
|
||||||
var off = 0
|
|
||||||
while (off < content.size) {
|
|
||||||
val d = read(content, off)
|
|
||||||
out.add(d)
|
|
||||||
val step = headerSize(content, off) + d.content.size
|
|
||||||
if (step <= 0) break // defensive: never spin on malformed input
|
|
||||||
off += step
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun headerSize(der: ByteArray, offset: Int): Int {
|
|
||||||
var i = offset + 1
|
|
||||||
var len = der[i].toInt() and 0xff
|
|
||||||
i++
|
|
||||||
if (len and 0x80 != 0) i += len and 0x7f
|
|
||||||
return i - offset
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+18
-138
@@ -1,21 +1,3 @@
|
|||||||
/*
|
|
||||||
* 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
|
package com.rtbishop.look4sat.core.data.repository
|
||||||
|
|
||||||
import com.rtbishop.look4sat.core.domain.model.AmSatReportSubmission
|
import com.rtbishop.look4sat.core.domain.model.AmSatReportSubmission
|
||||||
@@ -27,13 +9,7 @@ import com.rtbishop.look4sat.core.domain.model.SatStatus
|
|||||||
import com.rtbishop.look4sat.core.domain.model.SatStatusPage
|
import com.rtbishop.look4sat.core.domain.model.SatStatusPage
|
||||||
import com.rtbishop.look4sat.core.domain.repository.IAmSatRepository
|
import com.rtbishop.look4sat.core.domain.repository.IAmSatRepository
|
||||||
import com.rtbishop.look4sat.core.domain.source.IRemoteSource
|
import com.rtbishop.look4sat.core.domain.source.IRemoteSource
|
||||||
import kotlinx.coroutines.CoroutineScope
|
|
||||||
import kotlinx.coroutines.Deferred
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.SupervisorJob
|
|
||||||
import kotlinx.coroutines.async
|
|
||||||
import kotlinx.coroutines.sync.Mutex
|
|
||||||
import kotlinx.coroutines.sync.withLock
|
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import org.json.JSONObject
|
import org.json.JSONObject
|
||||||
import java.text.SimpleDateFormat
|
import java.text.SimpleDateFormat
|
||||||
@@ -52,90 +28,28 @@ private data class ApiReport(
|
|||||||
val reportedTimeUtcSec: Long
|
val reportedTimeUtcSec: Long
|
||||||
)
|
)
|
||||||
|
|
||||||
/** One slot's verdict: ARGB colour, the count the page prints, and whether the slot
|
|
||||||
* has no strict majority (= the page's "Conflicting reports" state). */
|
|
||||||
private data class SlotStatus(val color: Long, val count: Int, val conflicted: Boolean)
|
|
||||||
|
|
||||||
/** AMSAT status repository using RemoteSource (Clean Architecture: data layer handles HTTP). */
|
/** AMSAT status repository using RemoteSource (Clean Architecture: data layer handles HTTP). */
|
||||||
class AmSatRepository(
|
class AmSatRepository(private val remoteSource: IRemoteSource) : IAmSatRepository {
|
||||||
private val remoteSource: IRemoteSource,
|
|
||||||
private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
|
||||||
) : IAmSatRepository {
|
|
||||||
|
|
||||||
private val statusCacheMutex = Mutex()
|
|
||||||
|
|
||||||
@Volatile
|
|
||||||
private var statusCache: SatStatusPage? = null
|
|
||||||
|
|
||||||
@Volatile
|
|
||||||
private var statusFetchInFlight: Deferred<SatStatusPage?>? = null
|
|
||||||
|
|
||||||
@Volatile
|
|
||||||
private var cacheGeneration = 0
|
|
||||||
|
|
||||||
private val isoUtcFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.US).apply {
|
private val isoUtcFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.US).apply {
|
||||||
timeZone = TimeZone.getTimeZone("UTC")
|
timeZone = TimeZone.getTimeZone("UTC")
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun getCachedStatus(): SatStatusPage? = statusCache
|
override suspend fun fetchStatus(): SatStatusPage? = withContext(Dispatchers.IO) {
|
||||||
|
|
||||||
override suspend fun fetchStatus(forceRefresh: Boolean): SatStatusPage? {
|
|
||||||
if (!forceRefresh) statusCache?.let { return it }
|
|
||||||
|
|
||||||
var cachedPage: SatStatusPage? = null
|
|
||||||
val inFlightFetch = statusCacheMutex.withLock {
|
|
||||||
if (!forceRefresh) {
|
|
||||||
val cached = statusCache
|
|
||||||
if (cached != null) {
|
|
||||||
cachedPage = cached
|
|
||||||
return@withLock null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
statusFetchInFlight ?: scope.async(Dispatchers.IO) {
|
|
||||||
fetchStatusFromRemote(cacheGeneration)
|
|
||||||
}.also { statusFetchInFlight = it }
|
|
||||||
}
|
|
||||||
|
|
||||||
cachedPage?.let { return it }
|
|
||||||
val fetch = inFlightFetch ?: return null
|
|
||||||
return try {
|
|
||||||
fetch.await()
|
|
||||||
} finally {
|
|
||||||
statusCacheMutex.withLock {
|
|
||||||
if (statusFetchInFlight === fetch && fetch.isCompleted) statusFetchInFlight = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun fetchStatusFromRemote(generation: Int): SatStatusPage? {
|
|
||||||
val nowSec = System.currentTimeMillis() / 1000
|
val nowSec = System.currentTimeMillis() / 1000
|
||||||
val catalogJson = remoteSource.getAmSatCatalog() ?: return null
|
val catalogJson = remoteSource.getAmSatCatalog() ?: return@withContext null
|
||||||
// 72h = 3 days; API hard cap is limit=500 regardless of what we send.
|
// 72h = 3 days; API hard cap is limit=500 regardless of what we send.
|
||||||
// 500 records across ~100 catalog satellites ≈ ~1-5 reports/satellite/day — enough for 3 days.
|
// 500 records across ~100 catalog satellites ≈ ~1-5 reports/satellite/day — enough for 3 days.
|
||||||
// Upgrade path: paginate or request AMSAT to raise the cap if catalog grows beyond ~200 sats.
|
// Upgrade path: paginate or request AMSAT to raise the cap if catalog grows beyond ~200 sats.
|
||||||
val reportsJson = remoteSource.getAmSatReports(hours = 72, limit = 500) ?: return null
|
val reportsJson = remoteSource.getAmSatReports(hours = 72, limit = 500) ?: return@withContext null
|
||||||
|
|
||||||
val names = parseCatalog(catalogJson)
|
val names = parseCatalog(catalogJson)
|
||||||
val reports = parseReports(reportsJson)
|
val reports = parseReports(reportsJson)
|
||||||
|
|
||||||
if (names.isEmpty() && reports.isEmpty()) return null
|
if (names.isEmpty() && reports.isEmpty()) return@withContext null
|
||||||
|
|
||||||
val statuses = buildStatuses(names, reports, nowSec)
|
val statuses = buildStatuses(names, reports, nowSec)
|
||||||
val reportMap = reports.associate { it.id to toSatReport(it) }
|
val reportMap = reports.associate { it.id to toSatReport(it) }
|
||||||
return SatStatusPage(System.currentTimeMillis(), statuses, reportMap).also { page ->
|
SatStatusPage(System.currentTimeMillis(), statuses, reportMap)
|
||||||
if (generation == cacheGeneration) statusCache = page
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun prefetchStatus() {
|
|
||||||
fetchStatus(forceRefresh = false)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun clearStatusCache() {
|
|
||||||
cacheGeneration += 1
|
|
||||||
statusCache = null
|
|
||||||
statusFetchInFlight = null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun submitReport(submission: AmSatReportSubmission): AmSatReportSubmitResult = withContext(Dispatchers.IO) {
|
override suspend fun submitReport(submission: AmSatReportSubmission): AmSatReportSubmitResult = withContext(Dispatchers.IO) {
|
||||||
@@ -218,8 +132,7 @@ class AmSatRepository(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Build one SatStatus (3 days x 12 slots) per catalog satellite, slotting reports by age.
|
/** Build one SatStatus (5 days x 12 slots) per catalog satellite, slotting reports by age. */
|
||||||
* Each slot's colour/count follows the AMSAT status page rule — see [slotStatusOf]. */
|
|
||||||
private fun buildStatuses(names: List<String>, reports: List<ApiReport>, nowSec: Long): List<SatStatus> {
|
private fun buildStatuses(names: List<String>, reports: List<ApiReport>, nowSec: Long): List<SatStatus> {
|
||||||
val byName = reports.groupBy { it.name }
|
val byName = reports.groupBy { it.name }
|
||||||
val utc = Calendar.getInstance(TimeZone.getTimeZone("UTC"))
|
val utc = Calendar.getInstance(TimeZone.getTimeZone("UTC"))
|
||||||
@@ -235,32 +148,16 @@ class AmSatRepository(
|
|||||||
if (inSlot.isEmpty()) {
|
if (inSlot.isEmpty()) {
|
||||||
SatSlot(statusColor = NO_REPORT_GRAY, count = 0)
|
SatSlot(statusColor = NO_REPORT_GRAY, count = 0)
|
||||||
} else {
|
} else {
|
||||||
val status = slotStatusOf(inSlot)
|
val newest = inSlot.maxByOrNull { it.reportedTimeUtcSec }!!
|
||||||
SatSlot(
|
SatSlot(
|
||||||
statusColor = status.color,
|
statusColor = statusColorOf(newest.report),
|
||||||
count = status.count,
|
count = inSlot.size,
|
||||||
reportIds = inSlot.map { it.id },
|
reportIds = inSlot.map { it.id }
|
||||||
isConflicted = status.conflicted
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val days = (0 until 3).map { d ->
|
val days = (0 until 3).map { d ->
|
||||||
// 当天最近连续相同状态报告数:从最新一条往回数,遇状态不同即停。
|
SatDay(dateLabel = labels[d], slots = slots.subList(d * 12, (d + 1) * 12))
|
||||||
// 空时段(无报告)不打断;按状态文本严格比较,不跨天。
|
|
||||||
val dayStart = nowSec - (d + 1) * 86400L
|
|
||||||
val dayEnd = nowSec - d * 86400L
|
|
||||||
val dayReports = byName[name].orEmpty()
|
|
||||||
.filter { it.reportedTimeUtcSec in dayStart until dayEnd }
|
|
||||||
.sortedByDescending { it.reportedTimeUtcSec }
|
|
||||||
val streakCount = if (dayReports.isEmpty()) 0 else {
|
|
||||||
val newestStatus = dayReports.first().report
|
|
||||||
dayReports.takeWhile { it.report == newestStatus }.size
|
|
||||||
}
|
|
||||||
SatDay(
|
|
||||||
dateLabel = labels[d],
|
|
||||||
slots = slots.subList(d * 12, (d + 1) * 12),
|
|
||||||
streakCount = streakCount
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
SatStatus(name = name, days = days)
|
SatStatus(name = name, days = days)
|
||||||
}
|
}
|
||||||
@@ -293,28 +190,12 @@ class AmSatRepository(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Map status text to color value (for UI rendering). */
|
||||||
* The AMSAT status page's per-block rule, mirrored from the official page source
|
private fun statusColorOf(report: String): Long = when (report.lowercase()) {
|
||||||
* (gitlab.amsat.org/open-source/satellite-status, index.php):
|
"heard", "crew active" -> ACTIVE_BLUE
|
||||||
* - any Crew Active report wins: purple, count = crew + heard;
|
"telemetry only" -> TLM_ORANGE
|
||||||
* - otherwise one status must be a STRICT majority to take its colour, and the
|
"not heard" -> NOT_HEARD_PINK
|
||||||
* count shown is the majority group's count (not the block total);
|
else -> CONFLICT_DEEP_ORANGE
|
||||||
* - any tie of the remaining statuses is the "Conflicting reports" state;
|
|
||||||
* - a block whose reports all carry an unknown status stays blank.
|
|
||||||
*/
|
|
||||||
private fun slotStatusOf(inSlot: List<ApiReport>): SlotStatus {
|
|
||||||
val heard = inSlot.count { it.report.equals("Heard", ignoreCase = true) }
|
|
||||||
val notHeard = inSlot.count { it.report.equals("Not Heard", ignoreCase = true) }
|
|
||||||
val telemetry = inSlot.count { it.report.equals("Telemetry Only", ignoreCase = true) }
|
|
||||||
val crew = inSlot.count { it.report.equals("Crew Active", ignoreCase = true) }
|
|
||||||
return when {
|
|
||||||
crew > 0 -> SlotStatus(CREW_PURPLE, crew + heard, conflicted = false)
|
|
||||||
heard > notHeard && heard > telemetry -> SlotStatus(ACTIVE_BLUE, heard, conflicted = false)
|
|
||||||
notHeard > heard && notHeard > telemetry -> SlotStatus(NOT_HEARD_PINK, notHeard, conflicted = false)
|
|
||||||
telemetry > notHeard && telemetry > heard -> SlotStatus(TLM_ORANGE, telemetry, conflicted = false)
|
|
||||||
telemetry + notHeard + heard >= 1 -> SlotStatus(CONFLICT_DEEP_ORANGE, inSlot.size, conflicted = true)
|
|
||||||
else -> SlotStatus(NO_REPORT_GRAY, inSlot.size, conflicted = false)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
@@ -323,7 +204,6 @@ class AmSatRepository(
|
|||||||
private const val TLM_ORANGE = 0xFFFFB000
|
private const val TLM_ORANGE = 0xFFFFB000
|
||||||
private const val NOT_HEARD_PINK = 0xFFDC267F
|
private const val NOT_HEARD_PINK = 0xFFDC267F
|
||||||
private const val CONFLICT_DEEP_ORANGE = 0xFFFE6100
|
private const val CONFLICT_DEEP_ORANGE = 0xFFFE6100
|
||||||
private const val CREW_PURPLE = 0xFF785EF0
|
|
||||||
private const val NO_REPORT_GRAY = 0xFFC0C0C0
|
private const val NO_REPORT_GRAY = 0xFFC0C0C0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+16
-45
@@ -23,8 +23,6 @@ import com.rtbishop.look4sat.core.domain.repository.IDatabaseRepo
|
|||||||
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
|
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
|
||||||
import com.rtbishop.look4sat.core.domain.source.ILocalSource
|
import com.rtbishop.look4sat.core.domain.source.ILocalSource
|
||||||
import com.rtbishop.look4sat.core.domain.source.IRemoteSource
|
import com.rtbishop.look4sat.core.domain.source.IRemoteSource
|
||||||
import com.rtbishop.look4sat.core.domain.source.NetworkResult
|
|
||||||
import com.rtbishop.look4sat.core.domain.source.Sources
|
|
||||||
import com.rtbishop.look4sat.core.domain.utility.DataParser
|
import com.rtbishop.look4sat.core.domain.utility.DataParser
|
||||||
import kotlinx.coroutines.CoroutineDispatcher
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
import kotlinx.coroutines.async
|
import kotlinx.coroutines.async
|
||||||
@@ -41,14 +39,13 @@ class DatabaseRepo(
|
|||||||
private val settingsRepo: ISettingsRepo
|
private val settingsRepo: ISettingsRepo
|
||||||
) : IDatabaseRepo {
|
) : IDatabaseRepo {
|
||||||
|
|
||||||
private val customSourceType = "Other"
|
|
||||||
|
|
||||||
override suspend fun updateTLEFromFile(uri: String): Int = withContext(dispatcher) {
|
override suspend fun updateTLEFromFile(uri: String): Int = withContext(dispatcher) {
|
||||||
var importedCount = 0
|
var importedCount = 0
|
||||||
remoteSource.getFileStream(uri)?.let { stream ->
|
remoteSource.getFileStream(uri)?.let { stream ->
|
||||||
val entries = mergeEntries(listOf(parseSatelliteStream(uri, unwrapIfZipped(uri, stream))))
|
val entries = mergeEntries(listOf(parseSatelliteStream(uri, unwrapIfZipped(uri, stream))))
|
||||||
insertFresherEntries(entries)
|
insertFresherEntries(entries)
|
||||||
settingsRepo.setSatelliteTypeIds(customSourceType, entries.map { it.catnum })
|
// report what the file contained: a valid file holding only stale data is not an error
|
||||||
importedCount = entries.size
|
importedCount = entries.size
|
||||||
}
|
}
|
||||||
setUpdateSuccessful(System.currentTimeMillis())
|
setUpdateSuccessful(System.currentTimeMillis())
|
||||||
@@ -68,57 +65,34 @@ class DatabaseRepo(
|
|||||||
|
|
||||||
override suspend fun updateFromRemote() = withContext(dispatcher) {
|
override suspend fun updateFromRemote() = withContext(dispatcher) {
|
||||||
val settings = settingsRepo.dataSourcesSettings.value
|
val settings = settingsRepo.dataSourcesSettings.value
|
||||||
// Keep the raw URL as the status key (matches what the settings UI
|
fun normalizeUrl(url: String) = if (url.startsWith("http")) url else "https://$url"
|
||||||
// displays) while requesting with the normalized URL.
|
val tleUrls = settings.satelliteUrls.filterIndexed { i, url -> url.isNotBlank() && settings.isSatelliteEnabled(i) }
|
||||||
val tleUrls = settings.satelliteUrls
|
val radioUrls = settings.transceiversUrls.filterIndexed { i, url -> url.isNotBlank() && settings.isTransceiverEnabled(i) }
|
||||||
.filterIndexed { i, url -> url.isNotBlank() && settings.isSatelliteEnabled(i) }
|
|
||||||
.map { it to normalizeUrl(it) }
|
|
||||||
.distinctBy { it.second }
|
|
||||||
val radioUrls = settings.transceiversUrls
|
|
||||||
.filterIndexed { i, url -> url.isNotBlank() && settings.isTransceiverEnabled(i) }
|
|
||||||
.map { it to normalizeUrl(it) }
|
|
||||||
.distinctBy { it.second }
|
|
||||||
val builtinTypesByUrl = Sources.satelliteDataUrls
|
|
||||||
.filterValues { it.isNotBlank() }
|
|
||||||
.mapValues { normalizeUrl(it.value) }
|
|
||||||
.entries
|
|
||||||
.associate { (type, url) -> url to type }
|
|
||||||
val importedTypeIds = mutableMapOf<String, MutableList<Int>>()
|
|
||||||
// launch all network requests concurrently, keeping the raw url as key for status reporting
|
// launch all network requests concurrently, keeping the raw url as key for status reporting
|
||||||
val tleJobs = tleUrls.map { (raw, norm) -> async { raw to remoteSource.getNetworkStream(norm) } }
|
val tleJobs = tleUrls.map { url -> async { url to remoteSource.getNetworkStream(normalizeUrl(url)) } }
|
||||||
val radioJobs = radioUrls.map { (raw, norm) -> async { raw to remoteSource.getNetworkStream(norm) } }
|
val radioJobs = radioUrls.map { url -> async { url to remoteSource.getNetworkStream(normalizeUrl(url)) } }
|
||||||
val tleResults = tleJobs.awaitAll()
|
val tleResults = tleJobs.awaitAll()
|
||||||
val radioResults = radioJobs.awaitAll()
|
val radioResults = radioJobs.awaitAll()
|
||||||
// report the HTTP status code of every source (200, 404, ...)
|
// report the HTTP status code of every source (200, 404, ...)
|
||||||
settingsRepo.updateDataSourcesStatus(
|
settingsRepo.updateDataSourcesStatus(
|
||||||
(tleResults + radioResults).associate { (url, result) -> url to result.code }
|
(tleResults + radioResults).associate { (url, result) -> url to result.code }
|
||||||
)
|
)
|
||||||
// parse each source on its own: the type filters need every source's catnums, while the
|
// parse fetched data concurrently, merging satellites by freshness instead of source order
|
||||||
// stored elements are merged across sources below by freshness.
|
val parsedPerSource = tleResults.map { (url, result) ->
|
||||||
val parsedPerSource = tleResults.map { (rawUrl, result) ->
|
result.stream?.let { val nUrl = normalizeUrl(url); parseSatelliteStream(nUrl, unwrapIfZipped(nUrl, it)) }.orEmpty()
|
||||||
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
|
|
||||||
}
|
}
|
||||||
importedTypeIds.forEach { (type, ids) -> settingsRepo.setSatelliteTypeIds(type, ids.distinct()) }
|
val importedRadios = radioResults.flatMap { (url, result) ->
|
||||||
val importedRadios = radioResults.flatMap { (rawUrl, result) ->
|
result.stream?.let { val nUrl = normalizeUrl(url); dataParser.parseJSONStream(unwrapIfZipped(nUrl, it)) }.orEmpty()
|
||||||
val normUrl = normalizeUrl(rawUrl)
|
|
||||||
result.stream?.let { dataParser.parseJSONStream(unwrapIfZipped(normUrl, it)) }.orEmpty()
|
|
||||||
}.filter { it.uuid.isNotBlank() }.distinctBy { it.uuid }
|
}.filter { it.uuid.isNotBlank() }.distinctBy { it.uuid }
|
||||||
// insert parsed data into the database.
|
// insert parsed data into the database
|
||||||
// Transceivers are a full snapshot: sources publish active entries only, so a retired
|
insertFresherEntries(mergeEntries(parsedPerSource))
|
||||||
|
// transceivers are a full snapshot: sources publish active entries only, so a retired
|
||||||
// transceiver simply disappears from the feed and has to be dropped locally as well.
|
// transceiver simply disappears from the feed and has to be dropped locally as well.
|
||||||
// Manually imported ones (isCustom) are kept: no source can refresh them, so nothing
|
// Imported ones are kept: no source can refresh them, so nothing would bring them back
|
||||||
// would bring them back if they were replaced here.
|
|
||||||
if (importedRadios.isNotEmpty()) {
|
if (importedRadios.isNotEmpty()) {
|
||||||
localSource.deleteManagedRadios()
|
localSource.deleteManagedRadios()
|
||||||
localSource.insertRadios(importedRadios)
|
localSource.insertRadios(importedRadios, isCustom = false)
|
||||||
}
|
}
|
||||||
// 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
|
// keep the previous timestamp when every source failed, so the next launch retries
|
||||||
val hasFetchedData = parsedPerSource.any { entries -> entries.isNotEmpty() } || importedRadios.isNotEmpty()
|
val hasFetchedData = parsedPerSource.any { entries -> entries.isNotEmpty() } || importedRadios.isNotEmpty()
|
||||||
val previousTimestamp = settingsRepo.databaseState.value.updateTimestamp
|
val previousTimestamp = settingsRepo.databaseState.value.updateTimestamp
|
||||||
@@ -132,9 +106,6 @@ class DatabaseRepo(
|
|||||||
setUpdateSuccessful(0L)
|
setUpdateSuccessful(0L)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun normalizeUrl(url: String): String =
|
|
||||||
if (url.startsWith("http", ignoreCase = true)) url else "https://$url"
|
|
||||||
|
|
||||||
private suspend fun parseSatelliteStream(url: String, stream: InputStream): List<OrbitalData> {
|
private suspend fun parseSatelliteStream(url: String, stream: InputStream): List<OrbitalData> {
|
||||||
val bufferedStream = stream.buffered()
|
val bufferedStream = stream.buffered()
|
||||||
return when {
|
return when {
|
||||||
|
|||||||
@@ -1,524 +0,0 @@
|
|||||||
/*
|
|
||||||
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
|
||||||
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
|
||||||
*
|
|
||||||
* This program is free software: you can redistribute it and/or modify
|
|
||||||
* it under the terms of the GNU General Public License as published by
|
|
||||||
* the Free Software Foundation, either version 3 of the License, or
|
|
||||||
* (at your option) any later version.
|
|
||||||
*
|
|
||||||
* This program is distributed in the hope that it will be useful,
|
|
||||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
* GNU General Public License for more details.
|
|
||||||
*
|
|
||||||
* You should have received a copy of the GNU General Public License
|
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
*/
|
|
||||||
package com.rtbishop.look4sat.core.data.repository
|
|
||||||
|
|
||||||
import com.rtbishop.look4sat.core.domain.repository.ILoTWRepository
|
|
||||||
import com.rtbishop.look4sat.core.domain.repository.LoTWPhase
|
|
||||||
import com.rtbishop.look4sat.core.domain.repository.LoTWProgress
|
|
||||||
import com.rtbishop.look4sat.core.domain.repository.LoTWResult
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.ensureActive
|
|
||||||
import kotlinx.coroutines.withContext
|
|
||||||
import kotlin.coroutines.coroutineContext
|
|
||||||
import java.net.HttpURLConnection
|
|
||||||
import java.net.SocketTimeoutException
|
|
||||||
import java.net.URL
|
|
||||||
import java.net.URLEncoder
|
|
||||||
import java.io.IOException
|
|
||||||
import javax.net.ssl.SSLException
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetches confirmed gridsquares directly from ARRL LoTW via the official report endpoint:
|
|
||||||
* GET https://lotw.arrl.org/lotwuser/lotwreport.adi?login=<call>&password=<pwd>
|
|
||||||
* &qso_query=1&qso_qsl=yes&qso_qsldetail=yes&qso_mydetail=yes&qso_qslsince=<date>
|
|
||||||
*
|
|
||||||
* Only QSL_RCVD=Y records are returned by LoTW for qso_qsl=yes, so every grid in the
|
|
||||||
* report is a *confirmed* grid (green on the map). GRIDSQUARE may be a 4- or 6-char
|
|
||||||
* value; VUCC_GRIDS ("EN52en,EN53fa") also yields 4-char fields. All values are
|
|
||||||
* truncated/expanded to the 4-char form used by the map overlay.
|
|
||||||
*
|
|
||||||
* ARRL rate-limits the report endpoint: one download in progress per user id, and
|
|
||||||
* frequent full-report pulls get refused. Failures are reported with an explicit
|
|
||||||
* cause (see LoTWResult) so the UI can tell the user what to do next.
|
|
||||||
*/
|
|
||||||
class LoTWRepository : ILoTWRepository {
|
|
||||||
|
|
||||||
override suspend fun fetchConfirmedGrids(callsign: String, password: String): LoTWResult =
|
|
||||||
withContext(Dispatchers.IO) {
|
|
||||||
fetchReportBody(callsign, password, since = "", onProgress = {}).fold(
|
|
||||||
onSuccess = { body ->
|
|
||||||
parseBoth(body)?.let { (grids, _, roamed) ->
|
|
||||||
LoTWResult.Success(grids, emptyMap(), roamed)
|
|
||||||
} ?: LoTWResult.RateLimited
|
|
||||||
},
|
|
||||||
onFailure = { toResult(it) }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun fetchConfirmedGridQsos(
|
|
||||||
callsign: String,
|
|
||||||
password: String,
|
|
||||||
since: String,
|
|
||||||
onProgress: (LoTWProgress) -> Unit
|
|
||||||
): LoTWResult = withContext(Dispatchers.IO) {
|
|
||||||
fetchReportBody(callsign, password, since, onProgress).fold(
|
|
||||||
onSuccess = { body ->
|
|
||||||
parseBoth(body)?.let { (grids, qsos, roamed) ->
|
|
||||||
LoTWResult.Success(grids, qsos, roamed)
|
|
||||||
} ?: LoTWResult.RateLimited
|
|
||||||
},
|
|
||||||
onFailure = { toResult(it) }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
internal fun toResult(e: Throwable): LoTWResult = when (e) {
|
|
||||||
is CredentialsException -> LoTWResult.BadCredentials
|
|
||||||
is RateLimitException -> LoTWResult.RateLimited
|
|
||||||
is TimeoutException -> LoTWResult.Timeout
|
|
||||||
else -> LoTWResult.NetworkError(e.message ?: e.javaClass.simpleName)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Single report fetch feeding the grid set, the per-QSO detail and the roamed set. */
|
|
||||||
private fun parseBoth(
|
|
||||||
body: String
|
|
||||||
): Triple<Set<String>, Map<String, List<com.rtbishop.look4sat.core.domain.model.GridQso>>, Set<String>>? {
|
|
||||||
val grids = parseConfirmedGrids(body) ?: return null
|
|
||||||
val qsos = parseConfirmedGridQsos(body) ?: return null
|
|
||||||
val roamed = parseRoamedGrids(body) ?: return null
|
|
||||||
return Triple(grids, qsos, roamed)
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun fetchReportBody(
|
|
||||||
callsign: String,
|
|
||||||
password: String,
|
|
||||||
since: String,
|
|
||||||
onProgress: (LoTWProgress) -> Unit
|
|
||||||
): Result<String> {
|
|
||||||
val call = callsign.trim().uppercase()
|
|
||||||
val pwd = password.trim()
|
|
||||||
if (call.isBlank() || pwd.isBlank()) return Result.failure(IOException("empty credentials"))
|
|
||||||
// Empty since -> full report. qso_qslsince with an early date forces a
|
|
||||||
// FULL confirmed-QSL report; without it LoTW applies a "system supplied
|
|
||||||
// default" since-date and only returns confirmations newer than the
|
|
||||||
// account's last query — subsequent syncs would be incremental/empty.
|
|
||||||
val effectiveSince = since.ifBlank { "2000-01-01" }
|
|
||||||
val query = buildString {
|
|
||||||
append("login=").append(URLEncoder.encode(call, "UTF-8"))
|
|
||||||
append("&password=").append(URLEncoder.encode(pwd, "UTF-8"))
|
|
||||||
append("&qso_query=1&qso_qsl=yes&qso_qsldetail=yes&qso_mydetail=yes")
|
|
||||||
append("&qso_qslsince=").append(URLEncoder.encode(effectiveSince, "UTF-8"))
|
|
||||||
}
|
|
||||||
return try {
|
|
||||||
val connection = URL("$BASE_URL?$query").openConnection() as HttpURLConnection
|
|
||||||
// ARRL can be slow to accept connections from mobile networks
|
|
||||||
// (long TLS handshakes across the Pacific, occasional server-side
|
|
||||||
// queueing — a busy server took 36.5 s to accept a connection in
|
|
||||||
// testing, so 30 s connect would have killed it).
|
|
||||||
//
|
|
||||||
// NOTE: readTimeout is NOT a total-download cap. It is the longest
|
|
||||||
// wait for the NEXT chunk of data (a stall timeout). A full report
|
|
||||||
// for a huge account (tens of thousands of QSLs ≈ tens of MB at
|
|
||||||
// ARRL's ~10 KB/s stream rate) legitimately takes 30+ minutes;
|
|
||||||
// as long as chunks keep arriving it must never be cut off.
|
|
||||||
// 600 s tolerates long server-side pauses while it generates the
|
|
||||||
// report; a truly dead link (no data at all) still fails within it.
|
|
||||||
connection.connectTimeout = 60_000
|
|
||||||
connection.readTimeout = 600_000
|
|
||||||
connection.requestMethod = "GET"
|
|
||||||
connection.setRequestProperty("Accept-Encoding", "gzip")
|
|
||||||
connection.instanceFollowRedirects = true
|
|
||||||
onProgress(LoTWProgress(LoTWPhase.Connecting))
|
|
||||||
val code = connection.responseCode
|
|
||||||
if (code !in 200..299) {
|
|
||||||
connection.disconnect()
|
|
||||||
// Observed in the wild (CQRLOG #2422): LoTW answers a throttled
|
|
||||||
// report pull with HTTP 503 "Page request limit".
|
|
||||||
return if (code == 503 || code == 429) Result.failure(RateLimitException())
|
|
||||||
else Result.failure(IOException("HTTP $code"))
|
|
||||||
}
|
|
||||||
val stream = connection.inputStream
|
|
||||||
val isGzip = "gzip".equals(connection.contentEncoding, ignoreCase = true)
|
|
||||||
val body = readBodyWithProgress(stream, isGzip, onProgress)
|
|
||||||
connection.disconnect()
|
|
||||||
when {
|
|
||||||
// Login failure: HTTP 200 + HTML login page with the error text.
|
|
||||||
body.contains("incorrect", ignoreCase = true) &&
|
|
||||||
body.contains("assword", ignoreCase = true) -> Result.failure(CredentialsException())
|
|
||||||
// Any other non-ADIF body: rate-limit refusal / server error page.
|
|
||||||
!body.contains("<eoh>", ignoreCase = true) -> Result.failure(RateLimitException())
|
|
||||||
else -> Result.success(body)
|
|
||||||
}
|
|
||||||
} catch (e: java.util.concurrent.CancellationException) {
|
|
||||||
// Cancellation must propagate — it is not a network failure.
|
|
||||||
throw e
|
|
||||||
} catch (e: SocketTimeoutException) {
|
|
||||||
Result.failure(TimeoutException(e.message ?: "timed out"))
|
|
||||||
} catch (e: SSLException) {
|
|
||||||
Result.failure(IOException("TLS: ${e.message ?: "handshake failed"}"))
|
|
||||||
} catch (e: Exception) {
|
|
||||||
println("LoTWRepository fetch failure: $e")
|
|
||||||
Result.failure(IOException(e.message ?: e.javaClass.simpleName))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Streams the report body into a string while reporting download progress.
|
|
||||||
* Once the header's record count (<APP_LoTW_NUMREC>) is seen, the total
|
|
||||||
* body size is estimated as NUMREC * AVG_RECORD_BYTES so the UI can show a
|
|
||||||
* meaningful progress bar and remaining-time estimate.
|
|
||||||
*/
|
|
||||||
private suspend fun readBodyWithProgress(
|
|
||||||
stream: java.io.InputStream,
|
|
||||||
isGzip: Boolean,
|
|
||||||
onProgress: (LoTWProgress) -> Unit
|
|
||||||
): String {
|
|
||||||
val input = if (isGzip) java.util.zip.GZIPInputStream(stream) else stream
|
|
||||||
val reader = java.io.InputStreamReader(input, Charsets.UTF_8)
|
|
||||||
val sb = StringBuilder()
|
|
||||||
val buf = CharArray(8192)
|
|
||||||
var bytesRead = 0L
|
|
||||||
var expected = 0L
|
|
||||||
var qsoCount = 0L
|
|
||||||
var speed = 0L
|
|
||||||
var headerSeen = false
|
|
||||||
var lastSampleMs = System.currentTimeMillis()
|
|
||||||
var lastSampleBytes = 0L
|
|
||||||
var lastEmitMs = 0L
|
|
||||||
while (true) {
|
|
||||||
// Cooperative cancellation: lets the user abort a full sync that
|
|
||||||
// was started by mistake. Throws CancellationException once the
|
|
||||||
// job is cancelled; the blocking read below is fast while chunks
|
|
||||||
// keep arriving, so the abort lands on the next chunk boundary.
|
|
||||||
coroutineContext.ensureActive()
|
|
||||||
val n = reader.read(buf)
|
|
||||||
if (n <= 0) break
|
|
||||||
sb.append(buf, 0, n)
|
|
||||||
bytesRead += n
|
|
||||||
// The record count sits in the report header, long before the data.
|
|
||||||
if (!headerSeen) {
|
|
||||||
NUMREC_REGEX.find(sb)?.let { m ->
|
|
||||||
qsoCount = m.groupValues[1].toLongOrNull() ?: 0L
|
|
||||||
expected = qsoCount * AVG_RECORD_BYTES
|
|
||||||
headerSeen = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Sample speed every 500ms, EMA-smoothed.
|
|
||||||
val now = System.currentTimeMillis()
|
|
||||||
if (now - lastSampleMs >= 500) {
|
|
||||||
val elapsedS = (now - lastSampleMs) / 1000.0
|
|
||||||
if (elapsedS > 0) {
|
|
||||||
val inst = ((bytesRead - lastSampleBytes) / elapsedS).toLong()
|
|
||||||
speed = if (speed == 0L) inst else (speed * 7 + inst * 3) / 10
|
|
||||||
lastSampleMs = now
|
|
||||||
lastSampleBytes = bytesRead
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Throttle callbacks to ~4/s so StateFlow updates stay cheap.
|
|
||||||
if (now - lastEmitMs >= 250) {
|
|
||||||
onProgress(LoTWProgress(LoTWPhase.Downloading, bytesRead, expected, speed, qsoCount))
|
|
||||||
lastEmitMs = now
|
|
||||||
}
|
|
||||||
}
|
|
||||||
onProgress(LoTWProgress(LoTWPhase.Downloading, bytesRead, expected, speed, qsoCount))
|
|
||||||
return sb.toString()
|
|
||||||
}
|
|
||||||
|
|
||||||
internal class CredentialsException : Exception("bad callsign/password")
|
|
||||||
internal class RateLimitException : Exception("report refused (rate limit / server error)")
|
|
||||||
internal class TimeoutException(message: String) : Exception(message)
|
|
||||||
|
|
||||||
internal fun parseConfirmedGridQsos(
|
|
||||||
body: String
|
|
||||||
): Map<String, List<com.rtbishop.look4sat.core.domain.model.GridQso>>? {
|
|
||||||
if (!body.contains("<eoh>", ignoreCase = true)) return null
|
|
||||||
// One ADIF record = fields up to <EOR>. We buffer the fields we care
|
|
||||||
// about, then emit a GridQso on <EOR> when the record is a satellite
|
|
||||||
// QSO (PROP_MODE=SAT) carrying a grid.
|
|
||||||
val result = mutableMapOf<String, MutableList<com.rtbishop.look4sat.core.domain.model.GridQso>>()
|
|
||||||
var propMode: String? = null
|
|
||||||
var call = ""
|
|
||||||
var qsoDate = ""
|
|
||||||
var timeOn = ""
|
|
||||||
var satName = ""
|
|
||||||
var mode = ""
|
|
||||||
var bandUp = ""
|
|
||||||
var bandDown = ""
|
|
||||||
var dxcc: Int? = null
|
|
||||||
var country: String? = null
|
|
||||||
var cqz: Int? = null
|
|
||||||
var state: String? = null
|
|
||||||
// Own-station (台址) fields: MY_GRIDSQUARE + MY_VUCC_GRIDS give the
|
|
||||||
// full grid set of the station location; the remaining MY_* fields
|
|
||||||
// together identify the station location (one callsign can own
|
|
||||||
// several locations, and one location can span several grids).
|
|
||||||
var myGrids = mutableSetOf<String>()
|
|
||||||
var myCallsign: String? = null
|
|
||||||
var myDxcc: String? = null
|
|
||||||
var myState: String? = null
|
|
||||||
var myCq: String? = null
|
|
||||||
var myItu: String? = null
|
|
||||||
var myIota: String? = null
|
|
||||||
var myCountry: String? = null
|
|
||||||
val gridsInRecord = mutableListOf<String>()
|
|
||||||
|
|
||||||
fun emitRecord() {
|
|
||||||
if (propMode != "SAT" || gridsInRecord.isEmpty()) return
|
|
||||||
val epochMs = adifTimestampToEpoch(qsoDate, timeOn)
|
|
||||||
val stationKey = buildString {
|
|
||||||
append(myCallsign ?: "")
|
|
||||||
append('|').append(myDxcc ?: "")
|
|
||||||
append('|').append(myState ?: "")
|
|
||||||
append('|').append(myCq ?: "")
|
|
||||||
append('|').append(myItu ?: "")
|
|
||||||
append('|').append(myIota ?: "")
|
|
||||||
append('|').append(myCountry ?: "")
|
|
||||||
}.ifBlank { null }
|
|
||||||
val qso = com.rtbishop.look4sat.core.domain.model.GridQso(
|
|
||||||
call = call, epochMs = epochMs, satName = satName,
|
|
||||||
mode = mode, bandUp = bandUp, bandDown = bandDown,
|
|
||||||
dxcc = dxcc, country = country, cqz = cqz, state = state,
|
|
||||||
myGrid = myGrids.firstOrNull(),
|
|
||||||
myGrids = myGrids,
|
|
||||||
myCallsign = myCallsign,
|
|
||||||
stationKey = stationKey,
|
|
||||||
// Copy, never the live buffer — resetRecord() clears it.
|
|
||||||
theirGrids = gridsInRecord.distinct().toList()
|
|
||||||
)
|
|
||||||
for (grid in gridsInRecord) {
|
|
||||||
result.getOrPut(grid) { mutableListOf() }.add(qso)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun resetRecord() {
|
|
||||||
propMode = null; call = ""; qsoDate = ""; timeOn = ""
|
|
||||||
satName = ""; mode = ""; bandUp = ""; bandDown = ""
|
|
||||||
dxcc = null; country = null; cqz = null; state = null
|
|
||||||
myGrids = mutableSetOf()
|
|
||||||
myCallsign = null; myDxcc = null; myState = null
|
|
||||||
myCq = null; myItu = null; myIota = null; myCountry = null
|
|
||||||
gridsInRecord.clear()
|
|
||||||
}
|
|
||||||
|
|
||||||
for (raw in body.lineSequence()) {
|
|
||||||
val line = raw.trim()
|
|
||||||
when {
|
|
||||||
line.equals("<EOR>", ignoreCase = true) -> {
|
|
||||||
emitRecord()
|
|
||||||
resetRecord()
|
|
||||||
}
|
|
||||||
line.startsWith("<PROP_MODE:") ->
|
|
||||||
propMode = adifValue(line).uppercase()
|
|
||||||
line.startsWith("<CALL:") ->
|
|
||||||
call = adifValue(line).uppercase()
|
|
||||||
line.startsWith("<QSO_DATE:") ->
|
|
||||||
qsoDate = adifValue(line)
|
|
||||||
line.startsWith("<TIME_ON:") ->
|
|
||||||
timeOn = adifValue(line)
|
|
||||||
line.startsWith("<SAT_NAME:") ->
|
|
||||||
satName = adifValue(line)
|
|
||||||
line.startsWith("<MODE:") ->
|
|
||||||
mode = adifValue(line)
|
|
||||||
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:") ->
|
|
||||||
country = adifValue(line).ifBlank { null }
|
|
||||||
line.startsWith("<CQZ:") ->
|
|
||||||
cqz = adifValue(line).toIntOrNull()
|
|
||||||
line.startsWith("<STATE:") ->
|
|
||||||
state = adifValue(line).trim().ifBlank { null }?.let { normalizeState(it) }
|
|
||||||
line.startsWith("<MY_GRIDSQUARE:") -> {
|
|
||||||
// Own-station grid (must not be confused with GRIDSQUARE —
|
|
||||||
// the opposite station's grid). Recorded per QSO so awards
|
|
||||||
// can be counted per operated grid. A station location may
|
|
||||||
// also carry MY_VUCC_GRIDS (below) for multi-grid roaming.
|
|
||||||
val value = adifValue(line)
|
|
||||||
if (value.length >= 4) myGrids.add(value.take(4).uppercase())
|
|
||||||
}
|
|
||||||
line.startsWith("<MY_VUCC_GRIDS:") -> {
|
|
||||||
// Own-station VUCC grids: a comma-separated list LoTW emits
|
|
||||||
// when one station location spans several grid squares
|
|
||||||
// (e.g. "OM60,OM50" with MY_GRIDSQUARE absent). Without
|
|
||||||
// this field the extra grids of a multi-grid 台址 silently
|
|
||||||
// vanished from both the blue stripes and the per-台址 VUCC
|
|
||||||
// count. Same split/truncate as VUCC_GRIDS below.
|
|
||||||
adifValue(line).split(',').forEach { grid ->
|
|
||||||
val field = grid.trim().uppercase()
|
|
||||||
if (field.length >= 4) myGrids.add(field.take(4))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Remaining MY_* fields identify the station location (台址):
|
|
||||||
// grouped together they form the stationKey used by the map's
|
|
||||||
// operated-grid selector. One callsign can own several 台址.
|
|
||||||
line.startsWith("<STATION_CALLSIGN:") ->
|
|
||||||
myCallsign = adifValue(line).trim().uppercase().ifBlank { null }
|
|
||||||
line.startsWith("<MY_DXCC:") ->
|
|
||||||
myDxcc = adifValue(line).trim().ifBlank { null }
|
|
||||||
line.startsWith("<MY_STATE:") ->
|
|
||||||
myState = adifValue(line).trim().ifBlank { null }?.let { normalizeState(it) }
|
|
||||||
line.startsWith("<MY_CQ_ZONE:") ->
|
|
||||||
myCq = adifValue(line).trim().ifBlank { null }
|
|
||||||
line.startsWith("<MY_ITU_ZONE:") ->
|
|
||||||
myItu = adifValue(line).trim().ifBlank { null }
|
|
||||||
line.startsWith("<MY_IOTA:") ->
|
|
||||||
myIota = adifValue(line).trim().uppercase().ifBlank { null }
|
|
||||||
line.startsWith("<MY_COUNTRY:") ->
|
|
||||||
myCountry = adifValue(line).trim().ifBlank { null }
|
|
||||||
line.startsWith("<GRIDSQUARE:") || line.startsWith("<VUCC_GRIDS:") -> {
|
|
||||||
// VUCC_GRIDS holds a comma-separated list of grids
|
|
||||||
// ("EN52en,EN53fa"), up to four for contacts spanning
|
|
||||||
// several squares; split and keep every 4-char field.
|
|
||||||
adifValue(line).split(',').forEach { grid ->
|
|
||||||
val field = grid.trim().uppercase()
|
|
||||||
if (field.length >= 4) gridsInRecord.add(field.take(4))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
/** ADIF field value: "<GRIDSQUARE:4>OL62" -> "OL62". */
|
|
||||||
private fun adifValue(line: String): String =
|
|
||||||
line.substringAfter('>').substringBefore("E<").trim()
|
|
||||||
|
|
||||||
/**
|
|
||||||
* LoTW returns STATE as "CODE // NAME" (e.g. "HB // Hubei" for China,
|
|
||||||
* "34 // Tottori-ken" for Japan, "CA // California" for the US). The award
|
|
||||||
* statistics match on the short CODE only (China 2-letter province pinyin,
|
|
||||||
* Japan 2-digit prefecture, US 2-letter state), so strip the " // NAME"
|
|
||||||
* suffix here once, storing the clean code for every downstream consumer
|
|
||||||
* (persistence, AwardCalculator).
|
|
||||||
*/
|
|
||||||
private fun normalizeState(raw: String): String = raw.substringBefore(" // ").trim()
|
|
||||||
|
|
||||||
/** "20260820" + "1130" (or "113000") -> UTC epoch ms; 0 when unparseable. */
|
|
||||||
private fun adifTimestampToEpoch(date: String, time: String): Long = try {
|
|
||||||
val d = date.trim()
|
|
||||||
val t = time.trim().padEnd(6, '0').take(6)
|
|
||||||
val fmt = java.time.format.DateTimeFormatterBuilder()
|
|
||||||
.appendPattern("yyyyMMddHHmmss")
|
|
||||||
.toFormatter()
|
|
||||||
.withZone(java.time.ZoneOffset.UTC)
|
|
||||||
java.time.Instant.from(fmt.parse(d + t)).toEpochMilli()
|
|
||||||
} catch (_: Exception) {
|
|
||||||
0L
|
|
||||||
}
|
|
||||||
|
|
||||||
internal fun parseConfirmedGrids(body: String): Set<String>? {
|
|
||||||
// LoTW answers with ADIF text; on bad credentials it returns a short error
|
|
||||||
// page without an <eoh> header terminator. Real reports always carry <eoh>
|
|
||||||
// (LoTW writes it lowercase). Match case-insensitively to be safe.
|
|
||||||
if (!body.contains("<eoh>", ignoreCase = true)) return null
|
|
||||||
val grids = mutableSetOf<String>()
|
|
||||||
// ADIF fields of one QSO record span multiple lines and are terminated by
|
|
||||||
// <EOR>. Satellite QSOs carry <PROP_MODE:3>SAT (plus <SAT_NAME>); ground
|
|
||||||
// QSOs omit it. Grid fields (GRIDSQUARE / VUCC_GRIDS) must only be
|
|
||||||
// collected for records whose PROP_MODE is SAT, otherwise the map mixes
|
|
||||||
// in terrestrial contacts.
|
|
||||||
//
|
|
||||||
// Buffered per-record pattern (same as parseConfirmedGridQsos): field order
|
|
||||||
// is NOT reliable — ADIF producers (incl. LoTW) emit fields alphabetically,
|
|
||||||
// so <GRIDSQUARE> (G) arrives BEFORE <PROP_MODE> (P) within a record. A
|
|
||||||
// sequential gate ("only add while propMode == SAT") silently drops every
|
|
||||||
// grid on real reports. Buffer the record and decide at <EOR> instead.
|
|
||||||
var propMode: String? = null
|
|
||||||
var pendingGrids = mutableListOf<String>()
|
|
||||||
for (raw in body.lineSequence()) {
|
|
||||||
val line = raw.trim()
|
|
||||||
when {
|
|
||||||
line.equals("<EOR>", ignoreCase = true) -> {
|
|
||||||
if (propMode == "SAT") grids.addAll(pendingGrids)
|
|
||||||
propMode = null
|
|
||||||
pendingGrids = mutableListOf()
|
|
||||||
}
|
|
||||||
line.startsWith("<PROP_MODE:") -> {
|
|
||||||
propMode = adifValue(line).uppercase()
|
|
||||||
}
|
|
||||||
line.startsWith("<GRIDSQUARE:") || line.startsWith("<VUCC_GRIDS:") -> {
|
|
||||||
// VUCC_GRIDS holds a comma-separated PAIR of grids
|
|
||||||
// ("EN52en,EN53fa") for contacts spanning two squares —
|
|
||||||
// split on ',' and take the 4-char field of each, or the
|
|
||||||
// second grid is silently dropped.
|
|
||||||
val value = adifValue(line)
|
|
||||||
value.split(',').forEach { grid ->
|
|
||||||
val field = grid.trim().uppercase()
|
|
||||||
if (field.length >= 4) pendingGrids.add(field.take(4))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return grids
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Distinct 4-char gridsquares the account itself operated from, taken from
|
|
||||||
* ADIF <MY_GRIDSQUARE> of satellite QSO records only. This is the
|
|
||||||
* "roamed / activated" set shown as blue stripes on the map — the grids
|
|
||||||
* where the operator's own station was located during confirmed QSOs (a
|
|
||||||
* rover may log several distinct grids; the home grid is included too).
|
|
||||||
* Returns null when the body is not an ADIF report (mirrors the other parsers).
|
|
||||||
*/
|
|
||||||
internal fun parseRoamedGrids(body: String): Set<String>? {
|
|
||||||
if (!body.contains("<eoh>", ignoreCase = true)) return null
|
|
||||||
val grids = mutableSetOf<String>()
|
|
||||||
// Buffered per-record pattern (same as parseConfirmedGridQsos): field
|
|
||||||
// order is NOT reliable — ADIF producers (incl. LoTW) emit fields
|
|
||||||
// alphabetically, so <MY_GRIDSQUARE> (M) arrives BEFORE <PROP_MODE> (P)
|
|
||||||
// within a record. A sequential gate ("only add while propMode == SAT")
|
|
||||||
// would silently drop every own grid on real reports; buffer the record
|
|
||||||
// and decide at <EOR> instead.
|
|
||||||
var propMode: String? = null
|
|
||||||
var myGrids = mutableSetOf<String>()
|
|
||||||
for (raw in body.lineSequence()) {
|
|
||||||
val line = raw.trim()
|
|
||||||
when {
|
|
||||||
line.equals("<EOR>", ignoreCase = true) -> {
|
|
||||||
if (propMode == "SAT" && myGrids.isNotEmpty()) grids.addAll(myGrids)
|
|
||||||
propMode = null
|
|
||||||
myGrids = mutableSetOf()
|
|
||||||
}
|
|
||||||
line.startsWith("<PROP_MODE:") -> {
|
|
||||||
propMode = adifValue(line).uppercase()
|
|
||||||
}
|
|
||||||
line.startsWith("<MY_GRIDSQUARE:") -> {
|
|
||||||
val value = adifValue(line)
|
|
||||||
if (value.length >= 4) myGrids.add(value.take(4).uppercase())
|
|
||||||
}
|
|
||||||
line.startsWith("<MY_VUCC_GRIDS:") -> {
|
|
||||||
// Multi-grid 台址: each field of MY_VUCC_GRIDS is a grid the
|
|
||||||
// station location covered; without this the extra grids
|
|
||||||
// never reached the blue stripes.
|
|
||||||
adifValue(line).split(',').forEach { grid ->
|
|
||||||
val field = grid.trim().uppercase()
|
|
||||||
if (field.length >= 4) myGrids.add(field.take(4))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return grids
|
|
||||||
}
|
|
||||||
|
|
||||||
private companion object {
|
|
||||||
const val BASE_URL = "https://lotw.arrl.org/lotwuser/lotwreport.adi"
|
|
||||||
|
|
||||||
/** Record count in the report header: `<APP_LoTW_NUMREC:4>2367`. */
|
|
||||||
val NUMREC_REGEX = Regex("<APP_LoTW_NUMREC:\\d+>(\\d+)")
|
|
||||||
/**
|
|
||||||
* Measured 2026-09-16 (BH6RJD, 2367 QSLs): 1.7 MB body streamed at
|
|
||||||
* ~9.5 KB/s ≈ 180 s, i.e. ≈ 720 bytes per ADIF record. Use a
|
|
||||||
* CONSERVATIVE (larger) estimate so the progress bar never hits 100%
|
|
||||||
* before the download truly ends — an under-estimate made the bar sit
|
|
||||||
* at 100% while data was still flowing, read as "finished but stuck".
|
|
||||||
*/
|
|
||||||
const val AVG_RECORD_BYTES = 900L
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,134 +0,0 @@
|
|||||||
/*
|
|
||||||
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
|
||||||
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
|
||||||
*
|
|
||||||
* This program is free software: you can redistribute it and/or modify
|
|
||||||
* it under the terms of the GNU General Public License as published by
|
|
||||||
* the Free Software Foundation, either version 3 of the License, or
|
|
||||||
* (at your option) any later version.
|
|
||||||
*
|
|
||||||
* This program is distributed in the hope that it will be useful,
|
|
||||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
* GNU General Public License for more details.
|
|
||||||
*
|
|
||||||
* You should have received a copy of the GNU General Public License
|
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
*/
|
|
||||||
package com.rtbishop.look4sat.core.data.repository
|
|
||||||
|
|
||||||
import 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
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,465 +0,0 @@
|
|||||||
/*
|
|
||||||
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
|
||||||
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
|
||||||
*
|
|
||||||
* This program is free software: you can redistribute it and/or modify
|
|
||||||
* it under the terms of the GNU General Public License as published by
|
|
||||||
* the Free Software Foundation, either version 3 of the License, or
|
|
||||||
* (at your option) any later version.
|
|
||||||
*/
|
|
||||||
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.AdifCodec
|
|
||||||
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
|
|
||||||
import kotlinx.coroutines.sync.Mutex
|
|
||||||
import kotlinx.coroutines.sync.withLock
|
|
||||||
import kotlinx.coroutines.withContext
|
|
||||||
import java.util.Locale
|
|
||||||
|
|
||||||
class QsoRepository(
|
|
||||||
private val dao: QsoDao,
|
|
||||||
private val dispatcher: CoroutineDispatcher
|
|
||||||
) : IQsoRepository {
|
|
||||||
private val importMutex = Mutex()
|
|
||||||
|
|
||||||
override val records: Flow<List<QsoRecord>> = dao.observeAll().map { records -> records.map(QsoEntity::toDomain) }
|
|
||||||
|
|
||||||
override suspend fun find(id: Long): QsoRecord? = withContext(dispatcher) { dao.find(id)?.toDomain() }
|
|
||||||
|
|
||||||
override suspend fun save(record: QsoRecord): Long = withContext(dispatcher) {
|
|
||||||
importMutex.withLock {
|
|
||||||
val previous = if (record.id != 0L) dao.find(record.id)?.toDomain() else null
|
|
||||||
// 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,
|
|
||||||
wavelogStation = "",
|
|
||||||
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>, grids: List<String>, certificateCallsign: 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.isNotEmpty()) {
|
|
||||||
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() })
|
|
||||||
}
|
|
||||||
// Records logged while no certificate was installed carry no own callsign; they were just
|
|
||||||
// signed with the certificate's, so write it into them. Blanks only, and through save() so
|
|
||||||
// the stored dedupe key follows the new value.
|
|
||||||
if (certificateCallsign.isNotBlank()) {
|
|
||||||
ids.mapNotNull { id -> dao.find(id)?.toDomain()?.takeIf { it.myCallsign.isBlank() } }
|
|
||||||
.forEach { save(it.copy(myCallsign = certificateCallsign.trim().uppercase(Locale.US))) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun rewriteMyCallsign(ids: List<Long>, callsign: String): Int = withContext(dispatcher) {
|
|
||||||
val target = callsign.trim().uppercase(Locale.US)
|
|
||||||
if (ids.isEmpty() || target.isBlank()) return@withContext 0
|
|
||||||
var changed = 0
|
|
||||||
ids.forEach { id ->
|
|
||||||
val record = dao.find(id)?.toDomain() ?: return@forEach
|
|
||||||
if (record.myCallsign.trim().equals(target, true)) return@forEach
|
|
||||||
save(record.copy(myCallsign = target))
|
|
||||||
changed++
|
|
||||||
}
|
|
||||||
changed
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun markWavelogUploaded(ids: List<Long>, stationId: String) = withContext(dispatcher) {
|
|
||||||
if (ids.isEmpty()) return@withContext
|
|
||||||
importMutex.withLock { dao.markWavelogUploaded(ids, stationId) }
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun exportAdi(ids: Set<Long>?, includeIncomplete: Boolean): String = withContext(dispatcher) {
|
|
||||||
val records = dao.getAll()
|
|
||||||
.asSequence()
|
|
||||||
.filter { ids == null || it.id in ids }
|
|
||||||
.map(QsoEntity::toDomain)
|
|
||||||
.filter { includeIncomplete || it.status == QsoStatus.COMPLETE }
|
|
||||||
.toList()
|
|
||||||
AdifCodec.encode(records)
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun importAdi(content: String): AdifImportResult = withContext(dispatcher) {
|
|
||||||
importMutex.withLock {
|
|
||||||
mergeRecords(AdifCodec.decode(content))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun mergeConfirmed(records: List<QsoRecord>): AdifImportResult = withContext(dispatcher) {
|
|
||||||
importMutex.withLock { mergeRecords(records.filter { it.lotwConfirmed }) }
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun mergeLoTW(records: List<QsoRecord>): AdifImportResult = withContext(dispatcher) {
|
|
||||||
importMutex.withLock { mergeRecords(records, fromLoTW = true) }
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Merges QSO records pulled from Wavelog (the sync/download direction).
|
|
||||||
*
|
|
||||||
* Matching uses the same [stableQsoKey] identity as every other import, so a record
|
|
||||||
* already recorded locally (typically uploaded from this app) is not duplicated — it
|
|
||||||
* simply learns its Wavelog station profile and, where the local row is missing them,
|
|
||||||
* the opposite-station fields the server kept. Fresh records are inserted already
|
|
||||||
* marked as uploaded, so a later upload never sends them straight back.
|
|
||||||
*/
|
|
||||||
override suspend fun mergeWavelog(records: List<QsoRecord>): AdifImportResult = withContext(dispatcher) {
|
|
||||||
importMutex.withLock { mergeWavelogRecords(records) }
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun consolidateConfirmations(): Int = withContext(dispatcher) {
|
|
||||||
importMutex.withLock {
|
|
||||||
val working = dao.getAll().map(QsoEntity::toDomain).toMutableList()
|
|
||||||
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.map { stableQsoKey(it) }.toMutableSet()
|
|
||||||
val changes = linkedMapOf<Int, QsoRecord>().apply {
|
|
||||||
putAll(consolidation.merged)
|
|
||||||
putAll(renames)
|
|
||||||
}
|
|
||||||
var imported = 0
|
|
||||||
var updated = consolidation.merged.size
|
|
||||||
var skipped = 0
|
|
||||||
records.forEach { remote ->
|
|
||||||
if (!fromLoTW && !remote.lotwConfirmed && stableQsoKey(remote) in knownKeys) { skipped++; return@forEach }
|
|
||||||
val candidates = if (fromLoTW || remote.lotwConfirmed) lookup[remote.confirmationLookupKey()].orEmpty()
|
|
||||||
.filter { sameConfirmedContact(working[it], remote) } else emptyList()
|
|
||||||
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, satelliteName = officialSatelliteName(remote.satelliteName))
|
|
||||||
changes[working.size] = added
|
|
||||||
lookup.getOrPut(added.confirmationLookupKey()) { mutableListOf() }.add(working.size)
|
|
||||||
knownKeys += stableQsoKey(added)
|
|
||||||
working += added
|
|
||||||
imported++
|
|
||||||
} else {
|
|
||||||
val previous = working[match]
|
|
||||||
val merged = previous.withConfirmation(remote)
|
|
||||||
if (merged == previous) skipped++ else {
|
|
||||||
working[match] = merged
|
|
||||||
changes[match] = merged
|
|
||||||
updated++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
dao.saveBatch(changes.values.map(QsoRecord::toEntity))
|
|
||||||
consolidation.redundant.forEach { dao.delete(it.id) }
|
|
||||||
return AdifImportResult(imported, skipped, updated)
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun mergeWavelogRecords(records: List<QsoRecord>): AdifImportResult {
|
|
||||||
val working = dao.getAll().map(QsoEntity::toDomain).toMutableList()
|
|
||||||
// Rows imported under a tracking-source name join the ARRL name first, so the
|
|
||||||
// lookup below sees one identity for both sides of a match.
|
|
||||||
val renames = rewriteOfficialNames(working)
|
|
||||||
val indexByKey = HashMap<String, Int>(working.size)
|
|
||||||
working.indices.forEach { index -> indexByKey.putIfAbsent(stableQsoKey(working[index]), index) }
|
|
||||||
val changes = linkedMapOf<Int, QsoRecord>().apply { putAll(renames) }
|
|
||||||
var imported = 0
|
|
||||||
var updated = renames.size
|
|
||||||
var skipped = 0
|
|
||||||
val seenKeys = hashSetOf<String>()
|
|
||||||
records.forEach { remote ->
|
|
||||||
val key = stableQsoKey(remote)
|
|
||||||
if (!seenKeys.add(key)) {
|
|
||||||
skipped++
|
|
||||||
return@forEach
|
|
||||||
}
|
|
||||||
val index = indexByKey[key]
|
|
||||||
if (index == null) {
|
|
||||||
val added = remote.copy(
|
|
||||||
id = 0L,
|
|
||||||
wavelogUploaded = true,
|
|
||||||
satelliteName = officialSatelliteName(remote.satelliteName)
|
|
||||||
)
|
|
||||||
changes[working.size] = added
|
|
||||||
indexByKey[stableQsoKey(added)] = working.size
|
|
||||||
working += added
|
|
||||||
imported++
|
|
||||||
} else {
|
|
||||||
val previous = working[index]
|
|
||||||
val merged = previous.copy(
|
|
||||||
wavelogUploaded = true,
|
|
||||||
wavelogStation = remote.wavelogStation.ifBlank { previous.wavelogStation },
|
|
||||||
theirGrid = previous.theirGrid.ifBlank { remote.theirGrid },
|
|
||||||
theirVuccGrids = previous.theirVuccGrids.ifEmpty { remote.theirVuccGrids }
|
|
||||||
)
|
|
||||||
if (merged != previous) {
|
|
||||||
working[index] = merged
|
|
||||||
changes[index] = merged
|
|
||||||
updated++
|
|
||||||
} else {
|
|
||||||
skipped++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
dao.saveBatch(changes.values.map { it.toEntity() })
|
|
||||||
return AdifImportResult(imported, skipped, updated)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Result of folding already-split rows back together. */
|
|
||||||
private data class Consolidation(
|
|
||||||
/** index in the stored list -> the record to save (local row + its confirmation). */
|
|
||||||
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(
|
|
||||||
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 = runCatching { QsoStatus.valueOf(status) }.getOrDefault(QsoStatus.DRAFT),
|
|
||||||
propagationMode = propagationMode,
|
|
||||||
lotwConfirmed = lotwConfirmed,
|
|
||||||
lotwUploaded = lotwUploaded,
|
|
||||||
wavelogUploaded = wavelogUploaded,
|
|
||||||
wavelogStation = wavelogStation,
|
|
||||||
lotwReceived = lotwReceived,
|
|
||||||
lotwQslDate = lotwQslDate,
|
|
||||||
vuccGrids = vuccGrids.split(',').filter(String::isNotBlank),
|
|
||||||
theirVuccGrids = theirVuccGrids.split(',').filter(String::isNotBlank),
|
|
||||||
dxcc = dxcc,
|
|
||||||
country = country,
|
|
||||||
cqZone = cqZone,
|
|
||||||
region = region,
|
|
||||||
comment = comment
|
|
||||||
)
|
|
||||||
|
|
||||||
private fun QsoRecord.toEntity() = QsoEntity(
|
|
||||||
id = id,
|
|
||||||
startUtcMillis = startUtcMillis,
|
|
||||||
endUtcMillis = endUtcMillis,
|
|
||||||
theirCallsign = theirCallsign.trim().uppercase(Locale.US),
|
|
||||||
myCallsign = myCallsign.trim().uppercase(Locale.US),
|
|
||||||
theirGrid = theirGrid.trim().uppercase(Locale.US),
|
|
||||||
myGrid = myGrid.trim().uppercase(Locale.US),
|
|
||||||
sentReport = sentReport.trim(),
|
|
||||||
receivedReport = receivedReport.trim(),
|
|
||||||
txFrequencyHz = txFrequencyHz,
|
|
||||||
rxFrequencyHz = rxFrequencyHz,
|
|
||||||
band = band,
|
|
||||||
rxBand = rxBand,
|
|
||||||
mode = mode.trim(),
|
|
||||||
submode = submode.trim(),
|
|
||||||
satelliteName = satelliteName,
|
|
||||||
transponderName = transponderName,
|
|
||||||
satelliteMode = satelliteMode,
|
|
||||||
passAosUtcMillis = passAosUtcMillis,
|
|
||||||
automatic = automatic,
|
|
||||||
status = status.name,
|
|
||||||
dedupeKey = stableQsoKey(this),
|
|
||||||
propagationMode = propagationMode.ifBlank { if (satelliteName.isNotBlank()) "SAT" else "" },
|
|
||||||
lotwConfirmed = lotwConfirmed,
|
|
||||||
lotwUploaded = lotwUploaded,
|
|
||||||
wavelogUploaded = wavelogUploaded,
|
|
||||||
wavelogStation = wavelogStation.trim(),
|
|
||||||
lotwReceived = lotwReceived,
|
|
||||||
lotwQslDate = lotwQslDate,
|
|
||||||
vuccGrids = vuccGrids.joinToString(","),
|
|
||||||
theirVuccGrids = theirVuccGrids.joinToString(","),
|
|
||||||
dxcc = dxcc,
|
|
||||||
country = country,
|
|
||||||
cqZone = cqZone,
|
|
||||||
region = region,
|
|
||||||
comment = comment
|
|
||||||
)
|
|
||||||
|
|
||||||
internal fun stableQsoKey(record: QsoRecord): String = listOf(
|
|
||||||
record.startUtcMillis.toString(),
|
|
||||||
record.theirCallsign.trim().uppercase(Locale.US),
|
|
||||||
record.myCallsign.trim().uppercase(Locale.US),
|
|
||||||
record.txFrequencyHz?.toString().orEmpty(),
|
|
||||||
record.mode.trim().uppercase(Locale.US),
|
|
||||||
record.submode.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
|
|
||||||
+7
-19
@@ -40,6 +40,7 @@ import kotlinx.coroutines.flow.map
|
|||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import java.util.TimeZone
|
import java.util.TimeZone
|
||||||
|
import kotlin.time.Duration.Companion.milliseconds
|
||||||
|
|
||||||
class SatelliteRepo(
|
class SatelliteRepo(
|
||||||
private val dispatcher: CoroutineDispatcher,
|
private val dispatcher: CoroutineDispatcher,
|
||||||
@@ -65,33 +66,23 @@ class SatelliteRepo(
|
|||||||
|
|
||||||
override suspend fun getRadiosWithId(id: Int) = localStorage.getRadiosWithId(id)
|
override suspend fun getRadiosWithId(id: Int) = localStorage.getRadiosWithId(id)
|
||||||
|
|
||||||
override suspend fun getSatelliteIdsWithModes(modes: List<String>) = localStorage.getIdsWithModes(modes)
|
|
||||||
|
|
||||||
override suspend fun getSatelliteIdsWithModesAndUplink(modes: List<String>) = localStorage.getIdsWithModesAndUplink(modes)
|
|
||||||
|
|
||||||
override suspend fun getSatelliteIdsWithModesAndAmateur(modes: List<String>) = localStorage.getIdsWithModesAndAmateur(modes)
|
|
||||||
|
|
||||||
override suspend fun initRepository() = withContext(dispatcher) {
|
override suspend fun initRepository() = withContext(dispatcher) {
|
||||||
combine(
|
combine(
|
||||||
settingsRepo.selectedIds,
|
settingsRepo.selectedIds,
|
||||||
settingsRepo.stationPosition,
|
settingsRepo.stationPosition,
|
||||||
// Recalculate passes when the UTC display toggle changes, so an existing
|
|
||||||
// AOS time window is reapplied in the newly selected timezone.
|
|
||||||
settingsRepo.otherSettings.map { it.stateOfUtc }.distinctUntilChanged()
|
settingsRepo.otherSettings.map { it.stateOfUtc }.distinctUntilChanged()
|
||||||
) { selectedIds, _, _ -> selectedIds }
|
) { selectedIds, _, _ -> selectedIds }
|
||||||
.collect { selectedIds ->
|
.collect { selectedIds ->
|
||||||
_satellites.update { localStorage.getEntriesWithIds(selectedIds) }
|
_satellites.update { localStorage.getEntriesWithIds(selectedIds) }
|
||||||
val settings = settingsRepo.passesSettings.value
|
val settings = settingsRepo.passesSettings.value
|
||||||
val now = System.currentTimeMillis()
|
|
||||||
// 历史过境: 回看窗口 hoursBefore 小时, 过境列表从更早时间开始计算.
|
|
||||||
calculatePasses(
|
calculatePasses(
|
||||||
time = now - settings.hoursBefore * 60L * 60L * 1000L,
|
time = System.currentTimeMillis(),
|
||||||
hoursAhead = settings.hoursBefore + settings.hoursAhead,
|
hoursAhead = settings.hoursAhead,
|
||||||
minElevation = settings.minElevation,
|
minElevation = settings.minElevation,
|
||||||
aosStartMinute = settings.aosStartMinute,
|
aosStartMinute = settings.aosStartMinute,
|
||||||
aosEndMinute = settings.aosEndMinute,
|
aosEndMinute = settings.aosEndMinute,
|
||||||
invertAosTimeWindow = settings.invertAosTimeWindow,
|
invertAosTimeWindow = settings.invertAosTimeWindow,
|
||||||
modes = settings.selectedModes
|
modes = settingsRepo.selectedSatModes.value
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -176,7 +167,7 @@ class SatelliteRepo(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
newPasses.sortBy { it.aosTime }
|
newPasses.sortBy { it.aosTime }
|
||||||
delay(1000) // Simulate loading time for better UX
|
delay(1000.milliseconds) // Simulate loading time for better UX
|
||||||
_passes.update { newPasses }
|
_passes.update { newPasses }
|
||||||
}
|
}
|
||||||
_isCalculating.value = false
|
_isCalculating.value = false
|
||||||
@@ -188,10 +179,7 @@ class SatelliteRepo(
|
|||||||
aosEndMinute: Int,
|
aosEndMinute: Int,
|
||||||
invertAosTimeWindow: Boolean
|
invertAosTimeWindow: Boolean
|
||||||
): Boolean {
|
): Boolean {
|
||||||
// Follow the UTC display toggle: with UTC enabled the window is interpreted
|
// Must match the timezone used to display pass times (UTC toggle vs device local)
|
||||||
// in UTC, otherwise in the device's local timezone. Otherwise the pass list
|
|
||||||
// shows UTC times while the filter silently uses local time — the two
|
|
||||||
// disagree by the timezone offset (e.g. 8h for China).
|
|
||||||
val tz = if (settingsRepo.otherSettings.value.stateOfUtc) {
|
val tz = if (settingsRepo.otherSettings.value.stateOfUtc) {
|
||||||
TimeZone.getTimeZone("UTC")
|
TimeZone.getTimeZone("UTC")
|
||||||
} else {
|
} else {
|
||||||
@@ -203,7 +191,7 @@ class SatelliteRepo(
|
|||||||
val inRange = if (aosStartMinute <= aosEndMinute) {
|
val inRange = if (aosStartMinute <= aosEndMinute) {
|
||||||
aosMinute in aosStartMinute..aosEndMinute
|
aosMinute in aosStartMinute..aosEndMinute
|
||||||
} else {
|
} else {
|
||||||
aosMinute >= aosStartMinute || aosMinute <= aosEndMinute
|
aosMinute !in (aosEndMinute + 1)..<aosStartMinute
|
||||||
}
|
}
|
||||||
return if (invertAosTimeWindow) !inRange else inRange
|
return if (invertAosTimeWindow) !inRange else inRange
|
||||||
}
|
}
|
||||||
|
|||||||
+11
-66
@@ -38,39 +38,25 @@ class SelectionRepo(
|
|||||||
) : ISelectionRepo {
|
) : ISelectionRepo {
|
||||||
|
|
||||||
private val currentItems = MutableStateFlow<List<SatItem>>(emptyList())
|
private val currentItems = MutableStateFlow<List<SatItem>>(emptyList())
|
||||||
private val currentTypes = MutableStateFlow(settingsRepo.selectedTypes.value)
|
|
||||||
private val currentQuery = MutableStateFlow("")
|
private val currentQuery = MutableStateFlow("")
|
||||||
|
|
||||||
// Resolve type IDs once when types change, then filter items reactively.
|
// Resolve sat IDs once when modes change, then filter items reactively.
|
||||||
// The HashSet gives O(1) catnum lookups instead of O(n) with a List.
|
// The HashSet gives O(1) catnum lookups instead of O(n) with a List.
|
||||||
// The three virtual types ("AMSAT Live FM", "AMSAT Live Linear", "Live SSTV")
|
// Directly observe settingsRepo.selectedSatModes to ensure real-time sync across screens.
|
||||||
// are resolved from hardcoded FM/Linear catnum sets (Sources) or live
|
private val itemsWithModes = settingsRepo.selectedSatModes.flatMapLatest { list: List<String> ->
|
||||||
// SSTV transponder data instead of SharedPreferences.
|
val catnumSet: Set<Int>? = if (list.isEmpty()) {
|
||||||
private val itemsWithTypes = currentTypes.flatMapLatest { types: List<String> ->
|
|
||||||
val catnumSet: Set<Int>? = if (types.isEmpty()) {
|
|
||||||
null // null = no filtering
|
null // null = no filtering
|
||||||
} else {
|
} else {
|
||||||
val ids = resolveTypeIds(types)
|
val ids = localSource.getIdsWithModes(list)
|
||||||
when {
|
if (ids.isEmpty()) null else ids.toHashSet()
|
||||||
ids.isNotEmpty() -> ids.toHashSet()
|
|
||||||
// "All" is the CelesTrak active group, i.e. the whole catalogue: an empty
|
|
||||||
// persisted list must mean "no filtering" (show every satellite), not an
|
|
||||||
// empty list. Its list can be empty simply because CelesTrak answered 403
|
|
||||||
// to a repeated download inside the 2-hour update window.
|
|
||||||
Sources.allSourceType in types -> null
|
|
||||||
else -> emptySet()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
currentItems.map { items ->
|
currentItems.map { items ->
|
||||||
if (catnumSet == null) items else items.filter { it.catnum in catnumSet }
|
if (catnumSet == null) items else items.filter { it.catnum in catnumSet }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Selected satellites are pinned to the top (matches upstream); ordering
|
|
||||||
// within each group stays name/catnum. Sits on the reactive chain, so
|
|
||||||
// ticking an entry re-sorts the visible list immediately.
|
|
||||||
private val itemsWithQuery = currentQuery.flatMapLatest { query ->
|
private val itemsWithQuery = currentQuery.flatMapLatest { query ->
|
||||||
itemsWithTypes.map { items ->
|
itemsWithModes.map { items ->
|
||||||
filterByQuery(items, query).sortedWith(
|
filterByQuery(items, query).sortedWith(
|
||||||
compareByDescending<SatItem> { it.isSelected }
|
compareByDescending<SatItem> { it.isSelected }
|
||||||
.thenBy { it.name }
|
.thenBy { it.name }
|
||||||
@@ -79,49 +65,9 @@ class SelectionRepo(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun getCurrentTypes() = currentTypes.value
|
override fun getCurrentModes() = settingsRepo.selectedSatModes.value
|
||||||
|
|
||||||
/**
|
override fun getModesList() = Sources.satelliteModes
|
||||||
* Resolves a list of type names to satellite catnums. The three virtual
|
|
||||||
* transponder/activity types are resolved from hardcoded FM/Linear lists
|
|
||||||
* (Sources.amSatFmCatnums / amSatLinearCatnums) or local SSTV transponder
|
|
||||||
* data; all other types come from the per-type ID lists persisted by
|
|
||||||
* [ISettingsRepo].
|
|
||||||
*/
|
|
||||||
private suspend fun resolveTypeIds(types: List<String>): List<Int> {
|
|
||||||
val idsSet = mutableSetOf<Int>()
|
|
||||||
types.forEach { type ->
|
|
||||||
when (type) {
|
|
||||||
Sources.virtualTypeNames[0] -> idsSet.addAll(Sources.amSatFmCatnums)
|
|
||||||
Sources.virtualTypeNames[1] -> idsSet.addAll(Sources.amSatLinearCatnums)
|
|
||||||
// Live SSTV: mode=SSTV transponder records whose service class
|
|
||||||
// is "Amateur", excluding launcher debris / rocket bodies
|
|
||||||
// (names ending in "R/B" or "DEB" — e.g. Ariane 6 R/B) that
|
|
||||||
// carry an amateur payload transponder but are not satellites.
|
|
||||||
Sources.virtualTypeNames[2] -> {
|
|
||||||
val sstvIds = localSource.getIdsWithModesAndAmateur(listOf("SSTV")).toSet()
|
|
||||||
val inOrbitNames = currentItems.value.associate { it.catnum to it.name }
|
|
||||||
val filtered = sstvIds.filter { catnum ->
|
|
||||||
val name = inOrbitNames[catnum]?.uppercase().orEmpty()
|
|
||||||
!name.endsWith(" R/B") && !name.endsWith(" DEB") &&
|
|
||||||
!name.endsWith("R/B") && !name.endsWith("DEB")
|
|
||||||
}
|
|
||||||
idsSet.addAll(filtered)
|
|
||||||
}
|
|
||||||
else -> idsSet.addAll(settingsRepo.getSatelliteTypesIds(listOf(type)))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return idsSet.toList()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun getTypesList() = buildList {
|
|
||||||
// 三个转发器/活动虚拟类型排在最前.
|
|
||||||
addAll(Sources.virtualTypeNames)
|
|
||||||
// 所有真实 TLE 源类型均可选(含 "All" = CelesTrak active);只排除
|
|
||||||
// "Other"(空 URL 占位,无数据)。不要用 removeAt(0) —— 字母序第一个
|
|
||||||
// 是 "All"/"ARISS" 这类真实源,删掉会让用户勾不到它们。
|
|
||||||
addAll(Sources.satelliteDataUrls.keys.sorted().filterNot { it == "Other" })
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun getEntriesFlow() = withContext(dispatcher) {
|
override suspend fun getEntriesFlow() = withContext(dispatcher) {
|
||||||
val selectedIds = settingsRepo.selectedIds.value.toHashSet()
|
val selectedIds = settingsRepo.selectedIds.value.toHashSet()
|
||||||
@@ -131,9 +77,8 @@ class SelectionRepo(
|
|||||||
return@withContext itemsWithQuery
|
return@withContext itemsWithQuery
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun setTypes(types: List<String>) {
|
override suspend fun setModes(modes: List<String>) {
|
||||||
currentTypes.value = types
|
settingsRepo.setSelectedSatModes(modes)
|
||||||
settingsRepo.setSelectedTypes(types)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun setQuery(query: String) {
|
override suspend fun setQuery(query: String) {
|
||||||
|
|||||||
@@ -27,7 +27,6 @@ import android.view.Display
|
|||||||
import android.view.Surface
|
import android.view.Surface
|
||||||
import com.rtbishop.look4sat.core.domain.predict.GeoPos
|
import com.rtbishop.look4sat.core.domain.predict.GeoPos
|
||||||
import com.rtbishop.look4sat.core.domain.predict.RAD2DEG
|
import com.rtbishop.look4sat.core.domain.predict.RAD2DEG
|
||||||
import com.rtbishop.look4sat.core.domain.repository.CompassAccuracy
|
|
||||||
import com.rtbishop.look4sat.core.domain.repository.ISensorsRepo
|
import com.rtbishop.look4sat.core.domain.repository.ISensorsRepo
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
@@ -42,12 +41,7 @@ class SensorsRepo(
|
|||||||
) : ISensorsRepo, SensorEventListener {
|
) : ISensorsRepo, SensorEventListener {
|
||||||
|
|
||||||
private val _sensorData = MutableStateFlow(Pair(0f, 0f))
|
private val _sensorData = MutableStateFlow(Pair(0f, 0f))
|
||||||
private val rotationSensor: Sensor? = sensorManager.getDefaultSensor(Sensor.TYPE_ROTATION_VECTOR)
|
private val sensor: Sensor? = sensorManager.getDefaultSensor(Sensor.TYPE_ROTATION_VECTOR)
|
||||||
private val magneticSensor: Sensor? = sensorManager.getDefaultSensor(Sensor.TYPE_MAGNETIC_FIELD)
|
|
||||||
private val _compassAccuracy = MutableStateFlow(
|
|
||||||
if (rotationSensor == null || magneticSensor == null) CompassAccuracy.UNAVAILABLE
|
|
||||||
else CompassAccuracy.UNRELIABLE
|
|
||||||
)
|
|
||||||
private val rotationMatrix = FloatArray(9)
|
private val rotationMatrix = FloatArray(9)
|
||||||
private val tempMatrix = FloatArray(9)
|
private val tempMatrix = FloatArray(9)
|
||||||
private val orientationValues = FloatArray(3)
|
private val orientationValues = FloatArray(3)
|
||||||
@@ -56,7 +50,6 @@ class SensorsRepo(
|
|||||||
private var hasInitialReading = false
|
private var hasInitialReading = false
|
||||||
|
|
||||||
override val sensorData: StateFlow<Pair<Float, Float>> = _sensorData
|
override val sensorData: StateFlow<Pair<Float, Float>> = _sensorData
|
||||||
override val compassAccuracy: StateFlow<CompassAccuracy> = _compassAccuracy
|
|
||||||
|
|
||||||
override fun getMagDeclination(geoPos: GeoPos, time: Long): Float {
|
override fun getMagDeclination(geoPos: GeoPos, time: Long): Float {
|
||||||
return GeomagneticField(
|
return GeomagneticField(
|
||||||
@@ -69,41 +62,15 @@ class SensorsRepo(
|
|||||||
|
|
||||||
override fun enableSensor() {
|
override fun enableSensor() {
|
||||||
hasInitialReading = false
|
hasInitialReading = false
|
||||||
if (rotationSensor == null || magneticSensor == null) {
|
sensor?.let { sensorManager.registerListener(this, it, SENSOR_RATE_US) }
|
||||||
_compassAccuracy.value = CompassAccuracy.UNAVAILABLE
|
|
||||||
return
|
|
||||||
}
|
|
||||||
_compassAccuracy.value = CompassAccuracy.UNRELIABLE
|
|
||||||
val rotationRegistered = sensorManager.registerListener(this, rotationSensor, SENSOR_RATE_US)
|
|
||||||
val magneticRegistered = sensorManager.registerListener(this, magneticSensor, SENSOR_RATE_US)
|
|
||||||
if (!rotationRegistered || !magneticRegistered) {
|
|
||||||
sensorManager.unregisterListener(this)
|
|
||||||
_compassAccuracy.value = CompassAccuracy.UNAVAILABLE
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun disableSensor() = sensorManager.unregisterListener(this)
|
override fun disableSensor() = sensorManager.unregisterListener(this)
|
||||||
|
|
||||||
override fun onAccuracyChanged(sensor: Sensor, accuracy: Int) {
|
override fun onAccuracyChanged(sensor: Sensor, accuracy: Int) = Unit
|
||||||
if (sensor.type == Sensor.TYPE_MAGNETIC_FIELD) {
|
|
||||||
updateAccuracy(accuracy)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onSensorChanged(event: SensorEvent) {
|
override fun onSensorChanged(event: SensorEvent) {
|
||||||
when (event.sensor.type) {
|
if (event.sensor.type == Sensor.TYPE_ROTATION_VECTOR) handleSensorEvent(event)
|
||||||
Sensor.TYPE_ROTATION_VECTOR -> handleSensorEvent(event)
|
|
||||||
Sensor.TYPE_MAGNETIC_FIELD -> updateAccuracy(event.accuracy)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun updateAccuracy(accuracy: Int) {
|
|
||||||
_compassAccuracy.value = when (accuracy) {
|
|
||||||
SensorManager.SENSOR_STATUS_ACCURACY_HIGH -> CompassAccuracy.HIGH
|
|
||||||
SensorManager.SENSOR_STATUS_ACCURACY_MEDIUM -> CompassAccuracy.MEDIUM
|
|
||||||
SensorManager.SENSOR_STATUS_ACCURACY_LOW -> CompassAccuracy.LOW
|
|
||||||
else -> CompassAccuracy.UNRELIABLE
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getDisplayRotation(): Int {
|
private fun getDisplayRotation(): Int {
|
||||||
|
|||||||
+84
-582
@@ -25,15 +25,14 @@ import androidx.core.location.LocationListenerCompat
|
|||||||
import androidx.core.location.LocationManagerCompat
|
import androidx.core.location.LocationManagerCompat
|
||||||
import com.rtbishop.look4sat.core.domain.model.DataSourcesSettings
|
import com.rtbishop.look4sat.core.domain.model.DataSourcesSettings
|
||||||
import com.rtbishop.look4sat.core.domain.model.DatabaseState
|
import com.rtbishop.look4sat.core.domain.model.DatabaseState
|
||||||
import com.rtbishop.look4sat.core.domain.model.MapSource
|
|
||||||
import com.rtbishop.look4sat.core.domain.model.OtherSettings
|
import com.rtbishop.look4sat.core.domain.model.OtherSettings
|
||||||
import com.rtbishop.look4sat.core.domain.model.PassesSettings
|
import com.rtbishop.look4sat.core.domain.model.PassesSettings
|
||||||
import com.rtbishop.look4sat.core.domain.model.RCSettings
|
import com.rtbishop.look4sat.core.domain.model.RCSettings
|
||||||
import com.rtbishop.look4sat.core.domain.model.RadioControlSettings
|
import com.rtbishop.look4sat.core.domain.model.RadioControlSettings
|
||||||
|
import com.rtbishop.look4sat.core.domain.model.Constants
|
||||||
import com.rtbishop.look4sat.core.domain.model.WavelogUploadSettings
|
|
||||||
import com.rtbishop.look4sat.core.domain.predict.GeoPos
|
import com.rtbishop.look4sat.core.domain.predict.GeoPos
|
||||||
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
|
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
|
||||||
|
import com.rtbishop.look4sat.core.domain.source.Sources
|
||||||
import com.rtbishop.look4sat.core.domain.utility.positionToQth
|
import com.rtbishop.look4sat.core.domain.utility.positionToQth
|
||||||
import com.rtbishop.look4sat.core.domain.utility.qthToPosition
|
import com.rtbishop.look4sat.core.domain.utility.qthToPosition
|
||||||
import com.rtbishop.look4sat.core.domain.utility.round
|
import com.rtbishop.look4sat.core.domain.utility.round
|
||||||
@@ -42,13 +41,12 @@ import kotlinx.coroutines.flow.StateFlow
|
|||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
import org.json.JSONObject
|
import org.json.JSONObject
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
import com.rtbishop.look4sat.core.domain.model.Constants
|
|
||||||
import com.rtbishop.look4sat.core.domain.source.Sources
|
|
||||||
|
|
||||||
class SettingsRepo(
|
class SettingsRepo(
|
||||||
private val locationManager: LocationManager,
|
private val locationManager: LocationManager,
|
||||||
private val preferences: SharedPreferences,
|
private val preferences: SharedPreferences,
|
||||||
override val appVersionName: String
|
override val appVersionName: String,
|
||||||
|
override val appVersionCode: Long,
|
||||||
) : ISettingsRepo, LocationListenerCompat {
|
) : ISettingsRepo, LocationListenerCompat {
|
||||||
|
|
||||||
private val keyBluetoothRotatorAddress = "bluetoothAddress"
|
private val keyBluetoothRotatorAddress = "bluetoothAddress"
|
||||||
@@ -60,7 +58,6 @@ class SettingsRepo(
|
|||||||
private val keyBluetoothFrequencyFormat = "bluetoothFrequencyFormat"
|
private val keyBluetoothFrequencyFormat = "bluetoothFrequencyFormat"
|
||||||
private val keyFilterShowDeepSpace = "filterShowDeepSpace"
|
private val keyFilterShowDeepSpace = "filterShowDeepSpace"
|
||||||
private val keyFilterHoursAhead = "filterHoursAhead"
|
private val keyFilterHoursAhead = "filterHoursAhead"
|
||||||
private val keyFilterHoursBefore = "filterHoursBefore"
|
|
||||||
private val keyFilterMinElevation = "filterMinElevation"
|
private val keyFilterMinElevation = "filterMinElevation"
|
||||||
private val keyFilterAosStartMinute = "filterAosStartMinute"
|
private val keyFilterAosStartMinute = "filterAosStartMinute"
|
||||||
private val keyFilterAosEndMinute = "filterAosEndMinute"
|
private val keyFilterAosEndMinute = "filterAosEndMinute"
|
||||||
@@ -77,18 +74,13 @@ class SettingsRepo(
|
|||||||
private val keyFrequencyFormat = "frequencyFormat"
|
private val keyFrequencyFormat = "frequencyFormat"
|
||||||
private val keyFrequencyOffsetHz = "frequencyOffsetHz"
|
private val keyFrequencyOffsetHz = "frequencyOffsetHz"
|
||||||
private val keySelectedIds = "selectedIds"
|
private val keySelectedIds = "selectedIds"
|
||||||
private val keySelectedTypes = "selectedTypes"
|
private val keySelectedSatModes = "selectedSatModes"
|
||||||
private val keySelectedModes = "selectedModes"
|
|
||||||
private val keyStateOfAutoUpdate = "stateOfAutoUpdate"
|
private val keyStateOfAutoUpdate = "stateOfAutoUpdate"
|
||||||
private val keyStateOfAutoLotwSync = "stateOfAutoLotwSync"
|
|
||||||
private val keyStateOfSensors = "stateOfSensors"
|
private val keyStateOfSensors = "stateOfSensors"
|
||||||
private val keyCompassOffsetDegrees = "compassOffsetDegrees"
|
|
||||||
private val keyStateOfSweep = "stateOfSweep"
|
private val keyStateOfSweep = "stateOfSweep"
|
||||||
private val keyStateOfUtc = "stateOfUtc"
|
private val keyStateOfUtc = "stateOfUtc"
|
||||||
private val keyStateOfLightTheme = "stateOfLightTheme"
|
private val keyStateOfLightTheme = "stateOfLightTheme"
|
||||||
private val keyStateOfNightMode = "stateOfNightMode"
|
private val keyStateOfNightMode = "stateOfNightMode"
|
||||||
private val keyStateOfMapGrid = "stateOfMapGrid"
|
|
||||||
private val keyStateOfMapFirstCall = "stateOfMapFirstCall"
|
|
||||||
private val keyStationAltitude = "stationAltitude"
|
private val keyStationAltitude = "stationAltitude"
|
||||||
private val keyStationLatitude = "stationLatitude"
|
private val keyStationLatitude = "stationLatitude"
|
||||||
private val keyStationLongitude = "stationLongitude"
|
private val keyStationLongitude = "stationLongitude"
|
||||||
@@ -96,32 +88,38 @@ class SettingsRepo(
|
|||||||
private val keyStationTimestamp = "stationTimestamp"
|
private val keyStationTimestamp = "stationTimestamp"
|
||||||
private val keyUpdateTimestamp = "updateTimestamp"
|
private val keyUpdateTimestamp = "updateTimestamp"
|
||||||
private val keyShouldSeeWarning = "shouldSeeWarning"
|
private val keyShouldSeeWarning = "shouldSeeWarning"
|
||||||
private val keyShouldSeeWhatsNew = "shouldSeeWhatsNew_v$appVersionName"
|
private val keyShouldSeeWhatsNew = "shouldSeeWhatsNew"
|
||||||
private val keySstvMode = "sstvMode"
|
private val keySstvMode = "sstvMode"
|
||||||
private val keyLowElevation = "lowElevation"
|
private val keyLowElevation = "lowElevation"
|
||||||
private val keyHighElevation = "highElevation"
|
private val keyHighElevation = "highElevation"
|
||||||
private val keyMapSource = "mapSource"
|
private val keyRadarCompassOffset = "radarCompassOffset"
|
||||||
private val keyTiandituKey = "tiandituKey"
|
private val keyRadarCompassOffsetElev = "radarCompassOffsetElev"
|
||||||
private val keyUseCustomTle = "useCustomTle"
|
private val keySatellitesUrls = "satellitesUrls"
|
||||||
private val keyUseCustomTransceivers = "useCustomTransceivers"
|
|
||||||
private val keyTleUrl = "tleUrl"
|
|
||||||
private val keyTransceiversUrl = "transceiversUrl"
|
|
||||||
private val keySatelliteUrls = "satelliteUrls"
|
|
||||||
private val keyTransceiversUrls = "transceiversUrls"
|
private val keyTransceiversUrls = "transceiversUrls"
|
||||||
private val keySatelliteEnabled = "satelliteEnabled"
|
private val keySatellitesUrlsEnabled = "satellitesUrlsEnabled"
|
||||||
private val keyTransceiversEnabled = "transceiversEnabled"
|
private val keyTransceiversUrlsEnabled = "transceiversUrlsEnabled"
|
||||||
private val keySatnogsTleSourceMigration = "satnogsTleSourceMigration"
|
private val keySettingsVersion = "settingsVersion"
|
||||||
private val keyAutoTleSourceMigration = "autoTleSourceMigration"
|
|
||||||
private val separatorComma = ","
|
private val separatorComma = ","
|
||||||
private val separatorUrl = "\n"
|
private val separatorUrl = "\n"
|
||||||
private val legacyCelestrakSatnogsUrl =
|
|
||||||
"https://celestrak.org/NORAD/elements/gp.php?GROUP=satnogs&FORMAT=csv"
|
init {
|
||||||
|
// The meaning of the data source order changed, so the stored lists are dropped and the defaults
|
||||||
|
// are applied again. The update timestamp is cleared as well, to refresh the migrated data
|
||||||
|
// on the first launch instead of waiting up to 48 hours for the automatic update.
|
||||||
|
if (preferences.getInt(keySettingsVersion, 0) < appVersionCode) {
|
||||||
|
preferences.edit {
|
||||||
|
listOf(keyShouldSeeWhatsNew, keySatellitesUrls, keyTransceiversUrls, keyUpdateTimestamp)
|
||||||
|
.forEach { key -> remove(key) }
|
||||||
|
putInt(keySettingsVersion, appVersionCode.toInt())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
//region # Satellites selection settings
|
//region # Satellites selection settings
|
||||||
private val _satelliteSelection = MutableStateFlow(getSelectedIds())
|
private val _satelliteSelection = MutableStateFlow(getSelectedIds())
|
||||||
private val _typesSelection = MutableStateFlow(getSelectedTypes())
|
private val _satelliteModeSelection = MutableStateFlow(getSelectedSatModes())
|
||||||
override val selectedIds: StateFlow<List<Int>> = _satelliteSelection
|
override val selectedIds: StateFlow<List<Int>> = _satelliteSelection
|
||||||
override val selectedTypes: StateFlow<List<String>> = _typesSelection
|
override val selectedSatModes: StateFlow<List<String>> = _satelliteModeSelection
|
||||||
|
|
||||||
override fun setSelectedIds(ids: List<Int>) {
|
override fun setSelectedIds(ids: List<Int>) {
|
||||||
val selectionString = ids.joinToString(separatorComma)
|
val selectionString = ids.joinToString(separatorComma)
|
||||||
@@ -129,10 +127,10 @@ class SettingsRepo(
|
|||||||
_satelliteSelection.value = ids
|
_satelliteSelection.value = ids
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun setSelectedTypes(types: List<String>) {
|
override fun setSelectedSatModes(modes: List<String>) {
|
||||||
val typesString = types.joinToString(separatorComma)
|
val modesString = modes.joinToString(separatorComma)
|
||||||
preferences.edit { putString(keySelectedTypes, typesString) }
|
preferences.edit { putString(keySelectedSatModes, modesString) }
|
||||||
_typesSelection.value = types
|
_satelliteModeSelection.value = modes
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getSelectedIds(): List<Int> {
|
private fun getSelectedIds(): List<Int> {
|
||||||
@@ -141,375 +139,41 @@ class SettingsRepo(
|
|||||||
return selectionString.split(separatorComma).map { it.toInt() }
|
return selectionString.split(separatorComma).map { it.toInt() }
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getSelectedTypes(): List<String> {
|
private fun getSelectedSatModes(): List<String> {
|
||||||
val typesString = preferences.getString(keySelectedTypes, "Amateur")
|
val modesString = preferences.getString(keySelectedSatModes, null)
|
||||||
if (typesString.isNullOrEmpty()) return emptyList()
|
if (modesString.isNullOrEmpty()) return emptyList()
|
||||||
return typesString.split(separatorComma)
|
return modesString.split(separatorComma).sorted()
|
||||||
}
|
}
|
||||||
//endregion
|
//endregion
|
||||||
|
|
||||||
//region # Worked-grid data (written by the LoTW / Wavelog syncs)
|
//region # Passes filter settings
|
||||||
private val keyWorkedGrids = "workedGrids"
|
|
||||||
|
|
||||||
override fun getWorkedGrids(): Set<String> {
|
|
||||||
val json = preferences.getString(keyWorkedGrids, null).orEmpty()
|
|
||||||
if (json.isBlank()) return emptySet()
|
|
||||||
return try {
|
|
||||||
val array = org.json.JSONArray(json)
|
|
||||||
(0 until array.length()).mapNotNull { i ->
|
|
||||||
array.optString(i).takeIf { it.isNotBlank() }
|
|
||||||
}.toSet()
|
|
||||||
} catch (_: Exception) {
|
|
||||||
emptySet()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun setWorkedGrids(grids: Set<String>) {
|
|
||||||
val array = org.json.JSONArray()
|
|
||||||
grids.sorted().forEach { array.put(it) }
|
|
||||||
preferences.edit { putString(keyWorkedGrids, array.toString()) }
|
|
||||||
}
|
|
||||||
|
|
||||||
// Confirmed satellite QSOs per worked gridsquare, persisted as a single JSON
|
|
||||||
// object: {"OL62":[{"c":call,"t":epochMs,"s":sat,"m":mode,"bu":up,"bd":down}]}.
|
|
||||||
private val keyWorkedGridQsos = "workedGridQsos"
|
|
||||||
|
|
||||||
override fun getWorkedGridQsos(): Map<String, List<com.rtbishop.look4sat.core.domain.model.GridQso>> {
|
|
||||||
val json = preferences.getString(keyWorkedGridQsos, null).orEmpty()
|
|
||||||
if (json.isBlank()) return emptyMap()
|
|
||||||
return try {
|
|
||||||
val root = org.json.JSONObject(json)
|
|
||||||
val result = mutableMapOf<String, List<com.rtbishop.look4sat.core.domain.model.GridQso>>()
|
|
||||||
for (grid in root.keys()) {
|
|
||||||
val array = root.optJSONArray(grid) ?: continue
|
|
||||||
val list = (0 until array.length()).mapNotNull { i ->
|
|
||||||
val o = array.optJSONObject(i) ?: return@mapNotNull null
|
|
||||||
com.rtbishop.look4sat.core.domain.model.GridQso(
|
|
||||||
call = o.optString("c"),
|
|
||||||
epochMs = o.optLong("t"),
|
|
||||||
satName = o.optString("s"),
|
|
||||||
mode = o.optString("m"),
|
|
||||||
bandUp = o.optString("bu"),
|
|
||||||
bandDown = o.optString("bd"),
|
|
||||||
// New award fields: absent in pre-award data -> null.
|
|
||||||
dxcc = o.optInt("dx", 0).takeIf { it > 0 },
|
|
||||||
country = o.optString("cty").ifBlank { null },
|
|
||||||
cqz = o.optInt("cq", 0).takeIf { it > 0 },
|
|
||||||
state = o.optString("st").ifBlank { null },
|
|
||||||
// Own-grid field: absent in pre-myGrid data -> null.
|
|
||||||
myGrid = o.optString("mg").ifBlank { null },
|
|
||||||
// Multi-grid 台址 fields (v4.4.7-ba7opf.16+): "mgs" is
|
|
||||||
// the full grid set (MY_GRIDSQUARE + MY_VUCC_GRIDS);
|
|
||||||
// pre-multi-grid data has only "mg" and falls back to a
|
|
||||||
// single-element set so every consumer sees myGrids.
|
|
||||||
myGrids = o.optJSONArray("mgs")?.let { arr ->
|
|
||||||
(0 until arr.length()).mapNotNull { i ->
|
|
||||||
arr.optString(i).takeIf { it.isNotBlank() }
|
|
||||||
}.toSet()
|
|
||||||
}?.takeIf { it.isNotEmpty() }
|
|
||||||
?: o.optString("mg").ifBlank { null }?.let { setOf(it) }
|
|
||||||
?: emptySet(),
|
|
||||||
myCallsign = o.optString("mc").ifBlank { null },
|
|
||||||
stationKey = o.optString("sk").ifBlank { null }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (list.isNotEmpty()) result[grid] = list
|
|
||||||
}
|
|
||||||
result
|
|
||||||
} catch (_: Exception) {
|
|
||||||
emptyMap()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun setWorkedGridQsos(qsos: Map<String, List<com.rtbishop.look4sat.core.domain.model.GridQso>>) {
|
|
||||||
val root = org.json.JSONObject()
|
|
||||||
for ((grid, list) in qsos) {
|
|
||||||
val array = org.json.JSONArray()
|
|
||||||
for (q in list) {
|
|
||||||
array.put(
|
|
||||||
org.json.JSONObject()
|
|
||||||
.put("c", q.call)
|
|
||||||
.put("t", q.epochMs)
|
|
||||||
.put("s", q.satName)
|
|
||||||
.put("m", q.mode)
|
|
||||||
.put("bu", q.bandUp)
|
|
||||||
.put("bd", q.bandDown)
|
|
||||||
.put("dx", q.dxcc ?: 0)
|
|
||||||
.put("cty", q.country ?: "")
|
|
||||||
.put("cq", q.cqz ?: 0)
|
|
||||||
.put("st", q.state ?: "")
|
|
||||||
.put("mg", q.myGrid ?: "")
|
|
||||||
.put("mgs", org.json.JSONArray(q.myGrids.sorted()))
|
|
||||||
.put("mc", q.myCallsign ?: "")
|
|
||||||
.put("sk", q.stationKey ?: "")
|
|
||||||
)
|
|
||||||
}
|
|
||||||
root.put(grid, array)
|
|
||||||
}
|
|
||||||
preferences.edit { putString(keyWorkedGridQsos, root.toString()) }
|
|
||||||
}
|
|
||||||
|
|
||||||
// Distinct 4-char gridsquares the account operated from (LoTW <MY_GRIDSQUARE>,
|
|
||||||
// satellite QSOs only). Stored as a JSONArray like workedGrids.
|
|
||||||
private val keyRoamedGrids = "roamedGrids"
|
|
||||||
|
|
||||||
override fun getRoamedGrids(): Set<String> {
|
|
||||||
val json = preferences.getString(keyRoamedGrids, null).orEmpty()
|
|
||||||
if (json.isBlank()) return emptySet()
|
|
||||||
return try {
|
|
||||||
val array = org.json.JSONArray(json)
|
|
||||||
(0 until array.length()).mapNotNull { i ->
|
|
||||||
array.optString(i).takeIf { it.isNotBlank() }
|
|
||||||
}.toSet()
|
|
||||||
} catch (_: Exception) {
|
|
||||||
emptySet()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun setRoamedGrids(grids: Set<String>) {
|
|
||||||
val array = org.json.JSONArray()
|
|
||||||
grids.sorted().forEach { array.put(it) }
|
|
||||||
preferences.edit { putString(keyRoamedGrids, array.toString()) }
|
|
||||||
}
|
|
||||||
|
|
||||||
// Marked stations in unworked gridsquares, persisted as a single JSON
|
|
||||||
// object: {"OL62":[{"c":call,"t":epochMs}, ...]}. Each grid holds an
|
|
||||||
// ordered list (first = pinned/top). v14.1 stored a single object per
|
|
||||||
// grid; reads transparently migrate that old shape to a one-element list.
|
|
||||||
private val keyMarkedGridStations = "markedGridStations"
|
|
||||||
|
|
||||||
override fun getMarkedGridStations(): Map<String, List<com.rtbishop.look4sat.core.domain.model.MarkedStation>> {
|
|
||||||
val json = preferences.getString(keyMarkedGridStations, null).orEmpty()
|
|
||||||
if (json.isBlank()) return emptyMap()
|
|
||||||
return try {
|
|
||||||
val root = org.json.JSONObject(json)
|
|
||||||
val result = mutableMapOf<String, List<com.rtbishop.look4sat.core.domain.model.MarkedStation>>()
|
|
||||||
for (grid in root.keys()) {
|
|
||||||
val value = root.opt(grid)
|
|
||||||
val list = when (value) {
|
|
||||||
// v14.2+ shape: JSON array of station objects.
|
|
||||||
is org.json.JSONArray -> (0 until value.length()).mapNotNull { i ->
|
|
||||||
val o = value.optJSONObject(i) ?: return@mapNotNull null
|
|
||||||
val call = o.optString("c").ifBlank { return@mapNotNull null }
|
|
||||||
com.rtbishop.look4sat.core.domain.model.MarkedStation(call = call, epochMs = o.optLong("t"))
|
|
||||||
}
|
|
||||||
// v14.1 legacy shape: a single station object per grid.
|
|
||||||
is org.json.JSONObject -> {
|
|
||||||
val call = value.optString("c").ifBlank { null }
|
|
||||||
if (call == null) emptyList()
|
|
||||||
else listOf(com.rtbishop.look4sat.core.domain.model.MarkedStation(call = call, epochMs = value.optLong("t")))
|
|
||||||
}
|
|
||||||
else -> emptyList()
|
|
||||||
}
|
|
||||||
if (list.isNotEmpty()) result[grid] = list
|
|
||||||
}
|
|
||||||
result
|
|
||||||
} catch (_: Exception) {
|
|
||||||
emptyMap()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun setMarkedGridStations(stations: Map<String, List<com.rtbishop.look4sat.core.domain.model.MarkedStation>>) {
|
|
||||||
val root = org.json.JSONObject()
|
|
||||||
for ((grid, list) in stations) {
|
|
||||||
val array = org.json.JSONArray()
|
|
||||||
for (station in list) {
|
|
||||||
array.put(
|
|
||||||
org.json.JSONObject()
|
|
||||||
.put("c", station.call)
|
|
||||||
.put("t", station.epochMs)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
root.put(grid, array)
|
|
||||||
}
|
|
||||||
preferences.edit { putString(keyMarkedGridStations, root.toString()) }
|
|
||||||
}
|
|
||||||
|
|
||||||
// LoTW credentials (stored locally on the device only)
|
|
||||||
private val keyLoTWCall = "lotwCallsign"
|
|
||||||
private val keyLoTWPass = "lotwPassword"
|
|
||||||
|
|
||||||
override val lotwSettings: kotlinx.coroutines.flow.StateFlow<com.rtbishop.look4sat.core.domain.model.LoTWSettings>
|
|
||||||
get() = _lotwSettings
|
|
||||||
private val _lotwSettings = MutableStateFlow(getLoTWSettings())
|
|
||||||
|
|
||||||
override fun updateLoTWSettings(settings: com.rtbishop.look4sat.core.domain.model.LoTWSettings) {
|
|
||||||
preferences.edit {
|
|
||||||
putString(keyLoTWCall, settings.callsign.trim().uppercase())
|
|
||||||
putString(keyLoTWPass, settings.password)
|
|
||||||
}
|
|
||||||
_lotwSettings.value = settings.copy(
|
|
||||||
callsign = settings.callsign.trim().uppercase(),
|
|
||||||
password = settings.password
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun getLoTWSettings(): com.rtbishop.look4sat.core.domain.model.LoTWSettings =
|
|
||||||
com.rtbishop.look4sat.core.domain.model.LoTWSettings(
|
|
||||||
callsign = preferences.getString(keyLoTWCall, null).orEmpty(),
|
|
||||||
password = preferences.getString(keyLoTWPass, null).orEmpty()
|
|
||||||
)
|
|
||||||
|
|
||||||
// Last successful sync bookkeeping: date ("yyyyMMdd") and callsign. Used to
|
|
||||||
// decide incremental (same callsign) vs full (first time / callsign change)
|
|
||||||
// report requests and to merge increments into the stored grid data.
|
|
||||||
private val keyLastLotwSyncDate = "lotwLastSyncDate"
|
|
||||||
private val keyLastLotwSyncCallsign = "lotwLastSyncCallsign"
|
|
||||||
|
|
||||||
override fun getLastLotwSyncDate(): String =
|
|
||||||
preferences.getString(keyLastLotwSyncDate, null).orEmpty()
|
|
||||||
|
|
||||||
override fun setLastLotwSyncDate(date: String) =
|
|
||||||
preferences.edit { putString(keyLastLotwSyncDate, date) }
|
|
||||||
|
|
||||||
override fun getLastLotwSyncCallsign(): String =
|
|
||||||
preferences.getString(keyLastLotwSyncCallsign, null).orEmpty()
|
|
||||||
|
|
||||||
override fun setLastLotwSyncCallsign(callsign: String) =
|
|
||||||
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 # Wavelog sync bookkeeping
|
|
||||||
private val keyWavelogStations = "wavelogStations"
|
|
||||||
private val keyWavelogSyncCursors = "wavelogSyncCursors"
|
|
||||||
private val keyWavelogSyncUrl = "wavelogSyncUrl"
|
|
||||||
private val keyLastWavelogSyncEpochMs = "wavelogLastSyncEpochMs"
|
|
||||||
|
|
||||||
override fun getWavelogStations(): List<com.rtbishop.look4sat.core.domain.repository.WavelogStationInfo> {
|
|
||||||
val json = preferences.getString(keyWavelogStations, null).orEmpty()
|
|
||||||
if (json.isBlank()) return emptyList()
|
|
||||||
return try {
|
|
||||||
val array = org.json.JSONArray(json)
|
|
||||||
(0 until array.length()).mapNotNull { i ->
|
|
||||||
val o = array.optJSONObject(i) ?: return@mapNotNull null
|
|
||||||
val id = o.optString("id").ifBlank { return@mapNotNull null }
|
|
||||||
com.rtbishop.look4sat.core.domain.repository.WavelogStationInfo(
|
|
||||||
id = id,
|
|
||||||
name = o.optString("name"),
|
|
||||||
callsign = o.optString("call"),
|
|
||||||
grid = o.optString("grid"),
|
|
||||||
active = o.optBoolean("active", true)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} catch (_: Exception) {
|
|
||||||
emptyList()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun setWavelogStations(stations: List<com.rtbishop.look4sat.core.domain.repository.WavelogStationInfo>) {
|
|
||||||
val array = org.json.JSONArray()
|
|
||||||
stations.forEach { station ->
|
|
||||||
array.put(
|
|
||||||
org.json.JSONObject()
|
|
||||||
.put("id", station.id)
|
|
||||||
.put("name", station.name)
|
|
||||||
.put("call", station.callsign)
|
|
||||||
.put("grid", station.grid)
|
|
||||||
.put("active", station.active)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
preferences.edit { putString(keyWavelogStations, array.toString()) }
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun getWavelogSyncCursors(): Map<String, Long> {
|
|
||||||
val json = preferences.getString(keyWavelogSyncCursors, null).orEmpty()
|
|
||||||
if (json.isBlank()) return emptyMap()
|
|
||||||
return try {
|
|
||||||
val root = org.json.JSONObject(json)
|
|
||||||
root.keys().asSequence().associateWith { key -> root.optLong(key, 0L) }
|
|
||||||
} catch (_: Exception) {
|
|
||||||
emptyMap()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun setWavelogSyncCursors(cursors: Map<String, Long>) {
|
|
||||||
val root = org.json.JSONObject()
|
|
||||||
cursors.forEach { (stationId, cursor) -> root.put(stationId, cursor) }
|
|
||||||
preferences.edit { putString(keyWavelogSyncCursors, root.toString()) }
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun getWavelogSyncUrl(): String =
|
|
||||||
preferences.getString(keyWavelogSyncUrl, null).orEmpty()
|
|
||||||
|
|
||||||
override fun setWavelogSyncUrl(url: String) =
|
|
||||||
preferences.edit { putString(keyWavelogSyncUrl, url) }
|
|
||||||
|
|
||||||
override fun getLastWavelogSyncEpochMs(): Long =
|
|
||||||
preferences.getLong(keyLastWavelogSyncEpochMs, 0L)
|
|
||||||
|
|
||||||
override fun setLastWavelogSyncEpochMs(value: Long) =
|
|
||||||
preferences.edit { putLong(keyLastWavelogSyncEpochMs, value) }
|
|
||||||
//endregion
|
|
||||||
|
|
||||||
//region # Transceivers settings
|
|
||||||
private val _passesSettings = MutableStateFlow(getPassesSettings())
|
private val _passesSettings = MutableStateFlow(getPassesSettings())
|
||||||
override val passesSettings: StateFlow<PassesSettings> = _passesSettings
|
override val passesSettings: StateFlow<PassesSettings> = _passesSettings
|
||||||
|
|
||||||
override fun setPassesSettings(settings: PassesSettings) = preferences.edit {
|
override fun setPassesSettings(settings: PassesSettings) = preferences.edit {
|
||||||
putBoolean(keyFilterShowDeepSpace, settings.showDeepSpace)
|
putBoolean(keyFilterShowDeepSpace, settings.showDeepSpace)
|
||||||
putInt(keyFilterHoursAhead, settings.hoursAhead)
|
putInt(keyFilterHoursAhead, settings.hoursAhead)
|
||||||
putInt(keyFilterHoursBefore, settings.hoursBefore)
|
|
||||||
putLong(keyFilterMinElevation, settings.minElevation.toRawBits())
|
putLong(keyFilterMinElevation, settings.minElevation.toRawBits())
|
||||||
putInt(keyFilterAosStartMinute, settings.aosStartMinute)
|
putInt(keyFilterAosStartMinute, settings.aosStartMinute)
|
||||||
putInt(keyFilterAosEndMinute, settings.aosEndMinute)
|
putInt(keyFilterAosEndMinute, settings.aosEndMinute)
|
||||||
putBoolean(keyFilterAosInvert, settings.invertAosTimeWindow)
|
putBoolean(keyFilterAosInvert, settings.invertAosTimeWindow)
|
||||||
putString(keySelectedModes, settings.selectedModes.joinToString(separatorComma))
|
|
||||||
_passesSettings.value = settings
|
_passesSettings.value = settings
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getPassesSettings(): PassesSettings {
|
private fun getPassesSettings(): PassesSettings {
|
||||||
val showDeepSpace = preferences.getBoolean(keyFilterShowDeepSpace, true)
|
val showDeepSpace = preferences.getBoolean(keyFilterShowDeepSpace, true)
|
||||||
val hoursAhead = preferences.getInt(keyFilterHoursAhead, 24)
|
val hoursAhead = preferences.getInt(keyFilterHoursAhead, 24)
|
||||||
val hoursBefore = preferences.getInt(keyFilterHoursBefore, 0).coerceIn(0, 240)
|
|
||||||
val minElevation = Double.fromBits(preferences.getLong(keyFilterMinElevation, 16.0.toRawBits()))
|
val minElevation = Double.fromBits(preferences.getLong(keyFilterMinElevation, 16.0.toRawBits()))
|
||||||
val aosStartMinute = preferences.getInt(keyFilterAosStartMinute, 0).coerceIn(0, 23 * 60 + 59)
|
val aosStartMinute = preferences.getInt(keyFilterAosStartMinute, 0).coerceIn(0, 23 * 60 + 59)
|
||||||
val aosEndMinute = preferences.getInt(keyFilterAosEndMinute, 23 * 60 + 59).coerceIn(0, 23 * 60 + 59)
|
val aosEndMinute = preferences.getInt(keyFilterAosEndMinute, 23 * 60 + 59).coerceIn(0, 23 * 60 + 59)
|
||||||
val invertAosTimeWindow = preferences.getBoolean(keyFilterAosInvert, false)
|
val invertAosTimeWindow = preferences.getBoolean(keyFilterAosInvert, false)
|
||||||
val selectedModesString = preferences.getString(keySelectedModes, null)
|
|
||||||
val selectedModes = selectedModesString?.split(separatorComma)?.sorted() ?: emptyList()
|
|
||||||
return PassesSettings(
|
return PassesSettings(
|
||||||
showDeepSpace,
|
showDeepSpace,
|
||||||
hoursAhead,
|
hoursAhead,
|
||||||
minElevation,
|
minElevation,
|
||||||
aosStartMinute,
|
aosStartMinute,
|
||||||
aosEndMinute,
|
aosEndMinute,
|
||||||
invertAosTimeWindow,
|
invertAosTimeWindow
|
||||||
selectedModes,
|
|
||||||
hoursBefore
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
//endregion
|
//endregion
|
||||||
@@ -557,34 +221,6 @@ class SettingsRepo(
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun getCurrentGrid(): String? {
|
|
||||||
val station = _stationPosition.value
|
|
||||||
val now = System.currentTimeMillis()
|
|
||||||
// Only trust a location fix that is both recent (≤ 24 h) and newer than the stored
|
|
||||||
// station, so a stale fix can never override a position the operator just set.
|
|
||||||
val fix = lastKnownGridFix()?.takeIf { now - it.second <= 24 * 3_600_000L }
|
|
||||||
val chosen = if (fix != null && fix.second >= station.timestamp) fix.first else station.qthLocator
|
|
||||||
return chosen.takeIf(String::isNotBlank)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Last known GPS/NETWORK fix converted to a locator; read-only, null without permission. */
|
|
||||||
private fun lastKnownGridFix(): Pair<String, Long>? {
|
|
||||||
return try {
|
|
||||||
val provider = when {
|
|
||||||
LocationManagerCompat.hasProvider(locationManager, providerGps) -> providerGps
|
|
||||||
LocationManagerCompat.hasProvider(locationManager, providerNet) -> providerNet
|
|
||||||
else -> return null
|
|
||||||
}
|
|
||||||
val location = locationManager.getLastKnownLocation(provider) ?: return null
|
|
||||||
val locator = positionToQth(location.latitude, location.longitude) ?: return null
|
|
||||||
locator to location.time
|
|
||||||
} catch (_: SecurityException) {
|
|
||||||
null
|
|
||||||
} catch (_: IllegalArgumentException) {
|
|
||||||
null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun getStationPosition(): GeoPos {
|
private fun getStationPosition(): GeoPos {
|
||||||
val latitude = (preferences.getString(keyStationLatitude, null) ?: "0.0").toDouble()
|
val latitude = (preferences.getString(keyStationLatitude, null) ?: "0.0").toDouble()
|
||||||
val longitude = (preferences.getString(keyStationLongitude, null) ?: "0.0").toDouble()
|
val longitude = (preferences.getString(keyStationLongitude, null) ?: "0.0").toDouble()
|
||||||
@@ -617,27 +253,6 @@ class SettingsRepo(
|
|||||||
private val _databaseState = MutableStateFlow(getDatabaseState())
|
private val _databaseState = MutableStateFlow(getDatabaseState())
|
||||||
override val databaseState: StateFlow<DatabaseState> = _databaseState
|
override val databaseState: StateFlow<DatabaseState> = _databaseState
|
||||||
|
|
||||||
override fun getSatelliteTypesIds(types: List<String>): List<Int> {
|
|
||||||
val idsSet = mutableSetOf<Int>()
|
|
||||||
types.forEach { type ->
|
|
||||||
val typeString = preferences.getString("type$type", null)
|
|
||||||
val typeIds = if (typeString.isNullOrBlank()) {
|
|
||||||
emptyList()
|
|
||||||
} else {
|
|
||||||
typeString.split(separatorComma).map { it.toInt() }
|
|
||||||
}
|
|
||||||
idsSet.addAll(typeIds)
|
|
||||||
}
|
|
||||||
return idsSet.toList()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun setSatelliteTypeIds(type: String, ids: List<Int>) {
|
|
||||||
// "All" is a real TLE source type (CelesTrak active group) whose ids
|
|
||||||
// must be persisted like any other type — skipping it made the "All"
|
|
||||||
// filter resolve to an empty set and show nothing.
|
|
||||||
val typesString = ids.joinToString(separatorComma)
|
|
||||||
preferences.edit { putString("type$type", typesString) }
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun updateDatabaseState(state: DatabaseState) = preferences.edit {
|
override fun updateDatabaseState(state: DatabaseState) = preferences.edit {
|
||||||
putInt(keyNumberOfSatellites, state.numberOfSatellites)
|
putInt(keyNumberOfSatellites, state.numberOfSatellites)
|
||||||
@@ -734,30 +349,21 @@ class SettingsRepo(
|
|||||||
|
|
||||||
override fun updateOtherSettings(transform: (OtherSettings) -> OtherSettings) {
|
override fun updateOtherSettings(transform: (OtherSettings) -> OtherSettings) {
|
||||||
_otherSettings.update { current ->
|
_otherSettings.update { current ->
|
||||||
val new = transform(current).let {
|
val new = transform(current)
|
||||||
it.copy(
|
|
||||||
mapSource = MapSource.normalize(it.mapSource),
|
|
||||||
tiandituKey = it.tiandituKey.trim()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
preferences.edit {
|
preferences.edit {
|
||||||
putBoolean(keyStateOfAutoUpdate, new.stateOfAutoUpdate)
|
putBoolean(keyStateOfAutoUpdate, new.stateOfAutoUpdate)
|
||||||
putBoolean(keyStateOfAutoLotwSync, new.stateOfAutoLotwSync)
|
|
||||||
putBoolean(keyStateOfSensors, new.stateOfSensors)
|
putBoolean(keyStateOfSensors, new.stateOfSensors)
|
||||||
putFloat(keyCompassOffsetDegrees, new.compassOffsetDegrees.coerceIn(-180f, 180f))
|
|
||||||
putBoolean(keyStateOfSweep, new.stateOfSweep)
|
putBoolean(keyStateOfSweep, new.stateOfSweep)
|
||||||
putBoolean(keyStateOfUtc, new.stateOfUtc)
|
putBoolean(keyStateOfUtc, new.stateOfUtc)
|
||||||
putBoolean(keyStateOfLightTheme, new.stateOfLightTheme)
|
putBoolean(keyStateOfLightTheme, new.stateOfLightTheme)
|
||||||
putBoolean(keyStateOfNightMode, new.stateOfNightMode)
|
putBoolean(keyStateOfNightMode, new.stateOfNightMode)
|
||||||
putBoolean(keyStateOfMapGrid, new.stateOfMapGrid)
|
|
||||||
putBoolean(keyStateOfMapFirstCall, new.stateOfMapFirstCall)
|
|
||||||
putBoolean(keyShouldSeeWarning, new.shouldSeeWarning)
|
putBoolean(keyShouldSeeWarning, new.shouldSeeWarning)
|
||||||
putBoolean(keyShouldSeeWhatsNew, new.shouldSeeWhatsNew)
|
putBoolean(keyShouldSeeWhatsNew, new.shouldSeeWhatsNew)
|
||||||
putString(keySstvMode, new.sstvMode)
|
putString(keySstvMode, new.sstvMode)
|
||||||
putLong(keyLowElevation, new.lowElevation.toRawBits())
|
putLong(keyLowElevation, new.lowElevation.toRawBits())
|
||||||
putLong(keyHighElevation, new.highElevation.toRawBits())
|
putLong(keyHighElevation, new.highElevation.toRawBits())
|
||||||
putString(keyMapSource, new.mapSource)
|
putFloat(keyRadarCompassOffset, new.radarCompassOffset)
|
||||||
putString(keyTiandituKey, new.tiandituKey)
|
putFloat(keyRadarCompassOffsetElev, new.radarCompassOffsetElev)
|
||||||
}
|
}
|
||||||
new
|
new
|
||||||
}
|
}
|
||||||
@@ -765,22 +371,18 @@ class SettingsRepo(
|
|||||||
|
|
||||||
private fun getOtherSettings(): OtherSettings = OtherSettings(
|
private fun getOtherSettings(): OtherSettings = OtherSettings(
|
||||||
stateOfAutoUpdate = preferences.getBoolean(keyStateOfAutoUpdate, true),
|
stateOfAutoUpdate = preferences.getBoolean(keyStateOfAutoUpdate, true),
|
||||||
stateOfAutoLotwSync = preferences.getBoolean(keyStateOfAutoLotwSync, true),
|
|
||||||
stateOfSensors = preferences.getBoolean(keyStateOfSensors, true),
|
stateOfSensors = preferences.getBoolean(keyStateOfSensors, true),
|
||||||
compassOffsetDegrees = preferences.getFloat(keyCompassOffsetDegrees, 0f).coerceIn(-180f, 180f),
|
|
||||||
stateOfSweep = preferences.getBoolean(keyStateOfSweep, true),
|
stateOfSweep = preferences.getBoolean(keyStateOfSweep, true),
|
||||||
stateOfUtc = preferences.getBoolean(keyStateOfUtc, false),
|
stateOfUtc = preferences.getBoolean(keyStateOfUtc, false),
|
||||||
stateOfLightTheme = preferences.getBoolean(keyStateOfLightTheme, false),
|
stateOfLightTheme = preferences.getBoolean(keyStateOfLightTheme, false),
|
||||||
stateOfNightMode = preferences.getBoolean(keyStateOfNightMode, false),
|
stateOfNightMode = preferences.getBoolean(keyStateOfNightMode, false),
|
||||||
stateOfMapGrid = preferences.getBoolean(keyStateOfMapGrid, false),
|
|
||||||
stateOfMapFirstCall = preferences.getBoolean(keyStateOfMapFirstCall, false),
|
|
||||||
shouldSeeWarning = preferences.getBoolean(keyShouldSeeWarning, true),
|
shouldSeeWarning = preferences.getBoolean(keyShouldSeeWarning, true),
|
||||||
shouldSeeWhatsNew = preferences.getBoolean(keyShouldSeeWhatsNew, true),
|
shouldSeeWhatsNew = preferences.getBoolean(keyShouldSeeWhatsNew, true),
|
||||||
sstvMode = preferences.getString(keySstvMode, null) ?: "Auto",
|
sstvMode = preferences.getString(keySstvMode, null) ?: "Auto",
|
||||||
lowElevation = Double.fromBits(preferences.getLong(keyLowElevation, 15.0.toRawBits())),
|
lowElevation = Double.fromBits(preferences.getLong(keyLowElevation, 15.0.toRawBits())),
|
||||||
highElevation = Double.fromBits(preferences.getLong(keyHighElevation, 45.0.toRawBits())),
|
highElevation = Double.fromBits(preferences.getLong(keyHighElevation, 45.0.toRawBits())),
|
||||||
mapSource = MapSource.normalize(preferences.getString(keyMapSource, null).orEmpty()),
|
radarCompassOffset = preferences.getFloat(keyRadarCompassOffset, 0f),
|
||||||
tiandituKey = preferences.getString(keyTiandituKey, null).orEmpty().trim()
|
radarCompassOffsetElev = preferences.getFloat(keyRadarCompassOffsetElev, 0f)
|
||||||
)
|
)
|
||||||
//endregion
|
//endregion
|
||||||
|
|
||||||
@@ -797,114 +399,56 @@ class SettingsRepo(
|
|||||||
transceiversEnabled = alignFlags(settings.transceiversUrls, settings.transceiversEnabled)
|
transceiversEnabled = alignFlags(settings.transceiversUrls, settings.transceiversEnabled)
|
||||||
)
|
)
|
||||||
preferences.edit {
|
preferences.edit {
|
||||||
putString(keySatelliteUrls, normalized.satelliteUrls.joinToString(separatorUrl))
|
putString(keySatellitesUrls, normalized.satelliteUrls.joinToString(separatorUrl))
|
||||||
putString(keyTransceiversUrls, normalized.transceiversUrls.joinToString(separatorUrl))
|
putString(keyTransceiversUrls, normalized.transceiversUrls.joinToString(separatorUrl))
|
||||||
putString(keySatelliteEnabled, normalized.satelliteEnabled.joinToString(separatorComma))
|
putString(keySatellitesUrlsEnabled, normalized.satelliteEnabled.joinToString(separatorComma))
|
||||||
putString(keyTransceiversEnabled, normalized.transceiversEnabled.joinToString(separatorComma))
|
putString(keyTransceiversUrlsEnabled, normalized.transceiversEnabled.joinToString(separatorComma))
|
||||||
putBoolean(keySatnogsTleSourceMigration, true)
|
|
||||||
putBoolean(keyAutoTleSourceMigration, true)
|
|
||||||
}
|
}
|
||||||
_dataSourcesSettings.value = normalized
|
_dataSourcesSettings.value = normalized
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getDataSourcesSettings(): DataSourcesSettings = DataSourcesSettings(
|
private fun getDataSourcesSettings(): DataSourcesSettings {
|
||||||
satelliteUrls = getDataSourceUrls(
|
val (satUrls, satEnabled) = parseSources(
|
||||||
key = keySatelliteUrls,
|
preferences.getString(keySatellitesUrls, null),
|
||||||
defaultUrls = Sources.satelliteDataUrls.values.filter { it.isNotBlank() },
|
preferences.getString(keySatellitesUrlsEnabled, null),
|
||||||
legacyEnabledKey = keyUseCustomTle,
|
Sources.satelliteDataUrls
|
||||||
legacyUrlKey = keyTleUrl
|
)
|
||||||
).migrateSatnogsTleSource().migrateAutoTleSources(),
|
val (txUrls, txEnabled) = parseSources(
|
||||||
transceiversUrls = getDataSourceUrls(
|
preferences.getString(keyTransceiversUrls, null),
|
||||||
key = keyTransceiversUrls,
|
preferences.getString(keyTransceiversUrlsEnabled, null),
|
||||||
defaultUrls = Sources.transceiversDataUrls.values.filter { it.isNotBlank() },
|
Sources.transceiversDataUrls
|
||||||
legacyEnabledKey = keyUseCustomTransceivers,
|
)
|
||||||
legacyUrlKey = keyTransceiversUrl
|
return DataSourcesSettings(
|
||||||
),
|
satelliteUrls = satUrls,
|
||||||
satelliteEnabled = readEnabledFlags(keySatelliteEnabled),
|
transceiversUrls = txUrls,
|
||||||
transceiversEnabled = readEnabledFlags(keyTransceiversEnabled)
|
satelliteEnabled = satEnabled,
|
||||||
)
|
transceiversEnabled = txEnabled
|
||||||
|
)
|
||||||
/** Read the persisted per-source enabled flags (empty when never stored). */
|
}
|
||||||
private fun readEnabledFlags(key: String): List<Boolean> {
|
|
||||||
return preferences.getString(key, null)
|
private fun parseSources(
|
||||||
?.split(separatorComma)
|
storedUrls: String?,
|
||||||
?.mapNotNull { it.trim() }
|
storedEnabled: String?,
|
||||||
?.filter { it == "true" || it == "false" }
|
defaults: List<String>
|
||||||
?.map { it == "true" }
|
): Pair<List<String>, List<Boolean>> {
|
||||||
?: emptyList()
|
if (storedUrls == null) return defaults to defaults.map { true }
|
||||||
|
val urls = storedUrls.split(separatorUrl)
|
||||||
|
val flags = if (storedEnabled.isNullOrEmpty()) emptyList() else storedEnabled.split(separatorComma)
|
||||||
|
val filteredUrls = mutableListOf<String>()
|
||||||
|
val filteredFlags = mutableListOf<Boolean>()
|
||||||
|
urls.forEachIndexed { index, url ->
|
||||||
|
if (url.isNotBlank()) {
|
||||||
|
filteredUrls.add(url)
|
||||||
|
filteredFlags.add(flags.getOrNull(index)?.toBoolean() ?: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return filteredUrls to filteredFlags
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Keep the flags positionally aligned with the URL list, defaulting to enabled. */
|
|
||||||
private fun alignFlags(urls: List<String>, flags: List<Boolean>): List<Boolean> {
|
private fun alignFlags(urls: List<String>, flags: List<Boolean>): List<Boolean> {
|
||||||
if (flags.size >= urls.size) return flags.take(urls.size)
|
if (flags.size >= urls.size) return flags.take(urls.size)
|
||||||
return flags + List(urls.size - flags.size) { true }
|
return flags + List(urls.size - flags.size) { true }
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getDataSourceUrls(
|
|
||||||
key: String,
|
|
||||||
defaultUrls: List<String>,
|
|
||||||
legacyEnabledKey: String,
|
|
||||||
legacyUrlKey: String
|
|
||||||
): List<String> {
|
|
||||||
val stored = preferences.getString(key, null)
|
|
||||||
?.split(separatorUrl)
|
|
||||||
?.map { it.trim() }
|
|
||||||
?.filter { it.isNotBlank() }
|
|
||||||
if (stored != null) return stored
|
|
||||||
val legacyCustomUrl = preferences.getString(legacyUrlKey, null)?.trim().orEmpty()
|
|
||||||
return if (preferences.getBoolean(legacyEnabledKey, false) && legacyCustomUrl.isNotBlank()) {
|
|
||||||
(defaultUrls + legacyCustomUrl).distinct()
|
|
||||||
} else {
|
|
||||||
defaultUrls
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun List<String>.migrateSatnogsTleSource(): List<String> {
|
|
||||||
if (preferences.getBoolean(keySatnogsTleSourceMigration, false)) return this
|
|
||||||
val satnogsTleUrl = Sources.satelliteDataUrls["SatNOGS"].orEmpty()
|
|
||||||
if (satnogsTleUrl.isBlank() || containsSourceUrl(satnogsTleUrl)) return this
|
|
||||||
val celestrakSatnogsIndex = indexOfFirst { isSameSourceUrl(it, legacyCelestrakSatnogsUrl) }
|
|
||||||
if (celestrakSatnogsIndex < 0) return this
|
|
||||||
|
|
||||||
val migrated = toMutableList().apply { add(celestrakSatnogsIndex + 1, satnogsTleUrl) }
|
|
||||||
preferences.edit {
|
|
||||||
putString(keySatelliteUrls, migrated.joinToString(separatorUrl))
|
|
||||||
putBoolean(keySatnogsTleSourceMigration, true)
|
|
||||||
}
|
|
||||||
return migrated
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun List<String>.migrateAutoTleSources(): List<String> {
|
|
||||||
if (preferences.getBoolean(keyAutoTleSourceMigration, false)) return this
|
|
||||||
val autoTleUrls = listOfNotNull(
|
|
||||||
Sources.satelliteDataUrls["BI4PYM AutoTLE (GitHub)"],
|
|
||||||
Sources.satelliteDataUrls["BI4PYM AutoTLE (Mirror)"]
|
|
||||||
).filter { it.isNotBlank() }
|
|
||||||
val missingUrls = autoTleUrls.filterNot { containsSourceUrl(it) }
|
|
||||||
if (missingUrls.isEmpty()) {
|
|
||||||
preferences.edit { putBoolean(keyAutoTleSourceMigration, true) }
|
|
||||||
return this
|
|
||||||
}
|
|
||||||
|
|
||||||
val migrated = this + missingUrls
|
|
||||||
val enabled = alignFlags(migrated, readEnabledFlags(keySatelliteEnabled))
|
|
||||||
preferences.edit {
|
|
||||||
putString(keySatelliteUrls, migrated.joinToString(separatorUrl))
|
|
||||||
putString(keySatelliteEnabled, enabled.joinToString(separatorComma))
|
|
||||||
putBoolean(keyAutoTleSourceMigration, true)
|
|
||||||
}
|
|
||||||
return migrated
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun List<String>.containsSourceUrl(url: String): Boolean = any { isSameSourceUrl(it, url) }
|
|
||||||
|
|
||||||
private fun isSameSourceUrl(first: String, second: String): Boolean =
|
|
||||||
normalizeSourceUrl(first).equals(normalizeSourceUrl(second), ignoreCase = true)
|
|
||||||
|
|
||||||
private fun normalizeSourceUrl(url: String): String {
|
|
||||||
val trimmed = url.trim()
|
|
||||||
return if (trimmed.startsWith("http", ignoreCase = true)) trimmed else "https://$trimmed"
|
|
||||||
}
|
|
||||||
//endregion
|
//endregion
|
||||||
|
|
||||||
//region # Data sources status
|
//region # Data sources status
|
||||||
@@ -953,29 +497,16 @@ class SettingsRepo(
|
|||||||
baudRate = preferences.getInt(keyRadioBaudRate, 4800),
|
baudRate = preferences.getInt(keyRadioBaudRate, 4800),
|
||||||
splitMode = preferences.getBoolean(keyRadioSplitMode, false)
|
splitMode = preferences.getBoolean(keyRadioSplitMode, false)
|
||||||
)
|
)
|
||||||
//endregion
|
|
||||||
|
|
||||||
//region # Per-satellite calculator offset settings
|
|
||||||
private val keySatelliteOffsets = "satelliteOffsets"
|
private val keySatelliteOffsets = "satelliteOffsets"
|
||||||
private val keyLegacySatelliteOffsetPrefix = "offset_khz_"
|
|
||||||
|
|
||||||
override fun getSatelliteOffset(catnum: Int): String {
|
override fun getSatelliteOffset(catnum: Int): String {
|
||||||
val json = preferences.getString(keySatelliteOffsets, "{}") ?: "{}"
|
val json = preferences.getString(keySatelliteOffsets, "{}") ?: "{}"
|
||||||
val stored = try {
|
return try {
|
||||||
JSONObject(json).optString(catnum.toString(), "")
|
JSONObject(json).optString(catnum.toString(), "")
|
||||||
} catch (_: Exception) {
|
} catch (_: Exception) {
|
||||||
""
|
""
|
||||||
}
|
}
|
||||||
if (stored.isNotEmpty()) return stored
|
|
||||||
|
|
||||||
// Lazy migration from the fork's old UI-layer implementation, which stored
|
|
||||||
// one SharedPreferences entry per satellite directly from TransceiversPage.
|
|
||||||
val legacyKey = "$keyLegacySatelliteOffsetPrefix$catnum"
|
|
||||||
val legacy = preferences.getString(legacyKey, "").orEmpty()
|
|
||||||
if (legacy.isNotEmpty()) {
|
|
||||||
setSatelliteOffset(catnum, legacy)
|
|
||||||
}
|
|
||||||
return legacy
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun setSatelliteOffset(catnum: Int, offset: String) {
|
override fun setSatelliteOffset(catnum: Int, offset: String) {
|
||||||
@@ -985,39 +516,10 @@ class SettingsRepo(
|
|||||||
if (offset.isEmpty()) obj.remove(catnum.toString()) else obj.put(catnum.toString(), offset)
|
if (offset.isEmpty()) obj.remove(catnum.toString()) else obj.put(catnum.toString(), offset)
|
||||||
obj.toString()
|
obj.toString()
|
||||||
} catch (_: Exception) {
|
} catch (_: Exception) {
|
||||||
if (offset.isEmpty()) "{}" else """{"$catnum": "$offset"}"""
|
"""{"$catnum": "$offset"}"""
|
||||||
}
|
|
||||||
preferences.edit {
|
|
||||||
putString(keySatelliteOffsets, updated)
|
|
||||||
remove("$keyLegacySatelliteOffsetPrefix$catnum")
|
|
||||||
}
|
}
|
||||||
|
preferences.edit { putString(keySatelliteOffsets, updated) }
|
||||||
}
|
}
|
||||||
//endregion
|
|
||||||
|
|
||||||
//region # Per-satellite logbook mode preset settings
|
|
||||||
private val keySatelliteModes = "satelliteModes"
|
|
||||||
|
|
||||||
override fun getSatelliteMode(catnum: Int): String {
|
|
||||||
val json = preferences.getString(keySatelliteModes, "{}") ?: "{}"
|
|
||||||
return try {
|
|
||||||
JSONObject(json).optString(catnum.toString(), "")
|
|
||||||
} catch (_: Exception) {
|
|
||||||
""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun setSatelliteMode(catnum: Int, mode: String) {
|
|
||||||
val json = preferences.getString(keySatelliteModes, "{}") ?: "{}"
|
|
||||||
val updated = try {
|
|
||||||
val obj = JSONObject(json)
|
|
||||||
if (mode.isBlank()) obj.remove(catnum.toString()) else obj.put(catnum.toString(), mode.uppercase())
|
|
||||||
obj.toString()
|
|
||||||
} catch (_: Exception) {
|
|
||||||
if (mode.isBlank()) "{}" else """{"$catnum": "${mode.uppercase()}"}"""
|
|
||||||
}
|
|
||||||
preferences.edit { putString(keySatelliteModes, updated) }
|
|
||||||
}
|
|
||||||
//endregion
|
|
||||||
|
|
||||||
//region # AMSAT status report settings
|
//region # AMSAT status report settings
|
||||||
private val keyAmSatCallsign = "amSatCallsign"
|
private val keyAmSatCallsign = "amSatCallsign"
|
||||||
|
|||||||
-124
@@ -1,124 +0,0 @@
|
|||||||
/*
|
|
||||||
* 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
|
|
||||||
import com.rtbishop.look4sat.core.domain.repository.IUpdateRepository
|
|
||||||
import com.rtbishop.look4sat.core.domain.source.IRemoteSource
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.withContext
|
|
||||||
import java.io.File
|
|
||||||
|
|
||||||
class UpdateRepository(
|
|
||||||
private val remoteSource: IRemoteSource
|
|
||||||
) : IUpdateRepository {
|
|
||||||
|
|
||||||
override suspend fun getLatestRelease(): LatestRelease? = withContext(Dispatchers.IO) {
|
|
||||||
// Use the GitHub releases web page instead of the REST API endpoint:
|
|
||||||
// the API endpoint is rate-limited to 60 requests/hour per IP, which is
|
|
||||||
// quickly exhausted on shared egress IPs (e.g. VPN proxies), causing 403
|
|
||||||
// failures. The web endpoint redirects to the latest tag with no such limit.
|
|
||||||
// When GitHub is unreachable (common on mainland-China networks without a
|
|
||||||
// proxy), fall back to community GitHub accelerator mirrors that proxy the
|
|
||||||
// same page; each mirror resolves the identical tag and asset URLs.
|
|
||||||
for (baseUrl in LATEST_RELEASE_URLS) {
|
|
||||||
val result = remoteSource.getNetworkStream(baseUrl)
|
|
||||||
val stream = result.stream ?: continue
|
|
||||||
val parsed = try {
|
|
||||||
parseRelease(stream.bufferedReader().use { it.readText() }, baseUrl)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
println("UpdateRepository parse failure: $e")
|
|
||||||
null
|
|
||||||
}
|
|
||||||
if (parsed != null) return@withContext parsed
|
|
||||||
}
|
|
||||||
null
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun downloadApk(url: String, dest: File): Boolean = withContext(Dispatchers.IO) {
|
|
||||||
if (url.isBlank()) return@withContext false
|
|
||||||
val result = remoteSource.getNetworkStream(url)
|
|
||||||
val stream = result.stream ?: return@withContext false
|
|
||||||
try {
|
|
||||||
dest.outputStream().use { out -> stream.use { it.copyTo(out) } }
|
|
||||||
true
|
|
||||||
} catch (e: Exception) {
|
|
||||||
println("UpdateRepository download failure: $e")
|
|
||||||
false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun parseRelease(html: String, sourceUrl: String): LatestRelease? {
|
|
||||||
// The redirect target is the latest release's tag page. Extract the tag
|
|
||||||
// from the og:url meta tag ("…/releases/tag/v4.4.6-ba7opf.8") — stable and
|
|
||||||
// unambiguous. The <title> also holds the release name, but that is free
|
|
||||||
// text and cannot be used to build the asset URL.
|
|
||||||
val ogUrl = Regex("<meta\\s+property=\"og:url\"\\s+content=\"([^\"]*)\"").find(html)
|
|
||||||
?.groupValues?.get(1) ?: return null
|
|
||||||
val tag = Regex("/releases/tag/(v[0-9][0-9A-Za-z.\\-]*)$").find(ogUrl)
|
|
||||||
?.groupValues?.get(1) ?: return null
|
|
||||||
val title = Regex("<title>(.*?)</title>", RegexOption.DOT_MATCHES_ALL)
|
|
||||||
.find(html)?.groupValues?.get(1)?.trim()
|
|
||||||
?.substringBefore("·")?.removePrefix("Release")?.trim() ?: tag
|
|
||||||
// Release notes live in the page's markdown-body section (first occurrence
|
|
||||||
// is the release description). Strip HTML tags for plain-text display.
|
|
||||||
val raw = Regex("<div[^>]*class=\"[^\"]*markdown-body[^\"]*\"[^>]*>(.*?)</div>", RegexOption.DOT_MATCHES_ALL)
|
|
||||||
.find(html)?.groupValues?.get(1) ?: ""
|
|
||||||
val body = raw.replace(Regex("<[^>]+>"), "")
|
|
||||||
.replace("<", "<").replace(">", ">").replace("&", "&")
|
|
||||||
.replace(""", "\"").replace("'", "'")
|
|
||||||
.trim()
|
|
||||||
// The release APK asset naming: Look4Sat-<tag without leading v>.apk
|
|
||||||
// (since v4.4.6-ba7opf.9.9 the releases are uploaded with the bare
|
|
||||||
// versioned filename, no "-release" suffix). When the page was fetched
|
|
||||||
// through an accelerator mirror (https://<mirror>/https://github.com/...),
|
|
||||||
// download through the same mirror — raw github.com is unreachable on the
|
|
||||||
// networks that needed the mirror in the first place.
|
|
||||||
val mirrorPrefix = sourceUrl.substringBefore("https://github.com")
|
|
||||||
val apkName = "Look4Sat-${tag.removePrefix("v")}.apk"
|
|
||||||
val apkUrl = if (mirrorPrefix.isEmpty()) {
|
|
||||||
"$DOWNLOAD_BASE_URL/$tag/$apkName"
|
|
||||||
} else {
|
|
||||||
"${mirrorPrefix}https://github.com/atsunatsu/Look4Sat/releases/download/$tag/$apkName"
|
|
||||||
}
|
|
||||||
return LatestRelease(
|
|
||||||
versionTag = tag,
|
|
||||||
title = title,
|
|
||||||
body = body,
|
|
||||||
apkUrl = apkUrl
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private companion object {
|
|
||||||
// Web pages are used instead of api.github.com to avoid the 60 req/hour
|
|
||||||
// anonymous rate limit (see getLatestRelease above). The GitHub URL is
|
|
||||||
// tried first; if it is unreachable (no proxy on mainland networks) the
|
|
||||||
// accelerator mirrors are tried in order. Download URLs always point at
|
|
||||||
// the mirrors too — raw github.com release downloads are equally blocked
|
|
||||||
// without a proxy, so a mirror-resolved tag must be downloaded via the
|
|
||||||
// same mirror.
|
|
||||||
val LATEST_RELEASE_URLS = listOf(
|
|
||||||
"https://github.com/atsunatsu/Look4Sat/releases/latest",
|
|
||||||
"https://ghfast.top/https://github.com/atsunatsu/Look4Sat/releases/latest",
|
|
||||||
"https://gh.llkk.cc/https://github.com/atsunatsu/Look4Sat/releases/latest",
|
|
||||||
"https://github.moeyy.xyz/https://github.com/atsunatsu/Look4Sat/releases/latest"
|
|
||||||
)
|
|
||||||
const val DOWNLOAD_BASE_URL = "https://github.com/atsunatsu/Look4Sat/releases/download"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-142
@@ -1,142 +0,0 @@
|
|||||||
/*
|
|
||||||
* Look4Sat-BA7OPF. Amateur radio satellite tracker and pass predictor.
|
|
||||||
* Copyright (C) 2026 BA7OPF.
|
|
||||||
* Based on Look4Sat by Arty Bishop and contributors.
|
|
||||||
*
|
|
||||||
* This program is free software: you can redistribute it and/or modify
|
|
||||||
* it under the terms of the GNU General Public License as published by
|
|
||||||
* the Free Software Foundation, either version 3 of the License, or
|
|
||||||
* (at your option) any later version.
|
|
||||||
*
|
|
||||||
* This program is distributed in the hope that it will be useful,
|
|
||||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
* GNU General Public License for more details.
|
|
||||||
*
|
|
||||||
* You should have received a copy of the GNU General Public License
|
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
*/
|
|
||||||
package com.rtbishop.look4sat.core.data.repository
|
|
||||||
|
|
||||||
import com.rtbishop.look4sat.core.domain.logbook.AdifCodec
|
|
||||||
import com.rtbishop.look4sat.core.domain.logbook.QsoRecord
|
|
||||||
import com.rtbishop.look4sat.core.domain.repository.IWavelogSyncRepository
|
|
||||||
import com.rtbishop.look4sat.core.domain.repository.WavelogStationInfo
|
|
||||||
import com.rtbishop.look4sat.core.domain.repository.WavelogSyncFetch
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.withContext
|
|
||||||
import okhttp3.MediaType.Companion.toMediaType
|
|
||||||
import okhttp3.OkHttpClient
|
|
||||||
import okhttp3.Request
|
|
||||||
import okhttp3.RequestBody.Companion.toRequestBody
|
|
||||||
import org.json.JSONArray
|
|
||||||
import org.json.JSONObject
|
|
||||||
import java.util.concurrent.CancellationException
|
|
||||||
import java.util.concurrent.TimeUnit
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Pulls QSO records from a self-hosted Wavelog via the v1 API.
|
|
||||||
*
|
|
||||||
* Verified against Wavelog 3.0.2 (`Api.php::get_contacts_adif`):
|
|
||||||
* - `POST {base}/index.php/api/get_contacts_adif`
|
|
||||||
* with `{"key", "station_id": [id...], "fetchfromid": <last id>, "limit"}`.
|
|
||||||
* - Responds `{"status":"successful","lastfetchedid":<id>,"exported_qsos":N,"adif":"..."}`;
|
|
||||||
* only rows whose internal id is greater than `fetchfromid` are exported, so the cursor
|
|
||||||
* makes the pull incremental. Each station keeps its own cursor: a station profile added
|
|
||||||
* later starts from 0 and pulls its full history without touching the others'.
|
|
||||||
* - The export is a full ADIF document; decoding reuses [AdifCodec].
|
|
||||||
*/
|
|
||||||
class WavelogSyncRepository internal constructor(
|
|
||||||
client: OkHttpClient = OkHttpClient()
|
|
||||||
) : IWavelogSyncRepository {
|
|
||||||
|
|
||||||
private val client = client.newBuilder()
|
|
||||||
.connectTimeout(15, TimeUnit.SECONDS)
|
|
||||||
.readTimeout(120, TimeUnit.SECONDS)
|
|
||||||
.callTimeout(180, TimeUnit.SECONDS)
|
|
||||||
.retryOnConnectionFailure(false)
|
|
||||||
.build()
|
|
||||||
|
|
||||||
private val jsonType = "application/json; charset=utf-8".toMediaType()
|
|
||||||
|
|
||||||
override suspend fun fetchNewRecords(
|
|
||||||
url: String,
|
|
||||||
apiKey: String,
|
|
||||||
stations: List<WavelogStationInfo>,
|
|
||||||
cursors: Map<String, Long>,
|
|
||||||
full: Boolean
|
|
||||||
): WavelogSyncFetch? = withContext(Dispatchers.IO) {
|
|
||||||
val base = url.trim().trimEnd('/')
|
|
||||||
val key = apiKey.trim()
|
|
||||||
if (base.isBlank() || key.isBlank() || stations.isEmpty()) return@withContext null
|
|
||||||
val records = mutableListOf<QsoRecord>()
|
|
||||||
val newCursors = mutableMapOf<String, Long>()
|
|
||||||
val failed = mutableListOf<String>()
|
|
||||||
// Every station profile is fetched (including ones the server marks inactive:
|
|
||||||
// their historical QSOs still belong to the account and the logbook shows them).
|
|
||||||
stations.forEach { station ->
|
|
||||||
var cursor = if (full) 0L else cursors[station.id] ?: 0L
|
|
||||||
var advanced = false
|
|
||||||
var stationFailed = false
|
|
||||||
try {
|
|
||||||
var guard = 0
|
|
||||||
while (guard++ < MAX_CHUNKS_PER_STATION) {
|
|
||||||
val page = fetchPage(base, key, station.id, cursor)
|
|
||||||
if (page == null) {
|
|
||||||
stationFailed = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
cursor = maxOf(cursor, page.cursor)
|
|
||||||
advanced = true
|
|
||||||
page.records.forEach { records += it.copy(wavelogStation = station.id) }
|
|
||||||
if (page.exported < CHUNK_LIMIT) break
|
|
||||||
}
|
|
||||||
} catch (e: CancellationException) {
|
|
||||||
throw e
|
|
||||||
} catch (_: Exception) {
|
|
||||||
stationFailed = true
|
|
||||||
}
|
|
||||||
// A partially fetched station keeps the cursor of its last good page, so the
|
|
||||||
// next sync resumes exactly where this one stopped — nothing is fetched twice
|
|
||||||
// and nothing is skipped.
|
|
||||||
if (advanced) newCursors[station.id] = cursor
|
|
||||||
if (stationFailed) failed += station.id
|
|
||||||
}
|
|
||||||
WavelogSyncFetch(records, newCursors, failed)
|
|
||||||
}
|
|
||||||
|
|
||||||
internal data class Page(val records: List<QsoRecord>, val cursor: Long, val exported: Int)
|
|
||||||
|
|
||||||
private fun fetchPage(base: String, key: String, stationId: String, fromId: Long): Page? {
|
|
||||||
val body = JSONObject().apply {
|
|
||||||
put("key", key)
|
|
||||||
put("station_id", JSONArray().put(stationId))
|
|
||||||
put("fetchfromid", fromId)
|
|
||||||
put("limit", CHUNK_LIMIT)
|
|
||||||
}.toString()
|
|
||||||
val request = Request.Builder()
|
|
||||||
.url("$base/index.php/api/get_contacts_adif")
|
|
||||||
.post(body.toRequestBody(jsonType))
|
|
||||||
.build()
|
|
||||||
return client.newCall(request).execute().use { response ->
|
|
||||||
if (!response.isSuccessful) return@use null
|
|
||||||
parsePage(response.body.string())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
internal fun parsePage(text: String): Page? {
|
|
||||||
val json = runCatching { JSONObject(text) }.getOrNull() ?: return null
|
|
||||||
if (json.optString("status") != "successful") return null
|
|
||||||
if (!json.has("lastfetchedid")) return null
|
|
||||||
val cursor = json.optLong("lastfetchedid", 0L)
|
|
||||||
val exported = json.optInt("exported_qsos", 0)
|
|
||||||
val adif = json.optString("adif")
|
|
||||||
if (adif.isBlank()) return Page(emptyList(), cursor, exported)
|
|
||||||
return Page(AdifCodec.decode(adif), cursor, exported)
|
|
||||||
}
|
|
||||||
|
|
||||||
private companion object {
|
|
||||||
const val CHUNK_LIMIT = 5000
|
|
||||||
const val MAX_CHUNKS_PER_STATION = 200
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-327
@@ -1,327 +0,0 @@
|
|||||||
/*
|
|
||||||
* 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(""", "\"").replace("'", "'").replace("&", "&")
|
|
||||||
.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))
|
|
||||||
@@ -58,8 +58,6 @@ class LocalSource(private val look4SatDao: Look4SatDao) : ILocalSource {
|
|||||||
override suspend fun deleteEntries() = look4SatDao.deleteEntries()
|
override suspend fun deleteEntries() = look4SatDao.deleteEntries()
|
||||||
|
|
||||||
override suspend fun getIdsWithModes(modes: List<String>) = look4SatDao.getIdsWithModes(modes)
|
override suspend fun getIdsWithModes(modes: List<String>) = look4SatDao.getIdsWithModes(modes)
|
||||||
override suspend fun getIdsWithModesAndUplink(modes: List<String>) = look4SatDao.getIdsWithModesAndUplink(modes)
|
|
||||||
override suspend fun getIdsWithModesAndAmateur(modes: List<String>) = look4SatDao.getIdsWithModesAndAmateur(modes)
|
|
||||||
|
|
||||||
private fun FrameworkEntry.toDomain() = OrbitalData(
|
private fun FrameworkEntry.toDomain() = OrbitalData(
|
||||||
this.name, this.epoch, this.meanmo, this.eccn, this.incl,
|
this.name, this.epoch, this.meanmo, this.eccn, this.incl,
|
||||||
@@ -86,27 +84,23 @@ class LocalSource(private val look4SatDao: Look4SatDao) : ILocalSource {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun insertRadios(radios: List<SatRadio>, isCustom: Boolean) {
|
override suspend fun insertRadios(radios: List<SatRadio>, isCustom: Boolean) {
|
||||||
look4SatDao.insertRadios(radios.map { it.copy(isCustom = isCustom) }.toFrameworkRadios())
|
look4SatDao.insertRadios(radios.map { radio -> radio.toFramework(isCustom) })
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun deleteManagedRadios() = look4SatDao.deleteManagedRadios()
|
|
||||||
|
|
||||||
override suspend fun deleteRadios() = look4SatDao.deleteRadios()
|
override suspend fun deleteRadios() = look4SatDao.deleteRadios()
|
||||||
|
|
||||||
private fun DomainRadio.toFramework() = FrameworkRadio(
|
override suspend fun deleteManagedRadios() = look4SatDao.deleteManagedRadios()
|
||||||
|
|
||||||
|
private fun DomainRadio.toFramework(isCustom: Boolean = false) = FrameworkRadio(
|
||||||
this.uuid, this.info, this.isAlive, this.downlinkLow, this.downlinkHigh, this.downlinkMode,
|
this.uuid, this.info, this.isAlive, this.downlinkLow, this.downlinkHigh, this.downlinkMode,
|
||||||
this.uplinkLow, this.uplinkHigh, this.uplinkMode, this.isInverted, this.catnum,
|
this.uplinkLow, this.uplinkHigh, this.uplinkMode, this.isInverted, this.catnum, isCustom
|
||||||
this.service, this.isCustom
|
|
||||||
)
|
)
|
||||||
|
|
||||||
private fun FrameworkRadio.toDomain() = DomainRadio(
|
private fun FrameworkRadio.toDomain() = DomainRadio(
|
||||||
this.uuid, this.info, this.isAlive, this.downlinkLow, this.downlinkHigh, this.downlinkMode,
|
this.uuid, this.info, this.isAlive, this.downlinkLow, this.downlinkHigh, this.downlinkMode,
|
||||||
this.uplinkLow, this.uplinkHigh, this.uplinkMode, this.isInverted, this.catnum,
|
this.uplinkLow, this.uplinkHigh, this.uplinkMode, this.isInverted, this.catnum
|
||||||
this.service, this.isCustom
|
|
||||||
)
|
)
|
||||||
|
|
||||||
private fun List<DomainRadio>.toFrameworkRadios() = this.map { radio -> radio.toFramework() }
|
|
||||||
|
|
||||||
private fun List<FrameworkRadio>.toDomainRadios() = this.map { radio -> radio.toDomain() }
|
private fun List<FrameworkRadio>.toDomainRadios() = this.map { radio -> radio.toDomain() }
|
||||||
|
|
||||||
//endregion
|
//endregion
|
||||||
|
|||||||
@@ -54,8 +54,8 @@ class RemoteSource(
|
|||||||
response.close()
|
response.close()
|
||||||
return@withContext NetworkResult(code, null)
|
return@withContext NetworkResult(code, null)
|
||||||
}
|
}
|
||||||
// Return the body stream directly as the caller is responsible for closing it.
|
// Return the body stream directly as the caller is responsible for closing it
|
||||||
// Closing the stream returns the connection to OkHttp's pool.
|
// That returns the connection to OkHttp's pool
|
||||||
val body = response.body
|
val body = response.body
|
||||||
if (body == null) {
|
if (body == null) {
|
||||||
response.close()
|
response.close()
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ class AddToCalendar(private val context: Context) : IAddToCalendar {
|
|||||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||||
setData(CalendarContract.Events.CONTENT_URI)
|
setData(CalendarContract.Events.CONTENT_URI)
|
||||||
putExtra(CalendarContract.Events.TITLE, name)
|
putExtra(CalendarContract.Events.TITLE, name)
|
||||||
putExtra(CalendarContract.Events.DESCRIPTION, "Look4Sat-BA7OPF Pass")
|
putExtra(CalendarContract.Events.DESCRIPTION, "Look4Sat Pass")
|
||||||
putExtra(CalendarContract.EXTRA_EVENT_BEGIN_TIME, aosTime)
|
putExtra(CalendarContract.EXTRA_EVENT_BEGIN_TIME, aosTime)
|
||||||
putExtra(CalendarContract.EXTRA_EVENT_END_TIME, losTime)
|
putExtra(CalendarContract.EXTRA_EVENT_END_TIME, losTime)
|
||||||
}
|
}
|
||||||
|
|||||||
-18
@@ -1,21 +1,3 @@
|
|||||||
/*
|
|
||||||
* 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
|
package com.rtbishop.look4sat.core.data.framework
|
||||||
|
|
||||||
import org.junit.Assert.assertArrayEquals
|
import org.junit.Assert.assertArrayEquals
|
||||||
|
|||||||
-18
@@ -1,21 +1,3 @@
|
|||||||
/*
|
|
||||||
* 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
|
package com.rtbishop.look4sat.core.data.framework
|
||||||
|
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
|
|||||||
@@ -1,127 +0,0 @@
|
|||||||
/*
|
|
||||||
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
|
||||||
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
|
||||||
*
|
|
||||||
* This program is free software: you can redistribute it and/or modify
|
|
||||||
* it under the terms of the GNU General Public License as published by
|
|
||||||
* the Free Software Foundation, either version 3 of the License, or
|
|
||||||
* (at your option) any later version.
|
|
||||||
*/
|
|
||||||
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
|
|
||||||
import org.junit.Assert.assertNull
|
|
||||||
import org.junit.Assert.assertTrue
|
|
||||||
import org.junit.Test
|
|
||||||
|
|
||||||
/** Parses the real bundled ARRL config.tq6 to verify region-field and zonemap extraction. */
|
|
||||||
class LoTWConfigTest {
|
|
||||||
|
|
||||||
private val config = LoTWConfig(File("src/main/assets/lotw/config.tq6").inputStream())
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `china exposes province field with all 31 options and zonemaps`() {
|
|
||||||
val meta = config.stationMeta(318)
|
|
||||||
assertEquals("CN_PROVINCE", meta.regionField?.id)
|
|
||||||
assertEquals("Province", meta.regionField?.label)
|
|
||||||
assertEquals(31, meta.regionField?.options?.size)
|
|
||||||
val gd = meta.regionField?.options?.first { it.code == "GD" }
|
|
||||||
assertEquals("Guangdong", gd?.name)
|
|
||||||
assertEquals(listOf(LoTWZonePair(44, 24)), gd?.zones)
|
|
||||||
val gx = meta.regionField?.options?.first { it.code == "GX" }
|
|
||||||
assertEquals(listOf(LoTWZonePair(43, 24), LoTWZonePair(44, 24)), gx?.zones)
|
|
||||||
// China spans 8 zone pairs — the UI must NOT prefill from the national map.
|
|
||||||
assertTrue(meta.countryZones.size > 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `usa exposes state field with 49 continental options`() {
|
|
||||||
val meta = config.stationMeta(291)
|
|
||||||
assertEquals("US_STATE", meta.regionField?.id)
|
|
||||||
assertEquals("State", meta.regionField?.label)
|
|
||||||
// AK and HI are separate DXCC entities (6 and 110) with their own 1-option blocks.
|
|
||||||
assertEquals(49, meta.regionField?.options?.size)
|
|
||||||
assertTrue(meta.regionField!!.options.none { it.code == "AK" || it.code == "HI" })
|
|
||||||
assertTrue(meta.countryZones.size > 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `alaska and hawaii each expose their own single-state block`() {
|
|
||||||
val ak = config.stationMeta(6)
|
|
||||||
assertEquals(listOf("AK"), ak.regionField?.options?.map { it.code })
|
|
||||||
val hi = config.stationMeta(110)
|
|
||||||
assertEquals(listOf("HI"), hi.regionField?.options?.map { it.code })
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `germany has no region field and one national zone pair`() {
|
|
||||||
val meta = config.stationMeta(230)
|
|
||||||
assertNull(meta.regionField)
|
|
||||||
assertEquals(listOf(LoTWZonePair(28, 14)), meta.countryZones)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `india has no region field and one national zone pair`() {
|
|
||||||
val meta = config.stationMeta(324)
|
|
||||||
assertNull(meta.regionField)
|
|
||||||
assertEquals(listOf(LoTWZonePair(41, 22)), meta.countryZones)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `unknown entity yields empty meta`() {
|
|
||||||
val meta = config.stationMeta(999999)
|
|
||||||
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("RS95S", config.resolveSatellite("QMR-KWT 2"))
|
|
||||||
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,95 +0,0 @@
|
|||||||
/*
|
|
||||||
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
|
||||||
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
|
||||||
*
|
|
||||||
* This program is free software: you can redistribute it and/or modify
|
|
||||||
* it under the terms of the GNU General Public License as published by
|
|
||||||
* the Free Software Foundation, either version 3 of the License, or
|
|
||||||
* (at your option) any later version.
|
|
||||||
*
|
|
||||||
* This program is distributed in the hope that it will be useful,
|
|
||||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
* GNU General Public License for more details.
|
|
||||||
*
|
|
||||||
* You should have received a copy of the GNU General Public License
|
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package com.rtbishop.look4sat.core.data.lotw
|
|
||||||
|
|
||||||
import com.rtbishop.look4sat.core.domain.repository.LoTWOperationException
|
|
||||||
import com.rtbishop.look4sat.core.domain.repository.LoTWProblem
|
|
||||||
import java.io.InputStream
|
|
||||||
import java.security.KeyStore
|
|
||||||
import org.junit.Assert.assertEquals
|
|
||||||
import org.junit.Assert.assertThrows
|
|
||||||
import org.junit.Test
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Certificate import must not depend on the platform PKCS12 parser. Android's parser accepts a
|
|
||||||
* modern TQSL PBES2 export but can expose no key entry for it (observed with an empty-password
|
|
||||||
* backup, which was then rejected as "not a valid LoTW certificate file" although every field of
|
|
||||||
* the certificate is readable). These tests pin the fallback to our own PBES2 reader.
|
|
||||||
*/
|
|
||||||
class LoTWKeyMaterialTest {
|
|
||||||
|
|
||||||
private fun fixture(name: String): ByteArray =
|
|
||||||
javaClass.classLoader!!.getResourceAsStream(name)!!.use { it.readBytes() }
|
|
||||||
|
|
||||||
/** Stands in for a platform that parsed the container but offers no private-key entry. */
|
|
||||||
private fun storeWithoutKeyEntry(): KeyStore = KeyStore.getInstance("PKCS12").apply {
|
|
||||||
load(null as InputStream?, null as CharArray?)
|
|
||||||
}
|
|
||||||
|
|
||||||
private val now = System.currentTimeMillis()
|
|
||||||
|
|
||||||
/** TQSL-style backup (empty password) imported although the platform delivers no key entry. */
|
|
||||||
@Test
|
|
||||||
fun importsCertificateWhenPlatformExposesNoKeyEntry() {
|
|
||||||
val material = LoTWKeyMaterial.read(
|
|
||||||
bytes = fixture("test_tqsl_empty.p12"),
|
|
||||||
password = charArrayOf(),
|
|
||||||
now = now,
|
|
||||||
platformStore = { _, _ -> storeWithoutKeyEntry() },
|
|
||||||
)
|
|
||||||
assertEquals("BA7OPF", material.info.callsign)
|
|
||||||
assertEquals(318, material.info.dxcc)
|
|
||||||
assertEquals("2026-01-01", material.info.firstQsoDate)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The same backup still imports when the platform is the one that resolves the key entry. */
|
|
||||||
@Test
|
|
||||||
fun importsCertificateThroughPlatformWhenAvailable() {
|
|
||||||
val material = LoTWKeyMaterial.read(fixture("test_tqsl_empty.p12"), charArrayOf(), now)
|
|
||||||
assertEquals("BA7OPF", material.info.callsign)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A wrong password is still reported as a password problem, not as an invalid file. */
|
|
||||||
@Test
|
|
||||||
fun reportsWrongPasswordForPbes2Backup() {
|
|
||||||
val error = assertThrows(LoTWOperationException::class.java) {
|
|
||||||
LoTWKeyMaterial.read(fixture("test_tqsl_empty.p12"), "wrong-password".toCharArray(), now)
|
|
||||||
}
|
|
||||||
assertEquals(LoTWProblem.CERTIFICATE_PASSWORD, error.reason)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Something that is not a PKCS12 at all keeps the message the platform path produced. */
|
|
||||||
@Test
|
|
||||||
fun refusesNonPkcs12File() {
|
|
||||||
val error = assertThrows(LoTWOperationException::class.java) {
|
|
||||||
LoTWKeyMaterial.read("not a p12 at all".toByteArray(), "x".toCharArray(), now) { _, _ -> null }
|
|
||||||
}
|
|
||||||
assertEquals(LoTWProblem.CERTIFICATE_PASSWORD, error.reason)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Oversized input is rejected before any parser runs. */
|
|
||||||
@Test
|
|
||||||
fun refusesOversizedInput() {
|
|
||||||
val bytes = ByteArray(MAX_CERTIFICATE_BYTES + 1)
|
|
||||||
val error = assertThrows(LoTWOperationException::class.java) {
|
|
||||||
LoTWKeyMaterial.read(bytes, charArrayOf(), now) { _, _ -> storeWithoutKeyEntry() }
|
|
||||||
}
|
|
||||||
assertEquals(LoTWProblem.CERTIFICATE_INVALID, error.reason)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-189
@@ -1,189 +0,0 @@
|
|||||||
/*
|
|
||||||
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
|
||||||
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
|
||||||
*
|
|
||||||
* This program is free software: you can redistribute it and/or modify
|
|
||||||
* it under the terms of the GNU General Public License as published by
|
|
||||||
* the Free Software Foundation, either version 3 of the License, or
|
|
||||||
* (at your option) any later version.
|
|
||||||
*
|
|
||||||
* This program is distributed in the hope that it will be useful,
|
|
||||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
* GNU General Public License for more details.
|
|
||||||
*
|
|
||||||
* You should have received a copy of the GNU General Public License
|
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
*/
|
|
||||||
package com.rtbishop.look4sat.core.data.lotw
|
|
||||||
|
|
||||||
import com.rtbishop.look4sat.core.domain.logbook.QsoRecord
|
|
||||||
import com.rtbishop.look4sat.core.domain.logbook.QsoStatus
|
|
||||||
import com.rtbishop.look4sat.core.domain.repository.LoTWOperationException
|
|
||||||
import com.rtbishop.look4sat.core.domain.repository.LoTWProblem
|
|
||||||
import com.rtbishop.look4sat.core.domain.repository.LoTWStation
|
|
||||||
import com.rtbishop.look4sat.core.domain.repository.LoTWUploadResult
|
|
||||||
import java.io.File
|
|
||||||
import kotlinx.coroutines.runBlocking
|
|
||||||
import org.junit.Assert.assertEquals
|
|
||||||
import org.junit.Assert.assertNull
|
|
||||||
import org.junit.Assert.assertThrows
|
|
||||||
import org.junit.Assert.assertTrue
|
|
||||||
import org.junit.Test
|
|
||||||
|
|
||||||
/**
|
|
||||||
* previewCertificate parses a .p12 without touching storage — just enough to
|
|
||||||
* learn the certificate's DXCC entity, so the settings dialog can offer the
|
|
||||||
* entity's Province/State dropdown before the operator confirms the import.
|
|
||||||
*
|
|
||||||
* Fixture test_tqsl_empty.p12 mirrors a modern TQSL export: PBES2/AES-256,
|
|
||||||
* empty password, TQSL subject attribute 1.3.6.1.4.1.12348.1.1 = BA7OPF and
|
|
||||||
* extensions .2/.3/.4 = first/last QSO date, DXCC 318 (China).
|
|
||||||
*/
|
|
||||||
class LoTWUploadRepositoryPreviewTest {
|
|
||||||
|
|
||||||
private class MemStorage : LoTWStorage {
|
|
||||||
val files = mutableMapOf<String, ByteArray>()
|
|
||||||
// Reads hand out their own copy, like the file-backed storage does: several call sites
|
|
||||||
// wipe what they read, and that must not corrupt the stored bytes.
|
|
||||||
override fun read(name: String): ByteArray? = files[name]?.copyOf()
|
|
||||||
override fun write(name: String, data: ByteArray) { files[name] = data.copyOf() }
|
|
||||||
override fun delete(name: String) { files.remove(name) }
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun fixture(name: String): ByteArray =
|
|
||||||
javaClass.classLoader!!.getResourceAsStream(name)!!.use { it.readBytes() }
|
|
||||||
|
|
||||||
private fun repository(storage: MemStorage) = LoTWUploadRepository(
|
|
||||||
storage, { error("station config not needed") }, now = { System.currentTimeMillis() }
|
|
||||||
)
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `preview parses TQSL-style certificate without persisting it`() {
|
|
||||||
runBlocking {
|
|
||||||
val storage = MemStorage()
|
|
||||||
val repo = repository(storage)
|
|
||||||
val info = repo.previewCertificate(fixture("test_tqsl_empty.p12"), charArrayOf())
|
|
||||||
assertEquals("BA7OPF", info.callsign)
|
|
||||||
assertEquals(318, info.dxcc)
|
|
||||||
assertEquals("2026-01-01", info.firstQsoDate)
|
|
||||||
assertTrue("nothing written", storage.files.isEmpty())
|
|
||||||
assertNull("no certificate stored", repo.certificate())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `wrong password fails as certificate password and wipes the attempt`() {
|
|
||||||
runBlocking {
|
|
||||||
val storage = MemStorage()
|
|
||||||
val repo = repository(storage)
|
|
||||||
val password = "definitely-wrong".toCharArray()
|
|
||||||
val error = assertThrows(LoTWOperationException::class.java) {
|
|
||||||
runBlocking { repo.previewCertificate(fixture("test_pbes2.p12"), password) }
|
|
||||||
}
|
|
||||||
assertEquals(LoTWProblem.CERTIFICATE_PASSWORD, error.reason)
|
|
||||||
assertTrue("password wiped", password.all { it == '\u0000' })
|
|
||||||
assertTrue("nothing written", storage.files.isEmpty())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Records logged before a certificate was installed (blank own callsign) or under another
|
|
||||||
* certificate are signed with the certificate's callsign instead of being refused, and the
|
|
||||||
* preview reports how many they were.
|
|
||||||
*/
|
|
||||||
@Test
|
|
||||||
fun `records without a matching callsign upload under the certificate and are reported`() {
|
|
||||||
runBlocking {
|
|
||||||
val storage = MemStorage()
|
|
||||||
val repo = LoTWUploadRepository(
|
|
||||||
storage,
|
|
||||||
{ LoTWConfig(File("src/main/assets/lotw/config.tq6").inputStream()) },
|
|
||||||
now = { System.currentTimeMillis() }
|
|
||||||
)
|
|
||||||
repo.importCertificate(fixture("test_tqsl_empty.p12"), charArrayOf())
|
|
||||||
repo.saveStation(LoTWStation(grid = "OL62TI"))
|
|
||||||
val start = 1_787_000_000_000L // 2026-08, inside the fixture certificate's QSO range
|
|
||||||
val preview = repo.prepare(
|
|
||||||
listOf(
|
|
||||||
sampleRecord(start, "BH6RJD", ""),
|
|
||||||
sampleRecord(start + 60_000L, "BG7QBL", "XX0YY"),
|
|
||||||
sampleRecord(start + 120_000L, "BA8BLK", "BA7OPF")
|
|
||||||
),
|
|
||||||
resubmit = false
|
|
||||||
)
|
|
||||||
// The blank record is signed with the certificate's callsign; the one naming another
|
|
||||||
// callsign is refused and offered up for a rewrite instead.
|
|
||||||
assertEquals(2, preview.count)
|
|
||||||
assertEquals(1, preview.missingCallsign)
|
|
||||||
assertEquals(listOf(start + 60_000L), preview.callsignConflicts)
|
|
||||||
assertEquals(1, preview.unavailableReasons[LoTWProblem.CALLSIGN_MISMATCH])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Nothing signable at all: the preview still comes back (count 0) with the conflicts listed,
|
|
||||||
* so the UI can offer the rewrite / use-another-certificate actions instead of a dead end. */
|
|
||||||
@Test
|
|
||||||
fun `fully blocked batch still returns a preview with the conflicts`() {
|
|
||||||
runBlocking {
|
|
||||||
val storage = MemStorage()
|
|
||||||
val repo = LoTWUploadRepository(
|
|
||||||
storage,
|
|
||||||
{ LoTWConfig(File("src/main/assets/lotw/config.tq6").inputStream()) },
|
|
||||||
now = { System.currentTimeMillis() }
|
|
||||||
)
|
|
||||||
repo.importCertificate(fixture("test_tqsl_empty.p12"), charArrayOf())
|
|
||||||
repo.saveStation(LoTWStation(grid = "OL62TI"))
|
|
||||||
val start = 1_787_000_000_000L
|
|
||||||
val preview = repo.prepare(
|
|
||||||
listOf(
|
|
||||||
sampleRecord(start, "BH6RJD", "XX0YY"),
|
|
||||||
sampleRecord(start + 60_000L, "BG7QBL", "XX0ZZ")
|
|
||||||
),
|
|
||||||
resubmit = false
|
|
||||||
)
|
|
||||||
assertEquals(0, preview.count)
|
|
||||||
assertEquals(2, preview.unavailableSkipped)
|
|
||||||
assertEquals(listOf(start, start + 60_000L), preview.callsignConflicts)
|
|
||||||
assertEquals(2, preview.unavailableReasons[LoTWProblem.CALLSIGN_MISMATCH])
|
|
||||||
assertEquals(0, preview.missingCallsign)
|
|
||||||
// No payload was prepared: confirming this preview must fail as expired, not crash.
|
|
||||||
assertTrue(repo.upload(preview.id) is LoTWUploadResult.ExpiredPreview)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The mismatch reason names both callsigns (record vs certificate). The old detail showed the
|
|
||||||
* opposite station's call, which told the operator nothing about which side to fix. */
|
|
||||||
@Test
|
|
||||||
fun `callsign mismatch detail names the record and the certificate callsign`() {
|
|
||||||
runBlocking {
|
|
||||||
val storage = MemStorage()
|
|
||||||
val repo = LoTWUploadRepository(
|
|
||||||
storage,
|
|
||||||
{ LoTWConfig(File("src/main/assets/lotw/config.tq6").inputStream()) },
|
|
||||||
now = { System.currentTimeMillis() }
|
|
||||||
)
|
|
||||||
repo.importCertificate(fixture("test_tqsl_empty.p12"), charArrayOf())
|
|
||||||
repo.saveStation(LoTWStation(grid = "OL62TI"))
|
|
||||||
val audit = repo.audit(listOf(sampleRecord(1_787_000_000_000L, "BH6RJD", "XX0YY")))
|
|
||||||
assertEquals("XX0YY ≠ BA7OPF", audit.details[LoTWProblem.CALLSIGN_MISMATCH])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun sampleRecord(start: Long, call: String, myCallsign: String) = QsoRecord(
|
|
||||||
id = start,
|
|
||||||
startUtcMillis = start,
|
|
||||||
endUtcMillis = start,
|
|
||||||
theirCallsign = call,
|
|
||||||
myCallsign = myCallsign,
|
|
||||||
myGrid = "OL62TI",
|
|
||||||
txFrequencyHz = 145_850_000L,
|
|
||||||
rxFrequencyHz = 436_795_000L,
|
|
||||||
band = "2M",
|
|
||||||
rxBand = "70CM",
|
|
||||||
mode = "FM",
|
|
||||||
satelliteName = "SO-50",
|
|
||||||
propagationMode = "SAT",
|
|
||||||
status = QsoStatus.COMPLETE
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,170 +0,0 @@
|
|||||||
/*
|
|
||||||
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
|
||||||
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
|
||||||
*
|
|
||||||
* This program is free software: you can redistribute it and/or modify
|
|
||||||
* it under the terms of the GNU General Public License as published by
|
|
||||||
* the Free Software Foundation, either version 3 of the License, or
|
|
||||||
* (at your option) any later version.
|
|
||||||
*
|
|
||||||
* This program is distributed in the hope that it will be useful,
|
|
||||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
* GNU General Public License for more details.
|
|
||||||
*
|
|
||||||
* You should have received a copy of the GNU General Public License
|
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package com.rtbishop.look4sat.core.data.lotw
|
|
||||||
|
|
||||||
import java.security.Signature
|
|
||||||
import org.junit.Assert.assertEquals
|
|
||||||
import org.junit.Assert.assertNotNull
|
|
||||||
import org.junit.Assert.assertThrows
|
|
||||||
import org.junit.Assert.assertTrue
|
|
||||||
import org.junit.Test
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Verifies the PBES2/AES-256-CBC PKCS12 reader against a fixture generated with
|
|
||||||
* `openssl pkcs12 -export -certpbe NONE` — same layout as modern TQSL exports
|
|
||||||
* (plain certificate bags + PBES2-shrouded private key), which Android's legacy
|
|
||||||
* Bouncy Castle parser cannot read.
|
|
||||||
*/
|
|
||||||
class Pkcs12ReaderTest {
|
|
||||||
|
|
||||||
private fun fixture(): ByteArray =
|
|
||||||
javaClass.classLoader!!.getResourceAsStream("test_pbes2.p12")!!.use { it.readBytes() }
|
|
||||||
|
|
||||||
private fun fixture(name: String): ByteArray =
|
|
||||||
javaClass.classLoader!!.getResourceAsStream(name)!!.use { it.readBytes() }
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun readsAes192Variant() {
|
|
||||||
val (key, cert) = Pkcs12Reader.read(fixture("test_pbes2_aes192.p12"), "testpass123".toCharArray())
|
|
||||||
assertEquals("RSA", key.algorithm)
|
|
||||||
assertEquals("RSA", cert.publicKey.algorithm)
|
|
||||||
assertTrue("key/cert pair", keyMatches(key, cert))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun readsDesEde3CbcVariant() {
|
|
||||||
val (key, cert) = Pkcs12Reader.read(fixture("test_pbes2_desede3.p12"), "testpass123".toCharArray())
|
|
||||||
assertEquals("RSA", key.algorithm)
|
|
||||||
assertEquals("RSA", cert.publicKey.algorithm)
|
|
||||||
assertTrue("key/cert pair", keyMatches(key, cert))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun unsupportedCipherNamesTheAlgorithm() {
|
|
||||||
// A valid PBES2 file re-encrypted with an unsupported cipher must report the
|
|
||||||
// algorithm name (so the user can see exactly what to re-export with).
|
|
||||||
val fixtureBytes = fixture("test_pbes2.p12")
|
|
||||||
val e = assertThrows(IllegalStateException::class.java) {
|
|
||||||
// Simulate: patch the AES-256-CBC OID inside the file to an unknown OID.
|
|
||||||
val bogus = ByteArray(fixtureBytes.size) { fixtureBytes[it] }
|
|
||||||
// find AES-256-CBC OID bytes 0x60 86 48 01 65 03 04 01 2a
|
|
||||||
val oid = byteArrayOf(0x60, 0x86.toByte(), 0x48, 0x01, 0x65, 0x03, 0x04, 0x01, 0x2a)
|
|
||||||
var idx = -1
|
|
||||||
outer@ for (i in 0..bogus.size - oid.size) {
|
|
||||||
for (j in oid.indices) if (bogus[i + j] != oid[j]) continue@outer
|
|
||||||
idx = i; break
|
|
||||||
}
|
|
||||||
require(idx >= 0) { "AES-256-CBC OID not found in fixture" }
|
|
||||||
bogus[idx] = 0x7f.toByte() // corrupt the OID tag byte → unknown cipher
|
|
||||||
Pkcs12Reader.read(bogus, "testpass123".toCharArray())
|
|
||||||
}
|
|
||||||
assertTrue("mentions algorithm", e.message.orEmpty().contains("algorithm"))
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun keyMatches(key: java.security.PrivateKey, cert: java.security.cert.X509Certificate): Boolean {
|
|
||||||
val challenge = "Look4Sat LoTW certificate key check".toByteArray(Charsets.US_ASCII)
|
|
||||||
val signed = Signature.getInstance("SHA1withRSA").run {
|
|
||||||
initSign(key); update(challenge); sign()
|
|
||||||
}
|
|
||||||
return Signature.getInstance("SHA1withRSA").run {
|
|
||||||
initVerify(cert); update(challenge); verify(signed)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun wrongPasswordFailsWithBadPadding() {
|
|
||||||
// A wrong password must surface as a decryption failure (BadPadding), which
|
|
||||||
// LoTWKeyMaterial maps to CERTIFICATE_PASSWORD — not a format error.
|
|
||||||
val e = assertThrows(Exception::class.java) {
|
|
||||||
Pkcs12Reader.read(fixture(), "definitely-wrong-password".toCharArray())
|
|
||||||
}
|
|
||||||
assertTrue("BadPadding", e is javax.crypto.BadPaddingException)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun readsPbes2KeyAndCertificate() {
|
|
||||||
val (key, cert) = Pkcs12Reader.read(fixture(), "testpass123".toCharArray())
|
|
||||||
assertEquals("RSA", key.algorithm)
|
|
||||||
assertEquals("RSA", cert.publicKey.algorithm)
|
|
||||||
assertNotNull(cert.subjectX500Principal)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun keyPairsWithCertificate() {
|
|
||||||
val (key, cert) = Pkcs12Reader.read(fixture(), "testpass123".toCharArray())
|
|
||||||
val challenge = "Look4Sat LoTW certificate key check".toByteArray(Charsets.US_ASCII)
|
|
||||||
val signed = Signature.getInstance("SHA1withRSA").run {
|
|
||||||
initSign(key); update(challenge); sign()
|
|
||||||
}
|
|
||||||
val valid = Signature.getInstance("SHA1withRSA").run {
|
|
||||||
initVerify(cert); update(challenge); verify(signed)
|
|
||||||
}
|
|
||||||
assertTrue("private key must match certificate", valid)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun wrongPasswordThrows() {
|
|
||||||
assertThrows(Exception::class.java) {
|
|
||||||
Pkcs12Reader.read(fixture(), "wrongpass".toCharArray())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun garbageBytesThrows() {
|
|
||||||
assertThrows(Exception::class.java) {
|
|
||||||
Pkcs12Reader.read("not a p12 file at all".toByteArray(), "x".toCharArray())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun readsEmptyPasswordCertpbeNoneLayout() {
|
|
||||||
// Certificates exported without a password must import with an empty char
|
|
||||||
// array — the reader must decrypt, not demand a password.
|
|
||||||
val (key, cert) = Pkcs12Reader.read(fixture("test_pbes2_empty.p12"), charArrayOf())
|
|
||||||
assertEquals("RSA", key.algorithm)
|
|
||||||
assertTrue("key/cert pair", keyMatches(key, cert))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun readsEmptyPasswordEncryptedCertLayout() {
|
|
||||||
val (key, cert) = Pkcs12Reader.read(fixture("test_pbes2_empty_enc.p12"), charArrayOf())
|
|
||||||
assertEquals("RSA", key.algorithm)
|
|
||||||
assertTrue("key/cert pair", keyMatches(key, cert))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun emptyPasswordOnProtectedFileFailsAsDecrypt() {
|
|
||||||
// Blank input on a password-protected file must fail as a decryption error
|
|
||||||
// (mapped to "password" upstream) — never silently succeed.
|
|
||||||
val e = assertThrows(Exception::class.java) {
|
|
||||||
Pkcs12Reader.read(fixture(), charArrayOf())
|
|
||||||
}
|
|
||||||
assertTrue("BadPadding", e is javax.crypto.BadPaddingException)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun platformKeystoreAcceptsEmptyPassword() {
|
|
||||||
// LoTWKeyMaterial first tries the platform PKCS12 KeyStore: verify the
|
|
||||||
// empty-password load/getKey call convention this fix relies on.
|
|
||||||
val ks = java.security.KeyStore.getInstance("PKCS12")
|
|
||||||
ks.load(fixture("test_pbes2_empty.p12").inputStream(), charArrayOf())
|
|
||||||
val alias = ks.aliases().nextElement()
|
|
||||||
assertNotNull(ks.getKey(alias, charArrayOf()))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-307
@@ -1,307 +0,0 @@
|
|||||||
/*
|
|
||||||
* 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.SatSlot
|
|
||||||
import com.rtbishop.look4sat.core.domain.model.SatStatus
|
|
||||||
import com.rtbishop.look4sat.core.domain.model.SatStatusPage
|
|
||||||
import com.rtbishop.look4sat.core.domain.source.IRemoteSource
|
|
||||||
import com.rtbishop.look4sat.core.domain.source.NetworkResult
|
|
||||||
import kotlinx.coroutines.async
|
|
||||||
import kotlinx.coroutines.delay
|
|
||||||
import kotlinx.coroutines.test.runTest
|
|
||||||
import org.junit.Assert.assertEquals
|
|
||||||
import org.junit.Assert.assertNull
|
|
||||||
import org.junit.Assert.assertSame
|
|
||||||
import org.junit.Test
|
|
||||||
import java.io.InputStream
|
|
||||||
import java.text.SimpleDateFormat
|
|
||||||
import java.util.Date
|
|
||||||
import java.util.Locale
|
|
||||||
import java.util.TimeZone
|
|
||||||
|
|
||||||
class AmSatRepositoryTest {
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun fetchStatusReturnsSeededCacheUntilCacheIsCleared() = runTest {
|
|
||||||
val remoteSource = FakeAmSatRemoteSource()
|
|
||||||
val repository = AmSatRepository(remoteSource)
|
|
||||||
val cachedPage = SatStatusPage(
|
|
||||||
fetchedAtUtcMs = 123L,
|
|
||||||
statuses = listOf(SatStatus(name = "AO-7", days = emptyList())),
|
|
||||||
reports = emptyMap()
|
|
||||||
)
|
|
||||||
repository.seedStatusCache(cachedPage)
|
|
||||||
|
|
||||||
val firstPage = repository.fetchStatus()
|
|
||||||
val secondPage = repository.fetchStatus()
|
|
||||||
|
|
||||||
assertSame(cachedPage, firstPage)
|
|
||||||
assertSame(cachedPage, secondPage)
|
|
||||||
assertSame(cachedPage, repository.getCachedStatus())
|
|
||||||
assertEquals(0, remoteSource.catalogRequests)
|
|
||||||
assertEquals(0, remoteSource.reportRequests)
|
|
||||||
|
|
||||||
repository.clearStatusCache()
|
|
||||||
assertNull(repository.getCachedStatus())
|
|
||||||
repository.fetchStatus()
|
|
||||||
|
|
||||||
assertEquals(1, remoteSource.catalogRequests)
|
|
||||||
assertEquals(1, remoteSource.reportRequests)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun forceRefreshBypassesCachedPage() = runTest {
|
|
||||||
val remoteSource = FakeAmSatRemoteSource()
|
|
||||||
val repository = AmSatRepository(remoteSource)
|
|
||||||
repository.seedStatusCache(
|
|
||||||
SatStatusPage(
|
|
||||||
fetchedAtUtcMs = 123L,
|
|
||||||
statuses = listOf(SatStatus(name = "AO-7", days = emptyList())),
|
|
||||||
reports = emptyMap()
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
repository.fetchStatus(forceRefresh = true)
|
|
||||||
|
|
||||||
assertEquals(1, remoteSource.catalogRequests)
|
|
||||||
assertEquals(1, remoteSource.reportRequests)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun fetchStatusSharesStartupPrefetchRequest() = runTest {
|
|
||||||
val remoteSource = FakeAmSatRemoteSource(responseDelayMillis = 50)
|
|
||||||
val repository = AmSatRepository(remoteSource)
|
|
||||||
|
|
||||||
val prefetch = async { repository.prefetchStatus() }
|
|
||||||
val pageFetch = async { repository.fetchStatus() }
|
|
||||||
prefetch.await()
|
|
||||||
val page = pageFetch.await()
|
|
||||||
|
|
||||||
assertSame(page, repository.getCachedStatus())
|
|
||||||
assertEquals(1, remoteSource.catalogRequests)
|
|
||||||
assertEquals(1, remoteSource.reportRequests)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun buildStatusesStreakCountsConsecutiveSameStatusReportsPerDay() = runTest {
|
|
||||||
val nowSec = System.currentTimeMillis() / 1000
|
|
||||||
val remoteSource = FakeAmSatRemoteSource(
|
|
||||||
reportsJson = amSatReportsJson(
|
|
||||||
// 当天(day 0)最新槽(slot 0)混合状态:最新 Heard、次新 Heard、再旧 Not Heard → 连续=2
|
|
||||||
report("r1", "AO-7", "Heard", nowSec - 3600),
|
|
||||||
report("r2", "AO-7", "Heard", nowSec - 5400),
|
|
||||||
report("r3", "AO-7", "Not Heard", nowSec - 7000),
|
|
||||||
// 空时段(无报告)不打断:slot 8 的 Heard 更旧,不应计入(被 r3 打断)
|
|
||||||
report("r4", "AO-7", "Heard", nowSec - 63000),
|
|
||||||
// 前一天(day 1):最新 Heard,更旧的 Not Heard → 连续=1,且不跨天合并
|
|
||||||
report("r5", "AO-7", "Heard", nowSec - 90000),
|
|
||||||
report("r6", "AO-7", "Not Heard", nowSec - 100000)
|
|
||||||
// day 2 无报告 → 0
|
|
||||||
)
|
|
||||||
)
|
|
||||||
val repository = AmSatRepository(remoteSource)
|
|
||||||
|
|
||||||
val page = repository.fetchStatus()
|
|
||||||
val status = page?.statuses?.single()
|
|
||||||
assertEquals("AO-7", status?.name)
|
|
||||||
assertEquals(3, status?.days?.size)
|
|
||||||
|
|
||||||
assertEquals(2, status?.days?.get(0)?.streakCount)
|
|
||||||
assertEquals(1, status?.days?.get(1)?.streakCount)
|
|
||||||
assertEquals(0, status?.days?.get(2)?.streakCount)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun buildStatusesStreakCountsAllReportsWhenStatusNeverChanges() = runTest {
|
|
||||||
val nowSec = System.currentTimeMillis() / 1000
|
|
||||||
val remoteSource = FakeAmSatRemoteSource(
|
|
||||||
reportsJson = amSatReportsJson(
|
|
||||||
report("r1", "AO-7", "Heard", nowSec - 1800),
|
|
||||||
report("r2", "AO-7", "Heard", nowSec - 3600),
|
|
||||||
report("r3", "AO-7", "Heard", nowSec - 5400),
|
|
||||||
report("r4", "AO-7", "Heard", nowSec - 30000)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
val repository = AmSatRepository(remoteSource)
|
|
||||||
|
|
||||||
val page = repository.fetchStatus()
|
|
||||||
assertEquals(4, page?.statuses?.single()?.days?.get(0)?.streakCount)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun buildStatusesStrictMajorityTakesItsColorAndCount() = runTest {
|
|
||||||
val nowSec = System.currentTimeMillis() / 1000
|
|
||||||
|
|
||||||
val heardMost = firstSlotStatus(
|
|
||||||
report("h1", "AO-7", "Heard", nowSec - 3600),
|
|
||||||
report("h2", "AO-7", "Heard", nowSec - 4500),
|
|
||||||
report("t1", "AO-7", "Telemetry Only", nowSec - 5400)
|
|
||||||
)
|
|
||||||
assertEquals(0xFF648FFFL, heardMost?.statusColor) // blue
|
|
||||||
assertEquals(2, heardMost?.count) // majority count, not the block total
|
|
||||||
|
|
||||||
val telemetryMost = firstSlotStatus(
|
|
||||||
report("t1", "AO-7", "Telemetry Only", nowSec - 3600),
|
|
||||||
report("t2", "AO-7", "Telemetry Only", nowSec - 4500),
|
|
||||||
report("h1", "AO-7", "Heard", nowSec - 5400)
|
|
||||||
)
|
|
||||||
assertEquals(0xFFFFB000L, telemetryMost?.statusColor) // amber
|
|
||||||
assertEquals(2, telemetryMost?.count)
|
|
||||||
|
|
||||||
val notHeardMost = firstSlotStatus(
|
|
||||||
report("n1", "AO-7", "Not Heard", nowSec - 3600),
|
|
||||||
report("n2", "AO-7", "Not Heard", nowSec - 4500),
|
|
||||||
report("n3", "AO-7", "Not Heard", nowSec - 5400),
|
|
||||||
report("t1", "AO-7", "Telemetry Only", nowSec - 6000)
|
|
||||||
)
|
|
||||||
assertEquals(0xFFDC267FL, notHeardMost?.statusColor) // pink
|
|
||||||
assertEquals(3, notHeardMost?.count)
|
|
||||||
|
|
||||||
val single = firstSlotStatus(report("h1", "AO-7", "Heard", nowSec - 3600))
|
|
||||||
assertEquals(0xFF648FFFL, single?.statusColor)
|
|
||||||
assertEquals(1, single?.count)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun buildStatusesShowsConflictWhenNoStrictMajority() = runTest {
|
|
||||||
val nowSec = System.currentTimeMillis() / 1000
|
|
||||||
|
|
||||||
val oneToOne = firstSlotStatus(
|
|
||||||
report("h1", "AO-7", "Heard", nowSec - 3600),
|
|
||||||
report("n1", "AO-7", "Not Heard", nowSec - 4500)
|
|
||||||
)
|
|
||||||
assertEquals(0xFFFE6100L, oneToOne?.statusColor) // conflicting
|
|
||||||
assertEquals(2, oneToOne?.count)
|
|
||||||
|
|
||||||
val twoToTwo = firstSlotStatus(
|
|
||||||
report("h1", "AO-7", "Heard", nowSec - 3600),
|
|
||||||
report("h2", "AO-7", "Heard", nowSec - 4500),
|
|
||||||
report("t1", "AO-7", "Telemetry Only", nowSec - 5400),
|
|
||||||
report("t2", "AO-7", "Telemetry Only", nowSec - 6000)
|
|
||||||
)
|
|
||||||
assertEquals(0xFFFE6100L, twoToTwo?.statusColor)
|
|
||||||
|
|
||||||
val threeWay = firstSlotStatus(
|
|
||||||
report("h1", "AO-7", "Heard", nowSec - 3600),
|
|
||||||
report("n1", "AO-7", "Not Heard", nowSec - 4500),
|
|
||||||
report("t1", "AO-7", "Telemetry Only", nowSec - 5400)
|
|
||||||
)
|
|
||||||
assertEquals(0xFFFE6100L, threeWay?.statusColor)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun buildStatusesCrewActiveWinsWithCrewPlusHeardCount() = runTest {
|
|
||||||
val nowSec = System.currentTimeMillis() / 1000
|
|
||||||
|
|
||||||
val crewWithHeard = firstSlotStatus(
|
|
||||||
report("c1", "AO-7", "Crew Active", nowSec - 3600),
|
|
||||||
report("h1", "AO-7", "Heard", nowSec - 4500),
|
|
||||||
report("h2", "AO-7", "Heard", nowSec - 5400),
|
|
||||||
report("h3", "AO-7", "Heard", nowSec - 6000)
|
|
||||||
)
|
|
||||||
assertEquals(0xFF785EF0L, crewWithHeard?.statusColor) // purple
|
|
||||||
assertEquals(4, crewWithHeard?.count) // crew + heard
|
|
||||||
|
|
||||||
val crewAlone = firstSlotStatus(
|
|
||||||
report("c1", "AO-7", "Crew Active", nowSec - 3600),
|
|
||||||
report("n1", "AO-7", "Not Heard", nowSec - 4500),
|
|
||||||
report("n2", "AO-7", "Not Heard", nowSec - 5400)
|
|
||||||
)
|
|
||||||
assertEquals(0xFF785EF0L, crewAlone?.statusColor)
|
|
||||||
assertEquals(1, crewAlone?.count) // crew + heard (0)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun buildStatusesMarksOnlyNoStrictMajoritySlotsAsConflicted() = runTest {
|
|
||||||
val nowSec = System.currentTimeMillis() / 1000
|
|
||||||
|
|
||||||
val majority = firstSlotStatus(
|
|
||||||
report("h1", "AO-7", "Heard", nowSec - 3600),
|
|
||||||
report("h2", "AO-7", "Heard", nowSec - 4500),
|
|
||||||
report("t1", "AO-7", "Telemetry Only", nowSec - 5400)
|
|
||||||
)
|
|
||||||
assertEquals(false, majority?.isConflicted)
|
|
||||||
|
|
||||||
val tie = firstSlotStatus(
|
|
||||||
report("h1", "AO-7", "Heard", nowSec - 3600),
|
|
||||||
report("t1", "AO-7", "Telemetry Only", nowSec - 4500)
|
|
||||||
)
|
|
||||||
assertEquals(true, tie?.isConflicted)
|
|
||||||
|
|
||||||
val crew = firstSlotStatus(
|
|
||||||
report("c1", "AO-7", "Crew Active", nowSec - 3600),
|
|
||||||
report("n1", "AO-7", "Not Heard", nowSec - 4500)
|
|
||||||
)
|
|
||||||
assertEquals(false, crew?.isConflicted)
|
|
||||||
|
|
||||||
val empty = firstSlotStatus()
|
|
||||||
assertEquals(false, empty?.isConflicted)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun firstSlotStatus(vararg reports: String): SatSlot? {
|
|
||||||
val page = AmSatRepository(
|
|
||||||
FakeAmSatRemoteSource(reportsJson = amSatReportsJson(*reports))
|
|
||||||
).fetchStatus()
|
|
||||||
return page?.statuses?.single()?.days?.get(0)?.slots?.get(0)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun AmSatRepository.seedStatusCache(page: SatStatusPage) {
|
|
||||||
val cacheField = AmSatRepository::class.java.getDeclaredField("statusCache")
|
|
||||||
cacheField.isAccessible = true
|
|
||||||
cacheField.set(this, page)
|
|
||||||
}
|
|
||||||
|
|
||||||
private class FakeAmSatRemoteSource(
|
|
||||||
private val responseDelayMillis: Long = 0L,
|
|
||||||
private val reportsJson: String = """{"data":[]}"""
|
|
||||||
) : IRemoteSource {
|
|
||||||
var catalogRequests = 0
|
|
||||||
var reportRequests = 0
|
|
||||||
|
|
||||||
override suspend fun getFileStream(uri: String): InputStream? = null
|
|
||||||
|
|
||||||
override suspend fun getNetworkStream(url: String): NetworkResult = NetworkResult(404, null)
|
|
||||||
|
|
||||||
override suspend fun getAmSatCatalog(): String? {
|
|
||||||
if (responseDelayMillis > 0L) delay(responseDelayMillis)
|
|
||||||
catalogRequests += 1
|
|
||||||
return """{"data":[{"name":"AO-7"}]}"""
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun getAmSatReports(hours: Int, limit: Int): String? {
|
|
||||||
if (responseDelayMillis > 0L) delay(responseDelayMillis)
|
|
||||||
reportRequests += 1
|
|
||||||
return reportsJson
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun submitAmSatReport(payloadJson: String): Pair<Int, String>? = null
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun amSatReportsJson(vararg reports: String): String =
|
|
||||||
"""{"data":[${reports.joinToString(",")}]}"""
|
|
||||||
|
|
||||||
private fun report(id: String, name: String, status: String, epochSec: Long): String =
|
|
||||||
"""{"id":"$id","name":"$name","callsign":"BA7OPF","report":"$status","grid_square":"OL62","reported_time":"${isoUtc(epochSec)}"}"""
|
|
||||||
|
|
||||||
private fun isoUtc(epochSec: Long): String =
|
|
||||||
SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.US).apply {
|
|
||||||
timeZone = TimeZone.getTimeZone("UTC")
|
|
||||||
}.format(Date(epochSec * 1000))
|
|
||||||
+69
-148
@@ -19,7 +19,6 @@ package com.rtbishop.look4sat.core.data.repository
|
|||||||
|
|
||||||
import com.rtbishop.look4sat.core.domain.model.DataSourcesSettings
|
import com.rtbishop.look4sat.core.domain.model.DataSourcesSettings
|
||||||
import com.rtbishop.look4sat.core.domain.model.DatabaseState
|
import com.rtbishop.look4sat.core.domain.model.DatabaseState
|
||||||
import com.rtbishop.look4sat.core.domain.model.GridQso
|
|
||||||
import com.rtbishop.look4sat.core.domain.model.OtherSettings
|
import com.rtbishop.look4sat.core.domain.model.OtherSettings
|
||||||
import com.rtbishop.look4sat.core.domain.model.PassesSettings
|
import com.rtbishop.look4sat.core.domain.model.PassesSettings
|
||||||
import com.rtbishop.look4sat.core.domain.model.RCSettings
|
import com.rtbishop.look4sat.core.domain.model.RCSettings
|
||||||
@@ -33,7 +32,6 @@ import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
|
|||||||
import com.rtbishop.look4sat.core.domain.source.ILocalSource
|
import com.rtbishop.look4sat.core.domain.source.ILocalSource
|
||||||
import com.rtbishop.look4sat.core.domain.source.IRemoteSource
|
import com.rtbishop.look4sat.core.domain.source.IRemoteSource
|
||||||
import com.rtbishop.look4sat.core.domain.source.NetworkResult
|
import com.rtbishop.look4sat.core.domain.source.NetworkResult
|
||||||
import com.rtbishop.look4sat.core.domain.source.Sources
|
|
||||||
import com.rtbishop.look4sat.core.domain.utility.DataParser
|
import com.rtbishop.look4sat.core.domain.utility.DataParser
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
@@ -70,7 +68,6 @@ class DatabaseRepoTest {
|
|||||||
|
|
||||||
assertEquals(1, localSource.insertedEntries.size)
|
assertEquals(1, localSource.insertedEntries.size)
|
||||||
assertEquals(25544, localSource.insertedEntries.first().catnum)
|
assertEquals(25544, localSource.insertedEntries.first().catnum)
|
||||||
assertEquals(listOf(25544), settingsRepo.satelliteTypeIdsByType["Other"])
|
|
||||||
assertTrue(settingsRepo.databaseState.value.numberOfSatellites > 0)
|
assertTrue(settingsRepo.databaseState.value.numberOfSatellites > 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -108,51 +105,6 @@ class DatabaseRepoTest {
|
|||||||
repository.updateFromRemote()
|
repository.updateFromRemote()
|
||||||
|
|
||||||
assertTrue(localSource.insertedEntries.any { it.catnum == 25544 })
|
assertTrue(localSource.insertedEntries.any { it.catnum == 25544 })
|
||||||
assertEquals(listOf(25544), settingsRepo.satelliteTypeIdsByType["Other"])
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `remote update imports satellites from real SatNOGS TLE source`() = runTest(dispatcher) {
|
|
||||||
val satnogsUrl = Sources.satelliteDataUrls.getValue("SatNOGS")
|
|
||||||
val localSource = FakeLocalSource()
|
|
||||||
val remoteSource = FakeRemoteSource().apply {
|
|
||||||
networkStreams[satnogsUrl] = { jamxTleStream() }
|
|
||||||
}
|
|
||||||
val settingsRepo = FakeSettingsRepo(
|
|
||||||
dataSources = DataSourcesSettings(
|
|
||||||
satelliteUrls = listOf(satnogsUrl),
|
|
||||||
transceiversUrls = emptyList()
|
|
||||||
)
|
|
||||||
)
|
|
||||||
val repository = DatabaseRepo(dispatcher, dataParser, localSource, remoteSource, settingsRepo)
|
|
||||||
|
|
||||||
repository.updateFromRemote()
|
|
||||||
|
|
||||||
assertTrue(localSource.insertedEntries.any { it.name == "JAMX-0825b" && it.catnum == 98248 })
|
|
||||||
assertEquals(listOf(98248), settingsRepo.satelliteTypeIdsByType["SatNOGS"])
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `remote update persists All source type ids`() = runTest(dispatcher) {
|
|
||||||
// Regression: "All" (CelesTrak active) is a real TLE source type whose
|
|
||||||
// ids must be persisted on sync — the old `if (type == "All") return`
|
|
||||||
// silently dropped them, making the "All" filter resolve to nothing.
|
|
||||||
val allUrl = Sources.satelliteDataUrls.getValue("All")
|
|
||||||
val localSource = FakeLocalSource()
|
|
||||||
val remoteSource = FakeRemoteSource().apply {
|
|
||||||
networkStreams[allUrl] = { validCsvStream() }
|
|
||||||
}
|
|
||||||
val settingsRepo = FakeSettingsRepo(
|
|
||||||
dataSources = DataSourcesSettings(
|
|
||||||
satelliteUrls = listOf(allUrl),
|
|
||||||
transceiversUrls = emptyList()
|
|
||||||
)
|
|
||||||
)
|
|
||||||
val repository = DatabaseRepo(dispatcher, dataParser, localSource, remoteSource, settingsRepo)
|
|
||||||
|
|
||||||
repository.updateFromRemote()
|
|
||||||
|
|
||||||
assertEquals(listOf(25544), settingsRepo.satelliteTypeIdsByType["All"])
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -196,6 +148,28 @@ class DatabaseRepoTest {
|
|||||||
assertEquals(fresherEpoch, localSource.insertedEntries.single().epoch, 1e-8)
|
assertEquals(fresherEpoch, localSource.insertedEntries.single().epoch, 1e-8)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `remote update drops transceivers that are no longer published`() = runTest(dispatcher) {
|
||||||
|
val radioUrl = "https://example.com/transmitters.json"
|
||||||
|
val localSource = FakeLocalSource()
|
||||||
|
val remoteSource = FakeRemoteSource()
|
||||||
|
val settingsRepo = FakeSettingsRepo(
|
||||||
|
dataSources = DataSourcesSettings(
|
||||||
|
satelliteUrls = emptyList(),
|
||||||
|
transceiversUrls = listOf(radioUrl)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
val repository = DatabaseRepo(dispatcher, dataParser, localSource, remoteSource, settingsRepo)
|
||||||
|
|
||||||
|
remoteSource.networkStreams[radioUrl] = { twoRadiosStream() }
|
||||||
|
repository.updateFromRemote()
|
||||||
|
assertEquals(2, localSource.insertedRadios.size)
|
||||||
|
|
||||||
|
remoteSource.networkStreams[radioUrl] = { singleRadioStream() }
|
||||||
|
repository.updateFromRemote()
|
||||||
|
assertEquals(listOf("uuid-alive"), localSource.insertedRadios.map { it.uuid })
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `remote update renames satellites even when their elements are not newer`() = runTest(dispatcher) {
|
fun `remote update renames satellites even when their elements are not newer`() = runTest(dispatcher) {
|
||||||
val preferredUrl = "https://example.com/preferred.txt"
|
val preferredUrl = "https://example.com/preferred.txt"
|
||||||
@@ -248,29 +222,26 @@ class DatabaseRepoTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `remote update prunes satellites no source refreshed for a month`() = runTest(dispatcher) {
|
fun `remote update keeps the transceivers imported from a file`() = runTest(dispatcher) {
|
||||||
val url = "https://example.com/fresh.txt"
|
val radioUri = "content://imported.json"
|
||||||
|
val radioUrl = "https://example.com/transmitters.json"
|
||||||
val localSource = FakeLocalSource()
|
val localSource = FakeLocalSource()
|
||||||
val remoteSource = FakeRemoteSource().apply {
|
val remoteSource = FakeRemoteSource().apply {
|
||||||
networkStreams[url] = { fresherTleStream() }
|
fileStreams[radioUri] = { customRadioStream() }
|
||||||
|
networkStreams[radioUrl] = { singleRadioStream() }
|
||||||
}
|
}
|
||||||
val settingsRepo = FakeSettingsRepo(
|
val settingsRepo = FakeSettingsRepo(
|
||||||
dataSources = DataSourcesSettings(
|
dataSources = DataSourcesSettings(
|
||||||
satelliteUrls = listOf(url),
|
satelliteUrls = emptyList(),
|
||||||
transceiversUrls = emptyList()
|
transceiversUrls = listOf(radioUrl)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
val repository = DatabaseRepo(dispatcher, dataParser, localSource, remoteSource, settingsRepo)
|
val repository = DatabaseRepo(dispatcher, dataParser, localSource, remoteSource, settingsRepo)
|
||||||
// a satellite that disappeared from every catalog: elements stored a month and a half ago
|
repository.updateTransceiversFromFile(radioUri)
|
||||||
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()
|
repository.updateFromRemote()
|
||||||
|
|
||||||
assertEquals(listOf(25544), localSource.insertedEntries.map { it.catnum })
|
// the remote snapshot replaces what the sources publish, the imported entry stays put
|
||||||
|
assertEquals(setOf("uuid-custom", "uuid-alive"), localSource.insertedRadios.map { it.uuid }.toSet())
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun validCsvStream(): InputStream = """
|
private fun validCsvStream(): InputStream = """
|
||||||
@@ -290,21 +261,32 @@ class DatabaseRepoTest {
|
|||||||
2 25544 51.6447 309.4881 0004694 203.6966 299.8876 15.48582035312205
|
2 25544 51.6447 309.4881 0004694 203.6966 299.8876 15.48582035312205
|
||||||
""".trimIndent().byteInputStream()
|
""".trimIndent().byteInputStream()
|
||||||
|
|
||||||
private fun jamxTleStream(): InputStream = """
|
private fun twoRadiosStream(): InputStream = """
|
||||||
JAMX-0825b
|
[$radioAlive,{"uuid":"uuid-retired","description":"Retired","alive":true,"downlink_low":437800000,
|
||||||
1 98248U $todayEpoch.16675926 .00015724 00000-0 97477-3 0 00013
|
"mode":"FM","invert":false,"norad_cat_id":25544}]
|
||||||
2 98248 097.5373 310.9694 0011309 278.1232 340.7230 15.09766181000012
|
""".trimIndent().byteInputStream()
|
||||||
|
|
||||||
|
private fun singleRadioStream(): InputStream = "[$radioAlive]".byteInputStream()
|
||||||
|
|
||||||
|
private fun customRadioStream(): InputStream = """
|
||||||
|
[{"uuid":"uuid-custom","description":"Local beacon","alive":true,"downlink_low":144800000,
|
||||||
|
"mode":"FM","invert":false,"norad_cat_id":25544}]
|
||||||
""".trimIndent().byteInputStream()
|
""".trimIndent().byteInputStream()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private const val radioAlive = """{"uuid":"uuid-alive","description":"Voice repeater","alive":true,
|
||||||
|
"downlink_low":145800000,"mode":"FM","invert":false,"norad_cat_id":25544}"""
|
||||||
|
|
||||||
private class FakeRemoteSource : IRemoteSource {
|
private class FakeRemoteSource : IRemoteSource {
|
||||||
val fileStreams: MutableMap<String, () -> InputStream> = mutableMapOf()
|
val fileStreams: MutableMap<String, () -> InputStream> = mutableMapOf()
|
||||||
val networkStreams: MutableMap<String, () -> InputStream> = mutableMapOf()
|
val networkStreams: MutableMap<String, () -> InputStream> = mutableMapOf()
|
||||||
|
|
||||||
override suspend fun getFileStream(uri: String): InputStream? = fileStreams[uri]?.invoke()
|
override suspend fun getFileStream(uri: String): InputStream? = fileStreams[uri]?.invoke()
|
||||||
|
|
||||||
override suspend fun getNetworkStream(url: String): NetworkResult =
|
override suspend fun getNetworkStream(url: String): NetworkResult {
|
||||||
networkStreams[url]?.let { NetworkResult(200, it()) } ?: NetworkResult(404, null)
|
val stream = networkStreams[url]?.invoke()
|
||||||
|
return if (stream != null) NetworkResult(200, stream) else NetworkResult(404, null)
|
||||||
|
}
|
||||||
|
|
||||||
override suspend fun getAmSatCatalog(): String? = null
|
override suspend fun getAmSatCatalog(): String? = null
|
||||||
|
|
||||||
@@ -315,12 +297,12 @@ private class FakeRemoteSource : IRemoteSource {
|
|||||||
|
|
||||||
private class FakeLocalSource : ILocalSource {
|
private class FakeLocalSource : ILocalSource {
|
||||||
val insertedEntries = mutableListOf<OrbitalData>()
|
val insertedEntries = mutableListOf<OrbitalData>()
|
||||||
private val insertedRadios = mutableListOf<SatRadio>()
|
val insertedRadios = mutableListOf<SatRadio>()
|
||||||
|
private val customRadios = mutableListOf<String>()
|
||||||
|
|
||||||
override suspend fun getEntriesTotal(): Int = insertedEntries.size
|
override suspend fun getEntriesTotal(): Int = insertedEntries.size
|
||||||
|
|
||||||
override suspend fun getEntriesList(): List<SatItem> =
|
override suspend fun getEntriesList(): List<SatItem> = emptyList()
|
||||||
insertedEntries.map { SatItem(it.catnum, it.name) }
|
|
||||||
|
|
||||||
override suspend fun getEntriesWithIds(ids: List<Int>): List<OrbitalObject> = emptyList()
|
override suspend fun getEntriesWithIds(ids: List<Int>): List<OrbitalObject> = emptyList()
|
||||||
|
|
||||||
@@ -348,36 +330,39 @@ private class FakeLocalSource : ILocalSource {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun getIdsWithModes(modes: List<String>): List<Int> = emptyList()
|
override suspend fun getIdsWithModes(modes: List<String>): List<Int> = emptyList()
|
||||||
override suspend fun getIdsWithModesAndUplink(modes: List<String>): List<Int> = emptyList()
|
|
||||||
override suspend fun getIdsWithModesAndAmateur(modes: List<String>): List<Int> = emptyList()
|
|
||||||
|
|
||||||
override suspend fun getRadiosTotal(): Int = insertedRadios.size
|
override suspend fun getRadiosTotal(): Int = insertedRadios.size
|
||||||
|
|
||||||
override suspend fun getRadiosWithId(id: Int): List<SatRadio> = emptyList()
|
override suspend fun getRadiosWithId(id: Int): List<SatRadio> = emptyList()
|
||||||
|
|
||||||
override suspend fun insertRadios(radios: List<SatRadio>, isCustom: Boolean) {
|
override suspend fun insertRadios(radios: List<SatRadio>, isCustom: Boolean) {
|
||||||
insertedRadios += radios.map { it.copy(isCustom = isCustom) }
|
insertedRadios.removeAll { stored -> radios.any { radio -> radio.uuid == stored.uuid } }
|
||||||
}
|
insertedRadios += radios
|
||||||
|
if (isCustom) customRadios += radios.map { radio -> radio.uuid }
|
||||||
override suspend fun deleteManagedRadios() {
|
else customRadios -= radios.map { radio -> radio.uuid }.toSet()
|
||||||
insertedRadios.removeAll { !it.isCustom }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun deleteRadios() {
|
override suspend fun deleteRadios() {
|
||||||
insertedRadios.clear()
|
insertedRadios.clear()
|
||||||
|
customRadios.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun deleteManagedRadios() {
|
||||||
|
insertedRadios.removeAll { radio -> radio.uuid !in customRadios }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private class FakeSettingsRepo(dataSources: DataSourcesSettings = defaultDataSourcesSettings()) : ISettingsRepo {
|
private class FakeSettingsRepo(dataSources: DataSourcesSettings = defaultDataSourcesSettings()) : ISettingsRepo {
|
||||||
|
|
||||||
override val appVersionName: String = "test"
|
override val appVersionName: String = "test"
|
||||||
|
override val appVersionCode: Long = 1L
|
||||||
|
|
||||||
override val selectedIds: StateFlow<List<Int>> = MutableStateFlow(emptyList())
|
override val selectedIds: StateFlow<List<Int>> = MutableStateFlow(emptyList())
|
||||||
|
|
||||||
override val selectedTypes: StateFlow<List<String>> = MutableStateFlow(emptyList())
|
override val selectedSatModes: StateFlow<List<String>> = MutableStateFlow(emptyList())
|
||||||
|
|
||||||
override val passesSettings: StateFlow<PassesSettings> = MutableStateFlow(
|
override val passesSettings: StateFlow<PassesSettings> = MutableStateFlow(
|
||||||
PassesSettings(hoursAhead = 24, minElevation = 0.0, selectedModes = emptyList())
|
PassesSettings(hoursAhead = 24, minElevation = 0.0)
|
||||||
)
|
)
|
||||||
|
|
||||||
override val stationPosition: StateFlow<GeoPos> = MutableStateFlow(GeoPos(0.0, 0.0))
|
override val stationPosition: StateFlow<GeoPos> = MutableStateFlow(GeoPos(0.0, 0.0))
|
||||||
@@ -389,23 +374,20 @@ private class FakeSettingsRepo(dataSources: DataSourcesSettings = defaultDataSou
|
|||||||
)
|
)
|
||||||
|
|
||||||
override val otherSettings: StateFlow<OtherSettings> = MutableStateFlow(
|
override val otherSettings: StateFlow<OtherSettings> = MutableStateFlow(
|
||||||
OtherSettings(
|
OtherSettings(false, false, false, false, false, false, false, false)
|
||||||
false, false, false, false, false, false, false,
|
|
||||||
shouldSeeWarning = false, shouldSeeWhatsNew = false
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
override val dataSourcesSettings: MutableStateFlow<DataSourcesSettings> = MutableStateFlow(dataSources)
|
override val dataSourcesSettings: MutableStateFlow<DataSourcesSettings> = MutableStateFlow(dataSources)
|
||||||
|
|
||||||
|
override val dataSourcesStatus: MutableStateFlow<Map<String, Int>> = MutableStateFlow(emptyMap())
|
||||||
|
|
||||||
override val radioControlSettings: StateFlow<RadioControlSettings> = MutableStateFlow(
|
override val radioControlSettings: StateFlow<RadioControlSettings> = MutableStateFlow(
|
||||||
RadioControlSettings(false, RadioControlSettings.MODEL_YAESU_FT817, "", "", "", "", 9600)
|
RadioControlSettings(false, RadioControlSettings.MODEL_YAESU_FT817, "", "", "", "", 9600)
|
||||||
)
|
)
|
||||||
|
|
||||||
val satelliteTypeIdsByType = mutableMapOf<String, List<Int>>()
|
|
||||||
|
|
||||||
override fun setSelectedIds(ids: List<Int>) = Unit
|
override fun setSelectedIds(ids: List<Int>) = Unit
|
||||||
|
|
||||||
override fun setSelectedTypes(types: List<String>) = Unit
|
override fun setSelectedSatModes(modes: List<String>) = Unit
|
||||||
|
|
||||||
override fun setPassesSettings(settings: PassesSettings) = Unit
|
override fun setPassesSettings(settings: PassesSettings) = Unit
|
||||||
|
|
||||||
@@ -415,13 +397,6 @@ private class FakeSettingsRepo(dataSources: DataSourcesSettings = defaultDataSou
|
|||||||
|
|
||||||
override fun setStationPosition(locator: String): Boolean = true
|
override fun setStationPosition(locator: String): Boolean = true
|
||||||
|
|
||||||
override fun getCurrentGrid(): String? = null
|
|
||||||
|
|
||||||
override fun getSatelliteTypesIds(types: List<String>): List<Int> = emptyList()
|
|
||||||
|
|
||||||
override fun setSatelliteTypeIds(type: String, ids: List<Int>) {
|
|
||||||
satelliteTypeIdsByType[type] = ids
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun updateDatabaseState(state: DatabaseState) {
|
override fun updateDatabaseState(state: DatabaseState) {
|
||||||
databaseState.value = state
|
databaseState.value = state
|
||||||
@@ -435,73 +410,19 @@ private class FakeSettingsRepo(dataSources: DataSourcesSettings = defaultDataSou
|
|||||||
dataSourcesSettings.value = settings
|
dataSourcesSettings.value = settings
|
||||||
}
|
}
|
||||||
|
|
||||||
override val dataSourcesStatus: StateFlow<Map<String, Int>> = MutableStateFlow(emptyMap())
|
|
||||||
|
|
||||||
override fun updateDataSourcesStatus(status: Map<String, Int>) {
|
override fun updateDataSourcesStatus(status: Map<String, Int>) {
|
||||||
(dataSourcesStatus as? MutableStateFlow)?.value = status
|
dataSourcesStatus.value = status
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun updateRadioControlSettings(settings: RadioControlSettings) = Unit
|
override fun updateRadioControlSettings(settings: RadioControlSettings) = Unit
|
||||||
|
|
||||||
override fun getSatelliteOffset(catnum: Int): String = ""
|
override fun getSatelliteOffset(catnum: Int): String = ""
|
||||||
override fun getSatelliteMode(catnum: Int): String = ""
|
|
||||||
override fun setSatelliteMode(catnum: Int, mode: String) {}
|
|
||||||
|
|
||||||
override fun setSatelliteOffset(catnum: Int, offset: String) = Unit
|
override fun setSatelliteOffset(catnum: Int, offset: String) = Unit
|
||||||
|
|
||||||
override fun getAmSatCallsign(): String = ""
|
override fun getAmSatCallsign(): String = ""
|
||||||
|
|
||||||
override fun setAmSatCallsign(callsign: String) = Unit
|
override fun setAmSatCallsign(callsign: String) = Unit
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
override 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>())
|
|
||||||
|
|
||||||
override fun setWorkedGrids(grids: Set<String>) {
|
|
||||||
workedGrids.value = grids
|
|
||||||
}
|
|
||||||
|
|
||||||
override val lotwSettings: StateFlow<com.rtbishop.look4sat.core.domain.model.LoTWSettings> =
|
|
||||||
MutableStateFlow(com.rtbishop.look4sat.core.domain.model.LoTWSettings())
|
|
||||||
|
|
||||||
override fun updateLoTWSettings(settings: com.rtbishop.look4sat.core.domain.model.LoTWSettings) = Unit
|
|
||||||
override fun getLastLotwSyncDate(): String = ""
|
|
||||||
override fun setLastLotwSyncDate(date: String) = Unit
|
|
||||||
override fun getLastLotwSyncCallsign(): String = ""
|
|
||||||
override fun setLastLotwSyncCallsign(callsign: String) = Unit
|
|
||||||
override fun getWavelogStations(): List<com.rtbishop.look4sat.core.domain.repository.WavelogStationInfo> = emptyList()
|
|
||||||
override fun setWavelogStations(stations: List<com.rtbishop.look4sat.core.domain.repository.WavelogStationInfo>) = Unit
|
|
||||||
override fun getWavelogSyncCursors(): Map<String, Long> = emptyMap()
|
|
||||||
override fun setWavelogSyncCursors(cursors: Map<String, Long>) = Unit
|
|
||||||
override fun getWavelogSyncUrl(): String = ""
|
|
||||||
override fun setWavelogSyncUrl(url: String) = Unit
|
|
||||||
override fun getLastWavelogSyncEpochMs(): Long = 0L
|
|
||||||
override fun setLastWavelogSyncEpochMs(value: Long) = Unit
|
|
||||||
|
|
||||||
override fun getWorkedGridQsos(): Map<String, List<com.rtbishop.look4sat.core.domain.model.GridQso>> = emptyMap()
|
|
||||||
|
|
||||||
override fun setWorkedGridQsos(qsos: Map<String, List<com.rtbishop.look4sat.core.domain.model.GridQso>>) = Unit
|
|
||||||
|
|
||||||
override fun getRoamedGrids(): Set<String> = emptySet()
|
|
||||||
|
|
||||||
private val roamedGrids = MutableStateFlow(emptySet<String>())
|
|
||||||
|
|
||||||
override fun setRoamedGrids(grids: Set<String>) {
|
|
||||||
roamedGrids.value = grids
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun getMarkedGridStations(): Map<String, List<com.rtbishop.look4sat.core.domain.model.MarkedStation>> = emptyMap()
|
|
||||||
|
|
||||||
override fun setMarkedGridStations(stations: Map<String, List<com.rtbishop.look4sat.core.domain.model.MarkedStation>>) = Unit
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun defaultDataSourcesSettings(): DataSourcesSettings {
|
private fun defaultDataSourcesSettings(): DataSourcesSettings {
|
||||||
|
|||||||
-427
@@ -1,427 +0,0 @@
|
|||||||
/*
|
|
||||||
* 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
|
|
||||||
import org.junit.Assert.assertEquals
|
|
||||||
import org.junit.Assert.assertNull
|
|
||||||
import org.junit.Assert.assertTrue
|
|
||||||
import org.junit.Test
|
|
||||||
|
|
||||||
class LoTWRepositoryTest {
|
|
||||||
|
|
||||||
private val repo = LoTWRepository()
|
|
||||||
|
|
||||||
private fun report(vararg records: String): String =
|
|
||||||
buildString {
|
|
||||||
append("<ADIF_VERS:5>3.1.4\n")
|
|
||||||
append("<PROGRAMID:8>look4sat\n")
|
|
||||||
append("<eoh>\n")
|
|
||||||
records.forEach { append(it).append("\n") }
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun parseRejectsBodyWithoutEoh() {
|
|
||||||
assertNull(repo.parseConfirmedGrids("<HTML>Username/password incorrect</HTML>"))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun parseCollectsOnlySatelliteGrids() {
|
|
||||||
// Satellite QSO: PROP_MODE SAT, confirmed, opponent grid.
|
|
||||||
val satQso = "<CALL:6>BA7OPF\n" +
|
|
||||||
"<QSO_DATE:8>20260820\n" +
|
|
||||||
"<PROP_MODE:3>SAT\n" +
|
|
||||||
"<SAT_NAME:5>FO-29\n" +
|
|
||||||
"<GRIDSQUARE:4>NL47\n" +
|
|
||||||
"<EOR>\n"
|
|
||||||
// Ground QSO: same grid but no PROP_MODE -> must be excluded.
|
|
||||||
val groundQso = "<CALL:6>BA7OPF\n" +
|
|
||||||
"<QSO_DATE:8>20260821\n" +
|
|
||||||
"<GRIDSQUARE:4>NL47\n" +
|
|
||||||
"<EOR>\n"
|
|
||||||
val result = repo.parseConfirmedGrids(report(satQso, groundQso))
|
|
||||||
assertEquals(setOf("NL47"), result)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun parseSupportsVuccGridPairsAndSixCharGrids() {
|
|
||||||
val satQso = "<PROP_MODE:3>SAT\n" +
|
|
||||||
"<SAT_NAME:5>SO-50\n" +
|
|
||||||
"<GRIDSQUARE:6>OM60IL\n" +
|
|
||||||
"<VUCC_GRIDS:11>EN52en,EN53fa\n" +
|
|
||||||
"<EOR>\n"
|
|
||||||
val result = repo.parseConfirmedGrids(report(satQso))
|
|
||||||
assertEquals(setOf("OM60", "EN52", "EN53"), result)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun parsePropModeDoesNotLeakAcrossRecords() {
|
|
||||||
// PROP_MODE SAT record first, then a ground QSO with a grid — the second
|
|
||||||
// record must not inherit the satellite flag after its own <EOR>.
|
|
||||||
val satQso = "<PROP_MODE:3>SAT\n<GRIDSQUARE:4>OL62\n<EOR>\n"
|
|
||||||
val groundQso = "<GRIDSQUARE:4>PM00\n<EOR>\n"
|
|
||||||
val result = repo.parseConfirmedGrids(report(satQso, groundQso))
|
|
||||||
assertEquals(setOf("OL62"), result)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun parseHandlesAlphabeticalFieldOrder() {
|
|
||||||
// Real lotwreport.adi emits fields in ALPHABETICAL order: GRIDSQUARE (G)
|
|
||||||
// and VUCC_GRIDS come BEFORE PROP_MODE (P) inside each record. The old
|
|
||||||
// sequential gate (add only while propMode == SAT) dropped every grid on
|
|
||||||
// real reports — the buffered parser must collect them regardless of
|
|
||||||
// field order and filter at <EOR>.
|
|
||||||
val satQso = "<CALL:6>BA7OPF\n" +
|
|
||||||
"<GRIDSQUARE:4>NL47\n" +
|
|
||||||
"<MODE:3>FM\n" +
|
|
||||||
"<PROP_MODE:3>SAT\n" +
|
|
||||||
"<SAT_NAME:5>FO-29\n" +
|
|
||||||
"<VUCC_GRIDS:11>EN52en,EN53fa\n" +
|
|
||||||
"<EOR>\n"
|
|
||||||
val groundQso = "<CALL:6>BA7OPF\n" +
|
|
||||||
"<GRIDSQUARE:4>PM95\n" +
|
|
||||||
"<QSO_DATE:8>20260821\n" +
|
|
||||||
"<EOR>\n"
|
|
||||||
val result = repo.parseConfirmedGrids(report(satQso, groundQso))
|
|
||||||
assertEquals(setOf("NL47", "EN52", "EN53"), result)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun parseReturnsEmptySetForReportWithoutGrids() {
|
|
||||||
val satQso = "<PROP_MODE:3>SAT\n<SAT_NAME:5>AO-07\n<EOR>\n"
|
|
||||||
assertEquals(emptySet<String>(), repo.parseConfirmedGrids(report(satQso)))
|
|
||||||
}
|
|
||||||
|
|
||||||
// region parseConfirmedGridQsos
|
|
||||||
|
|
||||||
private val satQsoFull = "<CALL:5>A50QO\n" +
|
|
||||||
"<QSO_DATE:8>20260820\n" +
|
|
||||||
"<TIME_ON:6>113045\n" +
|
|
||||||
"<PROP_MODE:3>SAT\n" +
|
|
||||||
"<SAT_NAME:5>FO-29\n" +
|
|
||||||
"<MODE:2>CW\n" +
|
|
||||||
"<BAND:3>70CM\n" +
|
|
||||||
"<BAND_RX:3>2M\n" +
|
|
||||||
"<GRIDSQUARE:4>NL47\n" +
|
|
||||||
"<EOR>\n"
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun parseQsosExtractsSatelliteDetail() {
|
|
||||||
val result = repo.parseConfirmedGridQsos(report(satQsoFull))!!
|
|
||||||
val qsos = result["NL47"]!!
|
|
||||||
assertEquals(1, qsos.size)
|
|
||||||
val qso = qsos[0]
|
|
||||||
assertEquals("A50QO", qso.call)
|
|
||||||
assertEquals("FO-29", qso.satName)
|
|
||||||
assertEquals("CW", qso.mode)
|
|
||||||
// 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)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun parseQsosExcludesGroundQsos() {
|
|
||||||
val groundQso = "<CALL:6>BA7OPF\n<QSO_DATE:8>20260821\n<GRIDSQUARE:4>NL47\n<EOR>\n"
|
|
||||||
val result = repo.parseConfirmedGridQsos(report(satQsoFull, groundQso))!!
|
|
||||||
assertEquals(1, result["NL47"]!!.size)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun parseQsosVuccPairFeedsBothGrids() {
|
|
||||||
val qso = "<PROP_MODE:3>SAT\n<SAT_NAME:5>SO-50\n" +
|
|
||||||
"<QSO_DATE:8>20260819\n<TIME_ON:4>1231\n" +
|
|
||||||
"<VUCC_GRIDS:11>EN52en,EN53fa\n<EOR>\n"
|
|
||||||
val result = repo.parseConfirmedGridQsos(report(qso))!!
|
|
||||||
assertEquals(setOf("EN52", "EN53"), result.keys)
|
|
||||||
assertEquals(result["EN52"]!!.first(), result["EN53"]!!.first())
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun parseQsosVuccQuadrupleFeedsAllFourGrids() {
|
|
||||||
// VUCC allows up to four grids in one contact.
|
|
||||||
val qso = "<PROP_MODE:3>SAT\n<SAT_NAME:5>SO-50\n" +
|
|
||||||
"<QSO_DATE:8>20260819\n<TIME_ON:4>1231\n" +
|
|
||||||
"<VUCC_GRIDS:23>EN52en,EN53fa,EN42gj,EN43kh\n<EOR>\n"
|
|
||||||
val result = repo.parseConfirmedGridQsos(report(qso))!!
|
|
||||||
assertEquals(setOf("EN52", "EN53", "EN42", "EN43"), result.keys)
|
|
||||||
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>"))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun parseQsosCapturesOwnGridPerQso() {
|
|
||||||
// MY_GRIDSQUARE arrives BEFORE PROP_MODE (ADIF fields are emitted
|
|
||||||
// alphabetically); the per-record buffer must still attach it to the QSO.
|
|
||||||
val qso1 = "<CALL:5>A50QO\n<QSO_DATE:8>20260820\n<MY_GRIDSQUARE:4>OL62\n" +
|
|
||||||
"<PROP_MODE:3>SAT\n<SAT_NAME:5>FO-29\n<MODE:2>CW\n" +
|
|
||||||
"<BAND:3>70CM\n<BAND_RX:3>2M\n<GRIDSQUARE:4>NL47\n<EOR>\n"
|
|
||||||
val qso2 = "<CALL:6>BG7ZFK\n<QSO_DATE:8>20260819\n<MY_GRIDSQUARE:4>OL72\n" +
|
|
||||||
"<PROP_MODE:3>SAT\n<SAT_NAME:5>SO-50\n<MODE:3>FM\n" +
|
|
||||||
"<BAND:3>70CM\n<BAND_RX:3>2M\n<GRIDSQUARE:4>OK48\n<EOR>\n"
|
|
||||||
val result = repo.parseConfirmedGridQsos(report(qso1, qso2))!!
|
|
||||||
assertEquals("OL62", result["NL47"]!!.first().myGrid)
|
|
||||||
assertEquals("OL72", result["OK48"]!!.first().myGrid)
|
|
||||||
// Ground QSO (no PROP_MODE=SAT) must not leak its MY_GRIDSQUARE.
|
|
||||||
val ground = "<CALL:6>BA7OPF\n<QSO_DATE:8>20260821\n<MY_GRIDSQUARE:4>OL62\n<GRIDSQUARE:4>NL47\n<EOR>\n"
|
|
||||||
assertTrue(repo.parseConfirmedGridQsos(report(qso1, ground))!!.values.all { it.all { q -> q.myGrid == "OL62" } })
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun parseQsosCapturesMyVuccGridsMultiGridStation() {
|
|
||||||
// A station location (台址) can span several grids: LoTW emits
|
|
||||||
// <MY_VUCC_GRIDS> (comma-separated) with MY_GRIDSQUARE absent. Both
|
|
||||||
// grids must land in myGrids, and myGrid (back-compat) = the first.
|
|
||||||
val qso = "<CALL:5>A50QO\n<QSO_DATE:8>20260820\n<TIME_ON:4>1130\n" +
|
|
||||||
"<PROP_MODE:3>SAT\n<SAT_NAME:5>RS-44\n<MODE:2>CW\n" +
|
|
||||||
"<MY_VUCC_GRIDS:11>OM60,OM50\n<GRIDSQUARE:4>NL47\n<EOR>\n"
|
|
||||||
val result = repo.parseConfirmedGridQsos(report(qso))!!
|
|
||||||
val parsed = result["NL47"]!!.first()
|
|
||||||
assertEquals(setOf("OM60", "OM50"), parsed.myGrids)
|
|
||||||
assertEquals("OM60", parsed.myGrid)
|
|
||||||
// 6-char fields are truncated to 4 like every other grid path.
|
|
||||||
val six = "<PROP_MODE:3>SAT\n<SAT_NAME:5>RS-44\n" +
|
|
||||||
"<MY_VUCC_GRIDS:17>OM60IL70,OM50MA20\n<GRIDSQUARE:4>OK48\n<EOR>\n"
|
|
||||||
assertEquals(setOf("OM60", "OM50"), repo.parseConfirmedGridQsos(report(six))!!["OK48"]!!.first().myGrids)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun parseQsosBuildsStationKeyFromMyFields() {
|
|
||||||
// 台址 identity = the MY_* station snapshot (callsign + dxcc + state +
|
|
||||||
// CQ/ITU + IOTA + country). A record with the full snapshot yields the
|
|
||||||
// stable key the operated-grid selector groups by.
|
|
||||||
val qso = "<CALL:5>A50QO\n<QSO_DATE:8>20260820\n<TIME_ON:4>1130\n" +
|
|
||||||
"<PROP_MODE:3>SAT\n<SAT_NAME:5>RS-44\n" +
|
|
||||||
"<STATION_CALLSIGN:6>BH6RJD\n<MY_DXCC:3>318\n<MY_STATE:11>JS // Jiangsu\n" +
|
|
||||||
"<MY_CQ_ZONE:2>24\n<MY_ITU_ZONE:2>44\n<MY_COUNTRY:5>CHINA\n" +
|
|
||||||
"<GRIDSQUARE:4>NL47\n<EOR>\n"
|
|
||||||
val parsed = repo.parseConfirmedGridQsos(report(qso))!!["NL47"]!!.first()
|
|
||||||
assertEquals("BH6RJD", parsed.myCallsign)
|
|
||||||
assertEquals("BH6RJD|318|JS|24|44||CHINA", parsed.stationKey)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun parseQsosStationKeyDistinguishesLocationsUnderSameCallsign() {
|
|
||||||
// One callsign can own several 台址 (e.g. BH6RJD with a Hubei location
|
|
||||||
// and a Zhejiang location); different MY_STATE -> different stationKey.
|
|
||||||
val hb = "<CALL:5>A50QO\n<QSO_DATE:8>20260820\n<TIME_ON:4>1130\n" +
|
|
||||||
"<PROP_MODE:3>SAT\n<SAT_NAME:5>RS-44\n" +
|
|
||||||
"<STATION_CALLSIGN:6>BH6RJD\n<MY_STATE:11>HB // Hubei\n<GRIDSQUARE:4>NL47\n<EOR>\n"
|
|
||||||
val zj = "<CALL:5>BG7ZFK\n<QSO_DATE:8>20260819\n<TIME_ON:4>1130\n" +
|
|
||||||
"<PROP_MODE:3>SAT\n<SAT_NAME:5>SO-50\n" +
|
|
||||||
"<STATION_CALLSIGN:6>BH6RJD\n<MY_STATE:11>ZJ // Zhejiang\n<GRIDSQUARE:4>OK48\n<EOR>\n"
|
|
||||||
val result = repo.parseConfirmedGridQsos(report(hb, zj))!!
|
|
||||||
assertTrue(result["NL47"]!!.first().stationKey != result["OK48"]!!.first().stationKey)
|
|
||||||
}
|
|
||||||
|
|
||||||
// endregion
|
|
||||||
|
|
||||||
// region parseRoamedGrids (MY_GRIDSQUARE = grids the account operated from)
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun parseRoamedGridsCollectsOwnStationGrids() {
|
|
||||||
// Satellite QSOs carry MY_GRIDSQUARE = the grid the account itself
|
|
||||||
// operated from (the "roamed/activated" set shown as blue stripes).
|
|
||||||
val qso1 = "<CALL:6>BA7OPF\n<QSO_DATE:8>20260820\n<PROP_MODE:3>SAT\n<SAT_NAME:5>IO-86\n" +
|
|
||||||
"<MY_GRIDSQUARE:4>OL62\n<GRIDSQUARE:4>PM95\n<EOR>\n"
|
|
||||||
val qso2 = "<CALL:6>BA7OPF\n<QSO_DATE:8>20260901\n<PROP_MODE:3>SAT\n<SAT_NAME:5>IO-86\n" +
|
|
||||||
"<MY_GRIDSQUARE:4>OL72\n<GRIDSQUARE:4>NL47\n<EOR>\n"
|
|
||||||
assertEquals(setOf("OL62", "OL72"), repo.parseRoamedGrids(report(qso1, qso2)))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun parseRoamedGridsIgnoresGroundQsos() {
|
|
||||||
val ground = "<CALL:6>BA7OPF\n<QSO_DATE:8>20260821\n<MY_GRIDSQUARE:4>OL72\n<EOR>\n"
|
|
||||||
assertEquals(emptySet<String>(), repo.parseRoamedGrids(report(ground)))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun parseRoamedGridsDoesNotConfuseGridsquareWithMyGridsquare() {
|
|
||||||
// GRIDSQUARE (opposite station's grid) must never leak into the roamed set.
|
|
||||||
val qso = "<PROP_MODE:3>SAT\n<SAT_NAME:5>SO-50\n<MY_GRIDSQUARE:4>OL62\n" +
|
|
||||||
"<GRIDSQUARE:6>OM60IL\n<EOR>\n"
|
|
||||||
assertEquals(setOf("OL62"), repo.parseRoamedGrids(report(qso)))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun parseRoamedGridsTruncatesSixCharToFour() {
|
|
||||||
val qso = "<PROP_MODE:3>SAT\n<SAT_NAME:5>IO-86\n<MY_GRIDSQUARE:6>OL62AA\n<EOR>\n"
|
|
||||||
assertEquals(setOf("OL62"), repo.parseRoamedGrids(report(qso)))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun parseRoamedGridsHandlesAlphabeticalFieldOrder() {
|
|
||||||
// Real lotwreport.adi emits fields alphabetically, so <MY_GRIDSQUARE>
|
|
||||||
// (M) arrives BEFORE <PROP_MODE> (P). The old sequential gate (add only
|
|
||||||
// while propMode == SAT) silently returned an EMPTY set on real reports —
|
|
||||||
// this is the exact bug that made the blue roamed stripes never appear.
|
|
||||||
val qso = "<CALL:6>BA7OPF\n" +
|
|
||||||
"<GRIDSQUARE:4>PM95\n" +
|
|
||||||
"<MY_GRIDSQUARE:6>OL72XX\n" +
|
|
||||||
"<PROP_MODE:3>SAT\n" +
|
|
||||||
"<SAT_NAME:5>IO-86\n" +
|
|
||||||
"<EOR>\n"
|
|
||||||
assertEquals(setOf("OL72"), repo.parseRoamedGrids(report(qso)))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun parseRoamedGridsRejectsBodyWithoutEoh() {
|
|
||||||
assertNull(repo.parseRoamedGrids("<HTML>Username/password incorrect</HTML>"))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun parseRoamedGridsCollectsMyVuccGrids() {
|
|
||||||
// Multi-grid 台址: MY_VUCC_GRIDS carries the extra grids (MY_GRIDSQUARE
|
|
||||||
// may be absent); every field must reach the roamed set for the stripes.
|
|
||||||
val qso = "<PROP_MODE:3>SAT\n<SAT_NAME:5>RS-44\n" +
|
|
||||||
"<MY_VUCC_GRIDS:11>OM60,OM50\n<GRIDSQUARE:4>NL47\n<EOR>\n"
|
|
||||||
assertEquals(setOf("OM60", "OM50"), repo.parseRoamedGrids(report(qso)))
|
|
||||||
// Combined with MY_GRIDSQUARE the union is kept.
|
|
||||||
val both = "<PROP_MODE:3>SAT\n<SAT_NAME:5>RS-44\n" +
|
|
||||||
"<MY_GRIDSQUARE:4>OL62\n<MY_VUCC_GRIDS:11>OM60,OM50\n<GRIDSQUARE:4>NL47\n<EOR>\n"
|
|
||||||
assertEquals(setOf("OL62", "OM60", "OM50"), repo.parseRoamedGrids(report(both)))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun parseRoamedGridsUppercasesLowercaseMyGrid() {
|
|
||||||
// LoTW emits the <eor> tag lowercase, so field CASE is not guaranteed:
|
|
||||||
// a lowercase MY_GRIDSQUARE must still match the overlay's uppercase
|
|
||||||
// labels (regression: take(4) without .uppercase() silently dropped
|
|
||||||
// stripes for lowercase grids).
|
|
||||||
val qso = "<PROP_MODE:3>SAT\n<SAT_NAME:5>IO-86\n" +
|
|
||||||
"<MY_GRIDSQUARE:4>ol62\n<GRIDSQUARE:4>PM95\n<EOR>\n"
|
|
||||||
assertEquals(setOf("OL62"), repo.parseRoamedGrids(report(qso)))
|
|
||||||
}
|
|
||||||
|
|
||||||
// endregion
|
|
||||||
|
|
||||||
// region failure classification (fetchReportBody -> LoTWResult mapping)
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun failureClassificationMapsEachException() {
|
|
||||||
// LoTWRepository.toResult() must keep each failure cause distinct so
|
|
||||||
// the UI can show a specific message per cause.
|
|
||||||
assertEquals(
|
|
||||||
LoTWResult.BadCredentials,
|
|
||||||
repo.toResult(LoTWRepository.CredentialsException())
|
|
||||||
)
|
|
||||||
assertEquals(
|
|
||||||
LoTWResult.RateLimited,
|
|
||||||
repo.toResult(LoTWRepository.RateLimitException())
|
|
||||||
)
|
|
||||||
assertEquals(
|
|
||||||
LoTWResult.Timeout,
|
|
||||||
repo.toResult(LoTWRepository.TimeoutException("read timed out"))
|
|
||||||
)
|
|
||||||
val net = repo.toResult(java.io.IOException("HTTP 500"))
|
|
||||||
assertEquals(LoTWResult.NetworkError("HTTP 500"), net)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun parseQsosExtractsAwardFields() {
|
|
||||||
// Award statistics rely on the DXCC/CQZ/STATE fields coming straight
|
|
||||||
// from LoTW's qso_qsldetail report (STATE depends on DXCC). LoTW
|
|
||||||
// writes STATE as "CODE // NAME" (verified with a real report 2026-09);
|
|
||||||
// the parser must strip the name suffix and keep the bare code.
|
|
||||||
val qso = "<CALL:5>BG7XYZ\n" +
|
|
||||||
"<QSO_DATE:8>20260820\n<TIME_ON:4>1130\n" +
|
|
||||||
"<PROP_MODE:3>SAT\n<SAT_NAME:5>FO-29\n<MODE:2>CW\n" +
|
|
||||||
"<DXCC:3>318\n<COUNTRY:5>CHINA\n<CQZ:2>24\n<STATE:13>GD // Guangdong\n" +
|
|
||||||
"<GRIDSQUARE:4>OL62\n<EOR>\n"
|
|
||||||
val result = repo.parseConfirmedGridQsos(report(qso))!!
|
|
||||||
val parsed = result["OL62"]!!.first()
|
|
||||||
assertEquals(318, parsed.dxcc)
|
|
||||||
assertEquals("CHINA", parsed.country)
|
|
||||||
assertEquals(24, parsed.cqz)
|
|
||||||
assertEquals("GD", parsed.state)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun parseQsosStripsStateNameSuffixForAllEntities() {
|
|
||||||
// Japan prefecture: "34 // Tottori-ken" -> "34" (WAJA matching needs
|
|
||||||
// the 2-digit code). US states also arrive as "CODE // Name".
|
|
||||||
val jp = "<CALL:5>JH0ABC\n<QSO_DATE:8>20260820\n<TIME_ON:4>1130\n" +
|
|
||||||
"<PROP_MODE:3>SAT\n<SAT_NAME:5>RS-44\n<DXCC:3>339\n<COUNTRY:7>JAPAN\n" +
|
|
||||||
"<STATE:18>34 // Tottori-ken\n<GRIDSQUARE:4>PM95\n<EOR>\n"
|
|
||||||
val us = "<CALL:5>K1ABC\n<QSO_DATE:8>20260820\n<TIME_ON:4>1130\n" +
|
|
||||||
"<PROP_MODE:3>SAT\n<SAT_NAME:5>AO-07\n<DXCC:3>291\n<COUNTRY:12>UNITED STATES\n" +
|
|
||||||
"<STATE:22>CA // California\n<GRIDSQUARE:4>EM40\n<EOR>\n"
|
|
||||||
val result = repo.parseConfirmedGridQsos(report(jp, us))!!
|
|
||||||
assertEquals("34", result["PM95"]!!.first().state)
|
|
||||||
assertEquals("CA", result["EM40"]!!.first().state)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun parseQsosLeavesAwardFieldsNullWhenAbsent() {
|
|
||||||
val qso = "<CALL:5>JH0ABC\n<QSO_DATE:8>20260820\n<TIME_ON:4>1130\n" +
|
|
||||||
"<PROP_MODE:3>SAT\n<SAT_NAME:5>RS-44\n<GRIDSQUARE:4>PM95\n<EOR>\n"
|
|
||||||
val result = repo.parseConfirmedGridQsos(report(qso))!!
|
|
||||||
val parsed = result["PM95"]!!.first()
|
|
||||||
assertNull(parsed.dxcc)
|
|
||||||
assertNull(parsed.country)
|
|
||||||
assertNull(parsed.cqz)
|
|
||||||
assertNull(parsed.state)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun parseQsosAwardFieldsDoNotLeakAcrossRecords() {
|
|
||||||
val withFields = "<CALL:5>BG7AAA\n<QSO_DATE:8>20260820\n<TIME_ON:4>1130\n" +
|
|
||||||
"<PROP_MODE:3>SAT\n<SAT_NAME:5>FO-29\n<DXCC:3>318\n<CQZ:2>24\n<STATE:2>GD\n" +
|
|
||||||
"<GRIDSQUARE:4>OL62\n<EOR>\n"
|
|
||||||
val withoutFields = "<CALL:5>JH0BBB\n<QSO_DATE:8>20260821\n<TIME_ON:4>1130\n" +
|
|
||||||
"<PROP_MODE:3>SAT\n<SAT_NAME:5>RS-44\n<GRIDSQUARE:4>PM95\n<EOR>\n"
|
|
||||||
val result = repo.parseConfirmedGridQsos(report(withFields, withoutFields))!!
|
|
||||||
assertNull(result["PM95"]!!.first().dxcc)
|
|
||||||
assertNull(result["PM95"]!!.first().cqz)
|
|
||||||
assertNull(result["PM95"]!!.first().state)
|
|
||||||
}
|
|
||||||
|
|
||||||
// endregion
|
|
||||||
}
|
|
||||||
-502
@@ -1,502 +0,0 @@
|
|||||||
/*
|
|
||||||
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
|
||||||
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
|
||||||
*
|
|
||||||
* This program is free software: you can redistribute it and/or modify
|
|
||||||
* it under the terms of the GNU General Public License as published by
|
|
||||||
* the Free Software Foundation, either version 3 of the License, or
|
|
||||||
* (at your option) any later version.
|
|
||||||
*/
|
|
||||||
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 markUploaded_fillsBlankCallsignsAndKeepsTheOthers() = runBlocking {
|
|
||||||
val blank = dao.save(loggedInApp("SO-50", lotwUploaded = false).copy(myCallsign = "").toEntity())
|
|
||||||
val named = dao.save(loggedInApp("SO-50", lotwUploaded = false).copy(myCallsign = "BH3HCF").toEntity())
|
|
||||||
|
|
||||||
repository.markUploaded(listOf(blank, named), emptyList(), "BA7OPF")
|
|
||||||
|
|
||||||
val rows = dao.getAll().associateBy { it.id }
|
|
||||||
// Records logged before a certificate existed get the certificate's callsign; a record that
|
|
||||||
// names another callsign is not touched (they are refused, not rewritten, by the uploader).
|
|
||||||
assertEquals("BA7OPF", rows[blank]!!.myCallsign)
|
|
||||||
assertEquals("BH3HCF", rows[named]!!.myCallsign)
|
|
||||||
assertTrue(rows[blank]!!.lotwUploaded)
|
|
||||||
assertTrue(rows[named]!!.lotwUploaded)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun rewriteMyCallsign_rewritesTheConflictRecordsOnly() = runBlocking {
|
|
||||||
val conflict = dao.save(loggedInApp("SO-50", lotwUploaded = false).copy(myCallsign = "XX0YY").toEntity())
|
|
||||||
val kept = dao.save(loggedInApp("SO-50", lotwUploaded = false).copy(myCallsign = "BA7OPF").toEntity())
|
|
||||||
|
|
||||||
val changed = repository.rewriteMyCallsign(listOf(conflict, kept), "ba7opf")
|
|
||||||
|
|
||||||
assertEquals(1, changed)
|
|
||||||
val rows = dao.getAll().associateBy { it.id }
|
|
||||||
assertEquals("BA7OPF", rows[conflict]!!.myCallsign)
|
|
||||||
assertEquals("BA7OPF", rows[kept]!!.myCallsign)
|
|
||||||
assertFalse(rows[conflict]!!.lotwUploaded)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun markWavelogUploaded_setsTheFlagAndTheStationStamp() = runBlocking {
|
|
||||||
val id = dao.save(loggedInApp("SO-50", lotwUploaded = false).toEntity())
|
|
||||||
|
|
||||||
repository.markWavelogUploaded(listOf(id), "3")
|
|
||||||
|
|
||||||
val row = dao.getAll().single()
|
|
||||||
assertTrue(row.wavelogUploaded)
|
|
||||||
assertFalse(row.lotwUploaded)
|
|
||||||
assertEquals("3", row.wavelogStation)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun save_editingAWavelogUploadedRecordCreatesANewRecord() = runBlocking {
|
|
||||||
val id = dao.save(loggedInApp("SO-50", lotwUploaded = false).copy(wavelogUploaded = true).toEntity())
|
|
||||||
|
|
||||||
repository.save(loggedInApp("SO-50", lotwUploaded = false).copy(id = id, theirCallsign = "BG5JVM"))
|
|
||||||
|
|
||||||
assertEquals(2, dao.getAll().size)
|
|
||||||
assertEquals(1, dao.getAll().count { it.wavelogUploaded })
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun mergeWavelog_insertsNewRecordsAlreadyMarkedAndStationTagged() = runBlocking {
|
|
||||||
val result = repository.mergeWavelog(
|
|
||||||
listOf(loggedInApp("SO-50", lotwUploaded = false).copy(wavelogStation = "2"))
|
|
||||||
)
|
|
||||||
|
|
||||||
assertEquals(1, result.imported)
|
|
||||||
val row = dao.getAll().single()
|
|
||||||
assertTrue(row.wavelogUploaded)
|
|
||||||
assertEquals("2", row.wavelogStation)
|
|
||||||
assertFalse(row.lotwUploaded)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun mergeWavelog_matchesALocalRowInsteadOfDuplicating() = runBlocking {
|
|
||||||
val id = dao.save(loggedInApp("SAUDISAT 1C", lotwUploaded = false).copy(wavelogUploaded = true).toEntity())
|
|
||||||
|
|
||||||
repository.mergeWavelog(
|
|
||||||
listOf(loggedInApp("SAUDISAT 1C", lotwUploaded = false).copy(wavelogStation = "1", theirGrid = "EN52"))
|
|
||||||
)
|
|
||||||
|
|
||||||
val rows = dao.getAll()
|
|
||||||
assertEquals(1, rows.size)
|
|
||||||
assertEquals(id, rows.first().id)
|
|
||||||
assertEquals("1", rows.first().wavelogStation)
|
|
||||||
assertEquals("EN52", rows.first().theirGrid)
|
|
||||||
// The row joins the ARRL spelling like every other import path.
|
|
||||||
assertEquals("SO-50", rows.first().satelliteName)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun mergeWavelog_isIdempotentAcrossSyncs() = runBlocking {
|
|
||||||
val records = listOf(loggedInApp("SO-50", lotwUploaded = false).copy(wavelogStation = "1"))
|
|
||||||
|
|
||||||
repository.mergeWavelog(records)
|
|
||||||
repository.mergeWavelog(records)
|
|
||||||
|
|
||||||
assertEquals(1, dao.getAll().size)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A record as the log page creates it: tracker name, repeater pair, uplink band. */
|
|
||||||
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,
|
|
||||||
wavelogStation = wavelogStation,
|
|
||||||
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>, stationId: String) {
|
|
||||||
ids.forEach { id ->
|
|
||||||
rows[id]?.let {
|
|
||||||
rows[id] = it.copy(
|
|
||||||
wavelogUploaded = true,
|
|
||||||
wavelogStation = if (stationId.isBlank()) it.wavelogStation else stationId
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-437
@@ -1,437 +0,0 @@
|
|||||||
/*
|
|
||||||
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
|
||||||
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
|
||||||
*
|
|
||||||
* This program is free software: you can redistribute it and/or modify
|
|
||||||
* it under the terms of the GNU General Public License as published by
|
|
||||||
* the Free Software Foundation, either version 3 of the License, or
|
|
||||||
* (at your option) any later version.
|
|
||||||
*/
|
|
||||||
package com.rtbishop.look4sat.core.data.repository
|
|
||||||
|
|
||||||
import com.rtbishop.look4sat.core.domain.model.DataSourcesSettings
|
|
||||||
import com.rtbishop.look4sat.core.domain.model.DatabaseState
|
|
||||||
import com.rtbishop.look4sat.core.domain.model.OtherSettings
|
|
||||||
import com.rtbishop.look4sat.core.domain.model.PassesSettings
|
|
||||||
import com.rtbishop.look4sat.core.domain.model.RCSettings
|
|
||||||
import com.rtbishop.look4sat.core.domain.model.RadioControlSettings
|
|
||||||
import com.rtbishop.look4sat.core.domain.model.SatItem
|
|
||||||
import com.rtbishop.look4sat.core.domain.model.SatRadio
|
|
||||||
import com.rtbishop.look4sat.core.domain.predict.GeoPos
|
|
||||||
import com.rtbishop.look4sat.core.domain.predict.OrbitalData
|
|
||||||
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
|
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
|
||||||
import kotlinx.coroutines.flow.first
|
|
||||||
import kotlinx.coroutines.test.runTest
|
|
||||||
import org.junit.Assert.assertEquals
|
|
||||||
import org.junit.Assert.assertTrue
|
|
||||||
import org.junit.Test
|
|
||||||
|
|
||||||
@OptIn(ExperimentalCoroutinesApi::class)
|
|
||||||
class SelectionRepoSearchTest {
|
|
||||||
|
|
||||||
private val sampleItems = listOf(
|
|
||||||
SatItem(catnum = 25544, name = "ISS (ZARYA)"),
|
|
||||||
SatItem(catnum = 7530, name = "AO-7 (AMSAT-OSCAR 7)"),
|
|
||||||
SatItem(catnum = 39444, name = "AO-73 (FUNcube-1)"),
|
|
||||||
SatItem(catnum = 43803, name = "JO-97 (BIRDS-3)"),
|
|
||||||
SatItem(catnum = 99999, name = "FO-29"),
|
|
||||||
)
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `query without separators matches name with dashes spaces brackets`() = runTest {
|
|
||||||
val repo = createRepo(sampleItems)
|
|
||||||
repo.setQuery("ao7")
|
|
||||||
val results = repo.getEntriesFlow().first()
|
|
||||||
// "ao7" is a substring of the normalized "AO-73 (FUNcube-1)" too, so a
|
|
||||||
// fuzzy search legitimately returns both AO-7 (first) and AO-73. The
|
|
||||||
// key guarantee is that AO-7 — which was unreachable before because of
|
|
||||||
// the dashes/brackets — is now found.
|
|
||||||
assertTrue(results.map { it.catnum }.contains(7530))
|
|
||||||
assertEquals(7530, results.first().catnum)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `query without separators matches name with only dashes`() = runTest {
|
|
||||||
val repo = createRepo(sampleItems)
|
|
||||||
repo.setQuery("fo29")
|
|
||||||
val results = repo.getEntriesFlow().first()
|
|
||||||
assertEquals(listOf(99999), results.map { it.catnum })
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `space-separated tokens all must match in any order`() = runTest {
|
|
||||||
val repo = createRepo(sampleItems)
|
|
||||||
repo.setQuery("zarya iss")
|
|
||||||
val results = repo.getEntriesFlow().first()
|
|
||||||
assertEquals(listOf(25544), results.map { it.catnum })
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `exact name query still matches`() = runTest {
|
|
||||||
val repo = createRepo(sampleItems)
|
|
||||||
repo.setQuery("AO-7 (AMSAT-OSCAR 7)")
|
|
||||||
val results = repo.getEntriesFlow().first()
|
|
||||||
assertEquals(listOf(7530), results.map { it.catnum })
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `partial token query matches substring`() = runTest {
|
|
||||||
val repo = createRepo(sampleItems)
|
|
||||||
repo.setQuery("funcube")
|
|
||||||
val results = repo.getEntriesFlow().first()
|
|
||||||
assertEquals(listOf(39444), results.map { it.catnum })
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `numeric query matches catnum exactly`() = runTest {
|
|
||||||
val repo = createRepo(sampleItems)
|
|
||||||
repo.setQuery("25544")
|
|
||||||
val results = repo.getEntriesFlow().first()
|
|
||||||
assertEquals(listOf(25544), results.map { it.catnum })
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `no query returns all items`() = runTest {
|
|
||||||
val repo = createRepo(sampleItems)
|
|
||||||
repo.setQuery("")
|
|
||||||
val results = repo.getEntriesFlow().first()
|
|
||||||
assertEquals(sampleItems.size, results.size)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `unmatched query returns nothing`() = runTest {
|
|
||||||
val repo = createRepo(sampleItems)
|
|
||||||
repo.setQuery("zzzznomatch")
|
|
||||||
assertTrue(repo.getEntriesFlow().first().isEmpty())
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `FM virtual type shows only hardcoded FM satellites`() = runTest {
|
|
||||||
// AO-123 ASRTU-1 (61781) is on the hardcoded FM list; a random
|
|
||||||
// satellite (99999) is not.
|
|
||||||
val items = sampleItems + SatItem(catnum = 61781, name = "ASRTU-1")
|
|
||||||
val repo = createRepo(items)
|
|
||||||
repo.setTypes(listOf("AMSAT Live FM"))
|
|
||||||
val results = repo.getEntriesFlow().first()
|
|
||||||
assertEquals(setOf(25544, 61781), results.map { it.catnum }.toSet())
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `FM virtual type includes the BA7OPF RS95S UNNE-1B and IO-86 additions`() = runTest {
|
|
||||||
// RS95S / QMR-KWT-2 (67291), UNNE-1B (98205) and IO-86 / LAPAN-A2 (40931)
|
|
||||||
// joined the hardcoded AMSAT Live FM set on 2026-10-09; each carries a V/U
|
|
||||||
// FM repeater in SatNOGS ("V/U FM Transponder CTCSS 67.0 Hz",
|
|
||||||
// "Mode V/U - FM Repeater (Voice + (A)FSK)", "V/U FM Voice (PL 88.5Hz)").
|
|
||||||
// sampleItems contributes ISS (25544) only.
|
|
||||||
val items = sampleItems + listOf(
|
|
||||||
SatItem(catnum = 67291, name = "QMR-KWT-2 (RS95S)"),
|
|
||||||
SatItem(catnum = 98205, name = "UNNE-1B"),
|
|
||||||
SatItem(catnum = 40931, name = "LAPAN-A2 (IO-86)")
|
|
||||||
)
|
|
||||||
val repo = createRepo(items)
|
|
||||||
repo.setTypes(listOf("AMSAT Live FM"))
|
|
||||||
val results = repo.getEntriesFlow().first()
|
|
||||||
assertEquals(setOf(25544, 67291, 98205, 40931), results.map { it.catnum }.toSet())
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `Linear virtual type shows only hardcoded linear satellites`() = runTest {
|
|
||||||
val repo = createRepo(sampleItems)
|
|
||||||
repo.setTypes(listOf("AMSAT Live Linear"))
|
|
||||||
val results = repo.getEntriesFlow().first()
|
|
||||||
// Hardcoded linear list: RS-44 (44909), FO-29 (24278), AO-7 (7530),
|
|
||||||
// AO-73 (39444), JO-97 (43803). Present in the sample DB: 7530, 39444, 43803.
|
|
||||||
assertEquals(setOf(7530, 39444, 43803), results.map { it.catnum }.toSet())
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `SSTV virtual type shows only satellites with SSTV radios`() = runTest {
|
|
||||||
val repo = createRepo(
|
|
||||||
items = sampleItems,
|
|
||||||
sstvIds = listOf(43803)
|
|
||||||
)
|
|
||||||
repo.setTypes(listOf("Live SSTV"))
|
|
||||||
val results = repo.getEntriesFlow().first()
|
|
||||||
assertEquals(listOf(43803), results.map { it.catnum })
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `SSTV virtual type uses the amateur-only radio query`() = runTest {
|
|
||||||
// The repo delegates to getIdsWithModesAndAmateur: the fake returns
|
|
||||||
// only catnums the DAO would have filtered to Amateur service (43803).
|
|
||||||
val repo = createRepo(
|
|
||||||
items = sampleItems,
|
|
||||||
sstvIds = listOf(43803)
|
|
||||||
)
|
|
||||||
repo.setTypes(listOf("Live SSTV"))
|
|
||||||
val results = repo.getEntriesFlow().first()
|
|
||||||
assertEquals(listOf(43803), results.map { it.catnum })
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `SSTV virtual type excludes rocket-body debris by name`() = runTest {
|
|
||||||
// 60239 is "ARIANE 6 R/B" (launcher debris carrying an amateur SSTV
|
|
||||||
// payload); it must be dropped even though its radio is Amateur.
|
|
||||||
val items = sampleItems + SatItem(catnum = 60239, name = "ARIANE 6 R/B")
|
|
||||||
val repo = createRepo(
|
|
||||||
items = items,
|
|
||||||
sstvIds = listOf(43803, 60239)
|
|
||||||
)
|
|
||||||
repo.setTypes(listOf("Live SSTV"))
|
|
||||||
val results = repo.getEntriesFlow().first()
|
|
||||||
assertEquals(listOf(43803), results.map { it.catnum })
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `multiple virtual types union their satellites`() = runTest {
|
|
||||||
val repo = createRepo(
|
|
||||||
items = sampleItems,
|
|
||||||
sstvIds = listOf(43803)
|
|
||||||
)
|
|
||||||
repo.setTypes(listOf("AMSAT Live FM", "Live SSTV"))
|
|
||||||
val results = repo.getEntriesFlow().first()
|
|
||||||
assertEquals(setOf(25544, 43803), results.map { it.catnum }.toSet())
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `clearing types restores full list`() = runTest {
|
|
||||||
val repo = createRepo(sampleItems)
|
|
||||||
repo.setTypes(listOf("AMSAT Live FM"))
|
|
||||||
assertTrue(repo.getEntriesFlow().first().isNotEmpty())
|
|
||||||
repo.setTypes(emptyList())
|
|
||||||
assertEquals(sampleItems.size, repo.getEntriesFlow().first().size)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `virtual type with no matching satellites shows empty not everything`() = runTest {
|
|
||||||
// 硬编码 FM 列表 (27607/25544/61781) 里没有样本库中的卫星时, 选虚拟
|
|
||||||
// 类型应显示空列表, 而非全部卫星.
|
|
||||||
val items = listOf(SatItem(catnum = 7530, name = "AO-7 (AMSAT-OSCAR 7)"))
|
|
||||||
val repo = createRepo(items)
|
|
||||||
repo.setTypes(listOf("AMSAT Live FM"))
|
|
||||||
assertTrue(repo.getEntriesFlow().first().isEmpty())
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `virtual type combined with regular type unions both lists`() = runTest {
|
|
||||||
val repo = createRepo(items = sampleItems)
|
|
||||||
repo.setTypes(listOf("AMSAT Live FM", "AMSAT Live Linear"))
|
|
||||||
val results = repo.getEntriesFlow().first()
|
|
||||||
assertEquals(
|
|
||||||
setOf(25544, 7530, 39444, 43803),
|
|
||||||
results.map { it.catnum }.toSet()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `types list has virtual transponder types first and keeps All`() {
|
|
||||||
val repo = createRepo(sampleItems)
|
|
||||||
val types = repo.getTypesList()
|
|
||||||
assertTrue(types.size >= 4)
|
|
||||||
assertEquals("AMSAT Live FM", types[0])
|
|
||||||
assertEquals("AMSAT Live Linear", types[1])
|
|
||||||
assertEquals("Live SSTV", types[2])
|
|
||||||
// "All" must be kept in the list (first original type shown after virtual ones).
|
|
||||||
assertTrue("All" in types)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `All type resolves to persisted CelesTrak active ids`() = runTest {
|
|
||||||
// Regression: "All" is a real TLE source type (CelesTrak active). Its
|
|
||||||
// ids are persisted on sync like any other type; selecting it must
|
|
||||||
// show the synced satellites, NOT an empty list.
|
|
||||||
val fake = FakeSettingsRepoForSearch()
|
|
||||||
fake.setSatelliteTypeIds("All", sampleItems.map { it.catnum })
|
|
||||||
val repo = SelectionRepo(
|
|
||||||
dispatcher = Dispatchers.Unconfined,
|
|
||||||
localSource = FakeLocalSourceForSearch(sampleItems),
|
|
||||||
settingsRepo = fake
|
|
||||||
)
|
|
||||||
repo.setTypes(listOf("All"))
|
|
||||||
val results = repo.getEntriesFlow().first()
|
|
||||||
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)
|
|
||||||
val types = repo.getTypesList()
|
|
||||||
// All real TLE source types must be selectable, including "All"
|
|
||||||
// (CelesTrak active) and "ARISS" (removed by the old removeAt(0)).
|
|
||||||
assertTrue("All" in types)
|
|
||||||
assertTrue("ARISS" in types)
|
|
||||||
assertTrue("R4UAB" in types)
|
|
||||||
assertTrue("SatNOGS" in types)
|
|
||||||
assertTrue("Other" !in types)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `all types shows all items`() = runTest {
|
|
||||||
val repo = createRepo(sampleItems)
|
|
||||||
repo.setTypes(emptyList()) // "All" = no type filtering
|
|
||||||
val results = repo.getEntriesFlow().first()
|
|
||||||
assertEquals(sampleItems.size, results.size)
|
|
||||||
assertEquals(sampleItems.map { it.catnum }.toSet(), results.map { it.catnum }.toSet())
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `selected satellites are shown first`() = runTest {
|
|
||||||
// Pinning ported from upstream: ticked satellites sort above the rest,
|
|
||||||
// each group keeping the name order.
|
|
||||||
val repo = createRepo(
|
|
||||||
items = listOf(
|
|
||||||
SatItem(catnum = 44444, name = "Zeta"),
|
|
||||||
SatItem(catnum = 25544, name = "Alpha"),
|
|
||||||
SatItem(catnum = 40967, name = "Beta")
|
|
||||||
)
|
|
||||||
)
|
|
||||||
val flow = repo.getEntriesFlow()
|
|
||||||
repo.setSelection(listOf(40967), true)
|
|
||||||
val results = flow.first()
|
|
||||||
assertEquals(listOf(40967, 25544, 44444), results.map { it.catnum })
|
|
||||||
assertEquals(listOf(true, false, false), results.map { it.isSelected })
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun createRepo(
|
|
||||||
items: List<SatItem>,
|
|
||||||
sstvIds: List<Int> = emptyList()
|
|
||||||
): ISelectionRepo {
|
|
||||||
return SelectionRepo(
|
|
||||||
dispatcher = Dispatchers.Unconfined,
|
|
||||||
localSource = FakeLocalSourceForSearch(items, sstvIds),
|
|
||||||
settingsRepo = FakeSettingsRepoForSearch()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private class FakeLocalSourceForSearch(
|
|
||||||
private val items: List<SatItem>,
|
|
||||||
private val sstvIds: List<Int> = emptyList()
|
|
||||||
) : ILocalSource {
|
|
||||||
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
|
|
||||||
override suspend fun getIdsWithModesAndUplink(modes: List<String>): List<Int> = sstvIds
|
|
||||||
override suspend fun getIdsWithModesAndAmateur(modes: List<String>): List<Int> = sstvIds
|
|
||||||
override suspend fun getRadiosTotal(): Int = 0
|
|
||||||
override suspend fun getRadiosWithId(id: Int): List<SatRadio> = emptyList()
|
|
||||||
override suspend fun insertRadios(radios: List<SatRadio>, isCustom: Boolean) = Unit
|
|
||||||
override suspend fun deleteManagedRadios() = Unit
|
|
||||||
override suspend fun deleteRadios() = Unit
|
|
||||||
}
|
|
||||||
|
|
||||||
private class FakeSettingsRepoForSearch : ISettingsRepo {
|
|
||||||
private val typeIds = mutableMapOf<String, List<Int>>()
|
|
||||||
override val appVersionName: String = "test"
|
|
||||||
override val selectedIds: StateFlow<List<Int>> = MutableStateFlow(emptyList())
|
|
||||||
override val selectedTypes: StateFlow<List<String>> = MutableStateFlow(emptyList())
|
|
||||||
override val passesSettings: StateFlow<PassesSettings> = MutableStateFlow(
|
|
||||||
PassesSettings(hoursAhead = 24, minElevation = 0.0, selectedModes = emptyList())
|
|
||||||
)
|
|
||||||
override val stationPosition: StateFlow<GeoPos> = MutableStateFlow(GeoPos(0.0, 0.0))
|
|
||||||
override val databaseState: MutableStateFlow<DatabaseState> = MutableStateFlow(DatabaseState(0, 0, 0L))
|
|
||||||
override val rcSettings: StateFlow<RCSettings> = MutableStateFlow(
|
|
||||||
RCSettings(false, "", "", "", false, "", "", "", 0L, false, "", "", "", false, "", "")
|
|
||||||
)
|
|
||||||
override val otherSettings: StateFlow<OtherSettings> = MutableStateFlow(
|
|
||||||
OtherSettings(
|
|
||||||
false, false, false, false, false, false, false,
|
|
||||||
shouldSeeWarning = false, shouldSeeWhatsNew = false
|
|
||||||
)
|
|
||||||
)
|
|
||||||
override val dataSourcesSettings: MutableStateFlow<DataSourcesSettings> =
|
|
||||||
MutableStateFlow(DataSourcesSettings(satelliteUrls = emptyList(), transceiversUrls = emptyList()))
|
|
||||||
override val dataSourcesStatus: StateFlow<Map<String, Int>> = MutableStateFlow(emptyMap())
|
|
||||||
override val radioControlSettings: StateFlow<RadioControlSettings> = MutableStateFlow(
|
|
||||||
RadioControlSettings(false, RadioControlSettings.MODEL_YAESU_FT817, "", "", "", "", 9600)
|
|
||||||
)
|
|
||||||
|
|
||||||
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())
|
|
||||||
|
|
||||||
override fun setSelectedIds(ids: List<Int>) = Unit
|
|
||||||
override fun setSelectedTypes(types: List<String>) = Unit
|
|
||||||
override fun setPassesSettings(settings: PassesSettings) = Unit
|
|
||||||
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()
|
|
||||||
|
|
||||||
override fun setSatelliteTypeIds(type: String, ids: List<Int>) {
|
|
||||||
typeIds[type] = ids
|
|
||||||
}
|
|
||||||
override fun updateDatabaseState(state: DatabaseState) = Unit
|
|
||||||
override fun updateRCSettings(settings: RCSettings) = Unit
|
|
||||||
override fun updateOtherSettings(transform: (OtherSettings) -> OtherSettings) = Unit
|
|
||||||
override fun updateDataSourcesSettings(settings: DataSourcesSettings) = Unit
|
|
||||||
override fun updateDataSourcesStatus(status: Map<String, Int>) = Unit
|
|
||||||
override fun updateRadioControlSettings(settings: RadioControlSettings) = Unit
|
|
||||||
override fun getSatelliteOffset(catnum: Int): String = ""
|
|
||||||
override fun getSatelliteMode(catnum: Int): String = ""
|
|
||||||
override fun setSatelliteMode(catnum: Int, mode: String) {}
|
|
||||||
override fun setSatelliteOffset(catnum: Int, offset: String) = Unit
|
|
||||||
override fun getAmSatCallsign(): String = ""
|
|
||||||
override fun setAmSatCallsign(callsign: String) = Unit
|
|
||||||
|
|
||||||
override fun getWorkedGrids(): Set<String> = emptySet()
|
|
||||||
override fun setWorkedGrids(grids: Set<String>) = Unit
|
|
||||||
override fun getWorkedGridQsos(): Map<String, List<com.rtbishop.look4sat.core.domain.model.GridQso>> = emptyMap()
|
|
||||||
override fun setWorkedGridQsos(qsos: Map<String, List<com.rtbishop.look4sat.core.domain.model.GridQso>>) = Unit
|
|
||||||
override fun getRoamedGrids(): Set<String> = emptySet()
|
|
||||||
override fun setRoamedGrids(grids: Set<String>) = Unit
|
|
||||||
override fun getMarkedGridStations(): Map<String, List<com.rtbishop.look4sat.core.domain.model.MarkedStation>> = emptyMap()
|
|
||||||
override fun setMarkedGridStations(stations: Map<String, List<com.rtbishop.look4sat.core.domain.model.MarkedStation>>) = Unit
|
|
||||||
override fun updateLoTWSettings(settings: com.rtbishop.look4sat.core.domain.model.LoTWSettings) = Unit
|
|
||||||
override fun getLastLotwSyncDate(): String = ""
|
|
||||||
override fun setLastLotwSyncDate(date: String) = Unit
|
|
||||||
override fun getLastLotwSyncCallsign(): String = ""
|
|
||||||
override fun setLastLotwSyncCallsign(callsign: String) = Unit
|
|
||||||
override fun getWavelogStations(): List<com.rtbishop.look4sat.core.domain.repository.WavelogStationInfo> = emptyList()
|
|
||||||
override fun setWavelogStations(stations: List<com.rtbishop.look4sat.core.domain.repository.WavelogStationInfo>) = Unit
|
|
||||||
override fun getWavelogSyncCursors(): Map<String, Long> = emptyMap()
|
|
||||||
override fun setWavelogSyncCursors(cursors: Map<String, Long>) = Unit
|
|
||||||
override fun getWavelogSyncUrl(): String = ""
|
|
||||||
override fun setWavelogSyncUrl(url: String) = Unit
|
|
||||||
override fun getLastWavelogSyncEpochMs(): Long = 0L
|
|
||||||
override fun setLastWavelogSyncEpochMs(value: Long) = Unit
|
|
||||||
}
|
|
||||||
+253
@@ -0,0 +1,253 @@
|
|||||||
|
/*
|
||||||
|
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||||
|
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
package com.rtbishop.look4sat.core.data.repository
|
||||||
|
|
||||||
|
import com.rtbishop.look4sat.core.domain.model.DataSourcesSettings
|
||||||
|
import com.rtbishop.look4sat.core.domain.model.DatabaseState
|
||||||
|
import com.rtbishop.look4sat.core.domain.model.OtherSettings
|
||||||
|
import com.rtbishop.look4sat.core.domain.model.PassesSettings
|
||||||
|
import com.rtbishop.look4sat.core.domain.model.RCSettings
|
||||||
|
import com.rtbishop.look4sat.core.domain.model.RadioControlSettings
|
||||||
|
import com.rtbishop.look4sat.core.domain.model.SatItem
|
||||||
|
import com.rtbishop.look4sat.core.domain.model.SatRadio
|
||||||
|
import com.rtbishop.look4sat.core.domain.predict.GeoPos
|
||||||
|
import com.rtbishop.look4sat.core.domain.predict.OrbitalObject
|
||||||
|
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
|
||||||
|
import com.rtbishop.look4sat.core.domain.source.ILocalSource
|
||||||
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
|
class SelectionRepoTest {
|
||||||
|
|
||||||
|
private val dispatcher = StandardTestDispatcher()
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `unknown mode values do not crash and do not filter out entries`() = runTest(dispatcher) {
|
||||||
|
val localSource = FakeLocalSource(
|
||||||
|
entries = listOf(
|
||||||
|
SatItem(25544, "ISS (ZARYA)", false),
|
||||||
|
SatItem(40967, "TIANGONG", false)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
val settingsRepo = FakeSettingsRepo(selectedModes = listOf("REMOVED_MODE"))
|
||||||
|
val repository = SelectionRepo(dispatcher, localSource, settingsRepo)
|
||||||
|
|
||||||
|
val flow = repository.getEntriesFlow()
|
||||||
|
repository.setModes(listOf("REMOVED_MODE"))
|
||||||
|
|
||||||
|
val items = flow.first()
|
||||||
|
|
||||||
|
assertEquals(listOf(25544, 40967), items.map { it.catnum })
|
||||||
|
assertEquals(listOf("REMOVED_MODE"), repository.getCurrentModes())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `selected satellites are shown first`() = runTest(dispatcher) {
|
||||||
|
val localSource = FakeLocalSource(
|
||||||
|
entries = listOf(
|
||||||
|
SatItem(44444, "Zeta", false),
|
||||||
|
SatItem(25544, "Alpha", false),
|
||||||
|
SatItem(40967, "Beta", false)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
val settingsRepo = FakeSettingsRepo(selectedModes = emptyList())
|
||||||
|
val repository = SelectionRepo(dispatcher, localSource, settingsRepo)
|
||||||
|
|
||||||
|
val flow = repository.getEntriesFlow()
|
||||||
|
repository.setSelection(listOf(40967), true)
|
||||||
|
|
||||||
|
val items = flow.first()
|
||||||
|
|
||||||
|
assertEquals(listOf(40967, 25544, 44444), items.map { it.catnum })
|
||||||
|
assertEquals(listOf(true, false, false), items.map { it.isSelected })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `query without separators matches name with dashes spaces brackets`() = runTest(dispatcher) {
|
||||||
|
val repository = repoWithSearchEntries()
|
||||||
|
val flow = repository.getEntriesFlow()
|
||||||
|
repository.setQuery("ao7")
|
||||||
|
val items = flow.first()
|
||||||
|
// "ao7" is a substring of the normalized "AO-73 (FUNcube-1)" too, so a
|
||||||
|
// fuzzy search legitimately returns both AO-7 (first) and AO-73. The key
|
||||||
|
// guarantee is that AO-7 — unreachable before because of dashes/brackets
|
||||||
|
// — is now found.
|
||||||
|
assertEquals(7530, items.first().catnum)
|
||||||
|
assertEquals(listOf(7530, 39444), items.map { it.catnum })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `query without separators matches name with only dashes`() = runTest(dispatcher) {
|
||||||
|
val repository = repoWithSearchEntries()
|
||||||
|
val flow = repository.getEntriesFlow()
|
||||||
|
repository.setQuery("fo29")
|
||||||
|
assertEquals(listOf(99999), flow.first().map { it.catnum })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `space-separated tokens all must match in any order`() = runTest(dispatcher) {
|
||||||
|
val repository = repoWithSearchEntries()
|
||||||
|
val flow = repository.getEntriesFlow()
|
||||||
|
repository.setQuery("zarya iss")
|
||||||
|
assertEquals(listOf(25544), flow.first().map { it.catnum })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `partial token query matches substring`() = runTest(dispatcher) {
|
||||||
|
val repository = repoWithSearchEntries()
|
||||||
|
val flow = repository.getEntriesFlow()
|
||||||
|
repository.setQuery("funcube")
|
||||||
|
assertEquals(listOf(39444), flow.first().map { it.catnum })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `unmatched query returns nothing`() = runTest(dispatcher) {
|
||||||
|
val repository = repoWithSearchEntries()
|
||||||
|
val flow = repository.getEntriesFlow()
|
||||||
|
repository.setQuery("zzzznomatch")
|
||||||
|
assertEquals(emptyList<Int>(), flow.first().map { it.catnum })
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun repoWithSearchEntries(): SelectionRepo {
|
||||||
|
val localSource = FakeLocalSource(
|
||||||
|
entries = listOf(
|
||||||
|
SatItem(25544, "ISS (ZARYA)", false),
|
||||||
|
SatItem(7530, "AO-7 (AMSAT-OSCAR 7)", false),
|
||||||
|
SatItem(39444, "AO-73 (FUNcube-1)", false),
|
||||||
|
SatItem(43803, "JO-97 (BIRDS-3)", false),
|
||||||
|
SatItem(99999, "FO-29", false)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return SelectionRepo(dispatcher, localSource, FakeSettingsRepo(selectedModes = emptyList()))
|
||||||
|
}
|
||||||
|
|
||||||
|
private class FakeLocalSource(
|
||||||
|
private val entries: List<SatItem>
|
||||||
|
) : ILocalSource {
|
||||||
|
override suspend fun getEntriesTotal(): Int = entries.size
|
||||||
|
|
||||||
|
override suspend fun getEntriesList(): List<SatItem> = entries
|
||||||
|
|
||||||
|
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<com.rtbishop.look4sat.core.domain.predict.OrbitalData>) = Unit
|
||||||
|
|
||||||
|
override suspend fun deleteEntries() = Unit
|
||||||
|
|
||||||
|
override suspend fun getIdsWithModes(modes: List<String>): List<Int> = emptyList()
|
||||||
|
|
||||||
|
override suspend fun getRadiosTotal(): Int = 0
|
||||||
|
|
||||||
|
override suspend fun getRadiosWithId(id: Int): List<SatRadio> = emptyList()
|
||||||
|
|
||||||
|
override suspend fun insertRadios(radios: List<SatRadio>, isCustom: Boolean) = Unit
|
||||||
|
|
||||||
|
override suspend fun deleteRadios() = Unit
|
||||||
|
|
||||||
|
override suspend fun deleteManagedRadios() = Unit
|
||||||
|
}
|
||||||
|
|
||||||
|
private class FakeSettingsRepo(
|
||||||
|
selectedModes: List<String>
|
||||||
|
) : ISettingsRepo {
|
||||||
|
|
||||||
|
override val appVersionName: String = "test"
|
||||||
|
override val appVersionCode: Long = 1L
|
||||||
|
|
||||||
|
override val selectedIds: StateFlow<List<Int>> = MutableStateFlow(emptyList())
|
||||||
|
|
||||||
|
override val selectedSatModes: MutableStateFlow<List<String>> = MutableStateFlow(selectedModes)
|
||||||
|
|
||||||
|
override val passesSettings: StateFlow<PassesSettings> = MutableStateFlow(
|
||||||
|
PassesSettings(hoursAhead = 24, minElevation = 0.0)
|
||||||
|
)
|
||||||
|
|
||||||
|
override val stationPosition: StateFlow<GeoPos> = MutableStateFlow(GeoPos(0.0, 0.0))
|
||||||
|
|
||||||
|
override val databaseState: MutableStateFlow<DatabaseState> = MutableStateFlow(DatabaseState(0, 0, 0L))
|
||||||
|
|
||||||
|
override val rcSettings: StateFlow<RCSettings> = MutableStateFlow(
|
||||||
|
RCSettings(false, "", "", "", false, "", "", "", 0L, false, "", "", "", false, "", "")
|
||||||
|
)
|
||||||
|
|
||||||
|
override val otherSettings: StateFlow<OtherSettings> = MutableStateFlow(
|
||||||
|
OtherSettings(false, false, false, false, false, false, false, false)
|
||||||
|
)
|
||||||
|
|
||||||
|
override val dataSourcesSettings: StateFlow<DataSourcesSettings> = MutableStateFlow(
|
||||||
|
DataSourcesSettings(emptyList(), emptyList())
|
||||||
|
)
|
||||||
|
|
||||||
|
override val dataSourcesStatus: StateFlow<Map<String, Int>> = MutableStateFlow(emptyMap())
|
||||||
|
|
||||||
|
override val radioControlSettings: StateFlow<RadioControlSettings> = MutableStateFlow(
|
||||||
|
RadioControlSettings(false, RadioControlSettings.MODEL_YAESU_FT817, "", "", "", "", 9600)
|
||||||
|
)
|
||||||
|
|
||||||
|
override fun setSelectedIds(ids: List<Int>) = Unit
|
||||||
|
|
||||||
|
override fun setSelectedSatModes(modes: List<String>) {
|
||||||
|
selectedSatModes.value = modes
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun setPassesSettings(settings: PassesSettings) = Unit
|
||||||
|
|
||||||
|
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 updateDatabaseState(state: DatabaseState) {
|
||||||
|
databaseState.value = state
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun updateRCSettings(settings: RCSettings) = Unit
|
||||||
|
|
||||||
|
override fun updateOtherSettings(transform: (OtherSettings) -> OtherSettings) = Unit
|
||||||
|
|
||||||
|
override fun updateDataSourcesSettings(settings: DataSourcesSettings) = Unit
|
||||||
|
|
||||||
|
override fun updateDataSourcesStatus(status: Map<String, Int>) = Unit
|
||||||
|
|
||||||
|
override fun updateRadioControlSettings(settings: RadioControlSettings) = Unit
|
||||||
|
|
||||||
|
override fun getSatelliteOffset(catnum: Int): String = ""
|
||||||
|
|
||||||
|
override fun setSatelliteOffset(catnum: Int, offset: String) = Unit
|
||||||
|
|
||||||
|
override fun getAmSatCallsign(): String = ""
|
||||||
|
|
||||||
|
override fun setAmSatCallsign(callsign: String) = Unit
|
||||||
|
}
|
||||||
|
}
|
||||||
-74
@@ -1,74 +0,0 @@
|
|||||||
/*
|
|
||||||
* Look4Sat-BA7OPF. Amateur radio satellite tracker and pass predictor.
|
|
||||||
* Copyright (C) 2026 BA7OPF.
|
|
||||||
* Based on Look4Sat by Arty Bishop and contributors.
|
|
||||||
*
|
|
||||||
* This program is free software: you can redistribute it and/or modify
|
|
||||||
* it under the terms of the GNU General Public License as published by
|
|
||||||
* the Free Software Foundation, either version 3 of the License, or
|
|
||||||
* (at your option) any later version.
|
|
||||||
*
|
|
||||||
* This program is distributed in the hope that it will be useful,
|
|
||||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
* GNU General Public License for more details.
|
|
||||||
*
|
|
||||||
* You should have received a copy of the GNU General Public License
|
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
*/
|
|
||||||
package com.rtbishop.look4sat.core.data.repository
|
|
||||||
|
|
||||||
import org.json.JSONObject
|
|
||||||
import org.junit.Assert.assertEquals
|
|
||||||
import org.junit.Assert.assertNotNull
|
|
||||||
import org.junit.Assert.assertNull
|
|
||||||
import org.junit.Assert.assertTrue
|
|
||||||
import org.junit.Test
|
|
||||||
|
|
||||||
/** Parsing of the `get_contacts_adif` response envelope (transport-level wiring is exercised on device). */
|
|
||||||
class WavelogSyncRepositoryTest {
|
|
||||||
|
|
||||||
private val repository = WavelogSyncRepository()
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun successfulPageParsesRecordsAndCursor() {
|
|
||||||
val adif = "<ADIF_VER:5>3.1.7\n<EOH>\n" +
|
|
||||||
"<QSO_DATE:8>20260916<TIME_ON:4>0745<CALL:6>BG5JSB<GRIDSQUARE:4>EN52" +
|
|
||||||
"<MODE:2>FM<PROP_MODE:3>SAT<SAT_NAME:5>SO-50<EOR>\n"
|
|
||||||
val text = JSONObject()
|
|
||||||
.put("status", "successful")
|
|
||||||
.put("lastfetchedid", 42)
|
|
||||||
.put("exported_qsos", 1)
|
|
||||||
.put("adif", adif)
|
|
||||||
.toString()
|
|
||||||
|
|
||||||
val page = repository.parsePage(text)
|
|
||||||
|
|
||||||
assertNotNull(page)
|
|
||||||
assertEquals(42L, page!!.cursor)
|
|
||||||
assertEquals(1, page.exported)
|
|
||||||
assertEquals(1, page.records.size)
|
|
||||||
assertEquals("BG5JSB", page.records.single().theirCallsign)
|
|
||||||
assertEquals("EN52", page.records.single().theirGrid)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun failedStatusIsRejected() {
|
|
||||||
assertNull(repository.parsePage("""{"status":"failed","reason":"missing api key"}"""))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun missingCursorIsRejected() {
|
|
||||||
assertNull(repository.parsePage("""{"status":"successful","exported_qsos":0,"adif":null}"""))
|
|
||||||
assertNull(repository.parsePage("not json at all"))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun emptyExportKeepsTheCursorAndYieldsNoRecords() {
|
|
||||||
val page = repository.parsePage("""{"status":"successful","lastfetchedid":7,"exported_qsos":0,"adif":null}""")
|
|
||||||
|
|
||||||
assertNotNull(page)
|
|
||||||
assertEquals(7L, page!!.cursor)
|
|
||||||
assertTrue(page.records.isEmpty())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-197
@@ -1,197 +0,0 @@
|
|||||||
/*
|
|
||||||
* Look4Sat-BA7OPF. Amateur radio satellite tracker and pass predictor.
|
|
||||||
* Copyright (C) 2026 BA7OPF.
|
|
||||||
* Based on Look4Sat by Arty Bishop and contributors.
|
|
||||||
*
|
|
||||||
* This program is free software: you can redistribute it and/or modify
|
|
||||||
* it under the terms of the GNU General Public License as published by
|
|
||||||
* the Free Software Foundation, either version 3 of the License, or
|
|
||||||
* (at your option) any later version.
|
|
||||||
*/
|
|
||||||
package com.rtbishop.look4sat.core.data.repository
|
|
||||||
|
|
||||||
import com.rtbishop.look4sat.core.domain.logbook.QsoRecord
|
|
||||||
import com.rtbishop.look4sat.core.domain.logbook.QsoStatus
|
|
||||||
import com.rtbishop.look4sat.core.domain.model.WavelogUploadSettings
|
|
||||||
import com.rtbishop.look4sat.core.domain.repository.WavelogProblem
|
|
||||||
import com.rtbishop.look4sat.core.domain.repository.WavelogUploadOutcome
|
|
||||||
import kotlinx.coroutines.runBlocking
|
|
||||||
import org.junit.Assert.assertEquals
|
|
||||||
import org.junit.Assert.assertFalse
|
|
||||||
import org.junit.Assert.assertTrue
|
|
||||||
import org.junit.Test
|
|
||||||
|
|
||||||
class WavelogUploadRepositoryTest {
|
|
||||||
|
|
||||||
// region # gridCompatible (mirror of the server's adif_grid_check_location prefix rule)
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun grid_sameGridsPass() {
|
|
||||||
assertTrue(gridCompatible("OL62TI", "OL62TI"))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun grid_longerAdifGridPasses() {
|
|
||||||
assertTrue(gridCompatible("OL62TI", "OL62"))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun grid_longerStationGridPasses() {
|
|
||||||
assertTrue(gridCompatible("OL62", "OL62TI"))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun grid_differentSquaresFail() {
|
|
||||||
assertFalse(gridCompatible("PM01", "OL62"))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun grid_blankSidesDisableTheCheck() {
|
|
||||||
assertTrue(gridCompatible("", "OL62"))
|
|
||||||
assertTrue(gridCompatible("OL62", ""))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun grid_stationMarginAcceptsAPrefixPart() {
|
|
||||||
assertTrue(gridCompatible("OL63AA", "OL62,OL63"))
|
|
||||||
assertFalse(gridCompatible("PM01AA", "OL62,OL63"))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun grid_caseInsensitive() {
|
|
||||||
assertTrue(gridCompatible("ol62ti", "OL62"))
|
|
||||||
}
|
|
||||||
|
|
||||||
// endregion
|
|
||||||
|
|
||||||
// region # classifyUploadResponse
|
|
||||||
|
|
||||||
private val calls = mapOf(1L to "BG5JVM", 2L to "BH6RJD", 3L to "BI3SIR")
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun createdMarksEverything() {
|
|
||||||
val outcome = classifyUploadResponse(
|
|
||||||
201, """{"status":"created","adif_count":3,"adif_errors":0,"messages":[""]}""", calls
|
|
||||||
)
|
|
||||||
assertEquals(WavelogUploadOutcome.Imported(3, 0, 0, listOf(1L, 2L, 3L)), outcome)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun duplicatesAreCountedAndStillMarked() {
|
|
||||||
val body = """{"status":"abort","messages":["Date/Time: 2026-10-04 12:34:00 Callsign: BG5JVM Band: 70CM Duplicate for BA7OPF<br>"]}"""
|
|
||||||
val outcome = classifyUploadResponse(400, body, calls)
|
|
||||||
assertEquals(WavelogUploadOutcome.Imported(2, 1, 0, listOf(1L, 2L, 3L)), outcome)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun skippedRecordIsNotMarked() {
|
|
||||||
val body = """{"status":"abort","messages":["Differing locator <b>PM01</b> while importing QSO with <b>BH6RJD</b> for station locator <b>OL62TI</b>: SKIPPED<br>"]}"""
|
|
||||||
val outcome = classifyUploadResponse(400, body, calls)
|
|
||||||
assertEquals(WavelogUploadOutcome.Imported(2, 0, 1, listOf(1L, 3L)), outcome)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun duplicateAndSkipTogether() {
|
|
||||||
val body = """{"status":"abort","messages":["Differing locator <b>PM01</b> while importing QSO with <b>BH6RJD</b> for station locator <b>OL62TI</b>: SKIPPED<br>Date/Time: 2026-10-04 12:34:00 Callsign: BG5JVM Band: 70CM Duplicate for BA7OPF<br>"]}"""
|
|
||||||
val outcome = classifyUploadResponse(400, body, calls)
|
|
||||||
assertEquals(WavelogUploadOutcome.Imported(1, 1, 1, listOf(1L, 3L)), outcome)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun unmappedSkipMarksNothing() {
|
|
||||||
val body = """{"status":"abort","messages":["Differing locator <b>PM01</b> while importing QSO with <b>N0CALL</b> for station locator <b>OL62TI</b>: SKIPPED<br>"]}"""
|
|
||||||
val outcome = classifyUploadResponse(400, body, calls)
|
|
||||||
assertTrue(outcome is WavelogUploadOutcome.Rejected)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun otherMessagesAreRejected() {
|
|
||||||
val body = """{"status":"abort","messages":["QSO on 2026-10-04: You tried to import a QSO without any given CALL. This QSO wasn't imported. It's invalid.<br>"]}"""
|
|
||||||
val outcome = classifyUploadResponse(400, body, calls)
|
|
||||||
assertTrue(outcome is WavelogUploadOutcome.Rejected)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun authFailuresAreActionable() {
|
|
||||||
assertTrue(classifyUploadResponse(401, "", calls) is WavelogUploadOutcome.Failed)
|
|
||||||
val forbidden = classifyUploadResponse(403, """{"reason":"API key does not have write permissions"}""", calls)
|
|
||||||
assertTrue(forbidden is WavelogUploadOutcome.Failed)
|
|
||||||
assertTrue((forbidden as WavelogUploadOutcome.Failed).message.contains("write"))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun htmlGarbageOrEmptyBodyMarksNothing() {
|
|
||||||
assertTrue(classifyUploadResponse(400, "<html>oops</html>", calls) is WavelogUploadOutcome.Rejected)
|
|
||||||
assertTrue(classifyUploadResponse(400, "", calls) is WavelogUploadOutcome.Failed)
|
|
||||||
}
|
|
||||||
|
|
||||||
// endregion
|
|
||||||
|
|
||||||
// region # parseStations + prepare
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun stationsParse() {
|
|
||||||
val body = """[{"station_id":"1","station_profile_name":"Shenzhen","station_gridsquare":"OL62TI","station_callsign":"BA7OPF","station_active":"1"}]"""
|
|
||||||
val stations = parseStations(body)
|
|
||||||
assertEquals(1, stations.size)
|
|
||||||
assertEquals("1", stations.single().id)
|
|
||||||
assertEquals("Shenzhen", stations.single().name)
|
|
||||||
assertEquals("BA7OPF", stations.single().callsign)
|
|
||||||
assertEquals("OL62TI", stations.single().grid)
|
|
||||||
assertTrue(stations.single().active)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun prepareWithoutConfigIsBlocked() = runBlocking {
|
|
||||||
val repo = WavelogUploadRepository()
|
|
||||||
val preview = repo.prepare(listOf(sampleRecord(start = 1_700_000_000_000L)), WavelogUploadSettings())
|
|
||||||
assertEquals(WavelogProblem.NOT_CONFIGURED, preview.blockedBy)
|
|
||||||
assertEquals(0, preview.count)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun prepareWithoutStationIsBlocked() = runBlocking {
|
|
||||||
val repo = WavelogUploadRepository()
|
|
||||||
val settings = WavelogUploadSettings(url = "http://example.org", apiKey = "k")
|
|
||||||
val preview = repo.prepare(listOf(sampleRecord(start = 1_700_000_000_000L)), settings)
|
|
||||||
assertEquals(WavelogProblem.NO_STATION, preview.blockedBy)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun prepareSkipsGridMismatchAndDeduplicates() = runBlocking {
|
|
||||||
val repo = WavelogUploadRepository()
|
|
||||||
val settings = WavelogUploadSettings(
|
|
||||||
url = "http://example.org", apiKey = "k", stationId = "1",
|
|
||||||
stationName = "Shenzhen", stationCallsign = "BA7OPF", stationGrid = "OL62TI"
|
|
||||||
)
|
|
||||||
val records = listOf(
|
|
||||||
sampleRecord(start = 1_700_000_000_000L, myGrid = "PM01AA"),
|
|
||||||
sampleRecord(start = 1_700_000_120_000L, myGrid = "OL62TI"),
|
|
||||||
sampleRecord(start = 1_700_000_120_000L, myGrid = "OL62TI") // batch duplicate of the previous
|
|
||||||
)
|
|
||||||
val preview = repo.prepare(records, settings)
|
|
||||||
assertEquals(1, preview.count)
|
|
||||||
assertEquals(2, preview.skipped)
|
|
||||||
assertEquals(1, preview.reasons[WavelogProblem.GRID_MISMATCH])
|
|
||||||
assertEquals(1, preview.submittedIds.size)
|
|
||||||
assertEquals("Shenzhen · BA7OPF · OL62TI", preview.stationLabel)
|
|
||||||
}
|
|
||||||
|
|
||||||
// endregion
|
|
||||||
|
|
||||||
private fun sampleRecord(start: Long, myGrid: String = "OL62TI") = QsoRecord(
|
|
||||||
id = start,
|
|
||||||
startUtcMillis = start,
|
|
||||||
theirCallsign = "BG5JVM",
|
|
||||||
myCallsign = "BA7OPF",
|
|
||||||
myGrid = myGrid,
|
|
||||||
txFrequencyHz = 145_850_000L,
|
|
||||||
rxFrequencyHz = 436_795_000L,
|
|
||||||
band = "2M",
|
|
||||||
rxBand = "70CM",
|
|
||||||
mode = "FM",
|
|
||||||
satelliteName = "SO-50",
|
|
||||||
propagationMode = "SAT",
|
|
||||||
status = QsoStatus.COMPLETE
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,154 +0,0 @@
|
|||||||
/*
|
|
||||||
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
|
||||||
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
|
||||||
*
|
|
||||||
* This program is free software: you can redistribute it and/or modify
|
|
||||||
* it under the terms of the GNU General Public License as published by
|
|
||||||
* the Free Software Foundation, either version 3 of the License, or
|
|
||||||
* (at your option) any later version.
|
|
||||||
*
|
|
||||||
* This program is distributed in the hope that it will be useful,
|
|
||||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
* GNU General Public License for more details.
|
|
||||||
*
|
|
||||||
* You should have received a copy of the GNU General Public License
|
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
*/
|
|
||||||
package com.rtbishop.look4sat.core.domain.cw
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Bayesian Morse timing decoder.
|
|
||||||
* Replaces hard thresholds with probability-based decision making.
|
|
||||||
*
|
|
||||||
* Inspired by VE3NEA's CW Skimmer approach:
|
|
||||||
* "Instead of making a hard decision at every input sample whether the signal
|
|
||||||
* is present or not, compute the probability that the signal is present."
|
|
||||||
*
|
|
||||||
* Uses Gaussian probability density centered on expected durations:
|
|
||||||
* P(dit | duration) = exp(-(duration - dotMs)^2 / (2 * variance^2))
|
|
||||||
* P(dash | duration) = exp(-(duration - 3*dotMs)^2 / (2 * variance^2))
|
|
||||||
*/
|
|
||||||
internal class CwBayesianDecoder {
|
|
||||||
|
|
||||||
// Morse timing parameters
|
|
||||||
private var dotDurationMs = 60f // initial 20 WPM
|
|
||||||
private var speedWpm = 20f
|
|
||||||
|
|
||||||
// Current symbol being accumulated
|
|
||||||
private var currentSymbol = StringBuilder()
|
|
||||||
private var textBuffer = StringBuilder()
|
|
||||||
|
|
||||||
// Recent dit lengths for speed estimation
|
|
||||||
private val recentDits = mutableListOf<Float>()
|
|
||||||
|
|
||||||
// Output
|
|
||||||
private var _decodedText = ""
|
|
||||||
val decodedText: String get() = _decodedText
|
|
||||||
|
|
||||||
/** Gaussian probability. */
|
|
||||||
private fun gaussianProb(durationMs: Float, expectedMs: Float, varianceMs: Float): Float {
|
|
||||||
if (varianceMs <= 0f) return 0f
|
|
||||||
val diff = durationMs - expectedMs
|
|
||||||
return kotlin.math.exp(-(diff * diff) / (2 * varianceMs * varianceMs))
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Process a tone duration. Returns the symbol type with highest probability. */
|
|
||||||
fun processTone(durationMs: Float): ToneResult {
|
|
||||||
val ditProb = gaussianProb(durationMs, dotDurationMs, dotDurationMs * 0.4f)
|
|
||||||
val dashProb = gaussianProb(durationMs, dotDurationMs * 3f, dotDurationMs * 0.6f)
|
|
||||||
|
|
||||||
return if (ditProb > dashProb && ditProb > 0.05f) {
|
|
||||||
currentSymbol.append('0')
|
|
||||||
recentDits.add(durationMs)
|
|
||||||
updateSpeed()
|
|
||||||
ToneResult('0', ditProb)
|
|
||||||
} else if (dashProb > 0.05f) {
|
|
||||||
currentSymbol.append('1')
|
|
||||||
ToneResult('1', dashProb)
|
|
||||||
} else {
|
|
||||||
ToneResult(null, 0f)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Process a gap duration. Returns decoded character or null. */
|
|
||||||
fun processGap(durationMs: Float): Char? {
|
|
||||||
if (currentSymbol.isEmpty()) {
|
|
||||||
val wordProb = gaussianProb(durationMs, dotDurationMs * 7f, dotDurationMs * 1.2f)
|
|
||||||
if (wordProb > 0.2f) {
|
|
||||||
textBuffer.append(' ')
|
|
||||||
_decodedText = textBuffer.toString()
|
|
||||||
return ' '
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
val interCharProb = gaussianProb(durationMs, dotDurationMs * 3f, dotDurationMs * 0.6f)
|
|
||||||
val wordProb = gaussianProb(durationMs, dotDurationMs * 7f, dotDurationMs * 1.2f)
|
|
||||||
|
|
||||||
if (wordProb > interCharProb && wordProb > 0.2f) {
|
|
||||||
val char = flushSymbol()
|
|
||||||
textBuffer.append(' ')
|
|
||||||
_decodedText = textBuffer.toString()
|
|
||||||
return char
|
|
||||||
}
|
|
||||||
if (interCharProb > 0.15f) {
|
|
||||||
val char = flushSymbol()
|
|
||||||
_decodedText = textBuffer.toString()
|
|
||||||
return char
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun flushSymbol(): Char? {
|
|
||||||
if (currentSymbol.isEmpty()) return null
|
|
||||||
val morse = currentSymbol.toString()
|
|
||||||
currentSymbol.clear()
|
|
||||||
val char = morseToChar(morse)
|
|
||||||
if (char != null) textBuffer.append(char)
|
|
||||||
return char
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun updateSpeed() {
|
|
||||||
if (recentDits.size < 3) return
|
|
||||||
val sorted = recentDits.sorted()
|
|
||||||
val median = sorted[sorted.size / 2]
|
|
||||||
if (median > 0f) {
|
|
||||||
dotDurationMs = dotDurationMs * 0.7f + median * 0.3f
|
|
||||||
val wpm = 60.0f / (50.0f * dotDurationMs / 1000.0f)
|
|
||||||
if (wpm in 5f..55f) speedWpm = wpm
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getSpeed(): Float = speedWpm
|
|
||||||
|
|
||||||
fun reset() {
|
|
||||||
dotDurationMs = 60f
|
|
||||||
speedWpm = 20f
|
|
||||||
recentDits.clear()
|
|
||||||
currentSymbol.clear()
|
|
||||||
textBuffer.clear()
|
|
||||||
_decodedText = ""
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
private val MORSE_TABLE = mapOf(
|
|
||||||
"01" to 'A', "1000" to 'B', "1010" to 'C', "100" to 'D', "0" to 'E',
|
|
||||||
"0010" to 'F', "110" to 'G', "0000" to 'H', "00" to 'I', "0111" to 'J',
|
|
||||||
"101" to 'K', "0100" to 'L', "11" to 'M', "10" to 'N', "111" to 'O',
|
|
||||||
"0110" to 'P', "1101" to 'Q', "010" to 'R', "000" to 'S', "1" to 'T',
|
|
||||||
"001" to 'U', "0001" to 'V', "011" to 'W', "1001" to 'X', "1011" to 'Y',
|
|
||||||
"1100" to 'Z', "01111" to '1', "00111" to '2', "00011" to '3',
|
|
||||||
"00001" to '4', "00000" to '5', "10000" to '6', "11000" to '7',
|
|
||||||
"11100" to '8', "11110" to '9', "11111" to '0',
|
|
||||||
"010101" to '.', "110011" to ',', "001100" to '?', "011110" to '\'',
|
|
||||||
"101011" to '!', "10010" to '/', "10110" to '(', "101101" to ')',
|
|
||||||
"01000" to '&', "111000" to ':', "101010" to ';', "10001" to '=',
|
|
||||||
"01010" to '+', "100001" to '-', "001101" to '_', "010010" to '"',
|
|
||||||
"0001001" to '$', "011010" to '@'
|
|
||||||
)
|
|
||||||
fun morseToChar(morse: String): Char? = MORSE_TABLE[morse]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
data class ToneResult(val symbol: Char?, val probability: Float)
|
|
||||||
@@ -1,114 +0,0 @@
|
|||||||
/*
|
|
||||||
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
|
||||||
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
|
||||||
*
|
|
||||||
* This program is free software: you can redistribute it and/or modify
|
|
||||||
* it under the terms of the GNU General Public License as published by
|
|
||||||
* the Free Software Foundation, either version 3 of the License, or
|
|
||||||
* (at your option) any later version.
|
|
||||||
*
|
|
||||||
* This program is distributed in the hope that it will be useful,
|
|
||||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
* GNU General Public License for more details.
|
|
||||||
*
|
|
||||||
* You should have received a copy of the GNU General Public License
|
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
*/
|
|
||||||
package com.rtbishop.look4sat.core.domain.cw
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Multi-channel CW signal tracker.
|
|
||||||
* Monitors the spectrogram for active frequency bins and extracts
|
|
||||||
* energy envelopes for each detected signal.
|
|
||||||
*
|
|
||||||
* Inspired by CW Skimmer's multi-channel approach:
|
|
||||||
* tracks all active signals in the passband simultaneously,
|
|
||||||
* selects the best one for decoded output.
|
|
||||||
*/
|
|
||||||
internal class CwChannelTracker(
|
|
||||||
private val spectrogram: CwSpectrogram,
|
|
||||||
private val maxChannels: Int = 3
|
|
||||||
) {
|
|
||||||
data class Channel(
|
|
||||||
val bin: Int,
|
|
||||||
val frequency: Float,
|
|
||||||
var active: Boolean = false,
|
|
||||||
var energy: Float = 0f,
|
|
||||||
val history: MutableList<Float> = mutableListOf(),
|
|
||||||
var confidence: Float = 0f
|
|
||||||
)
|
|
||||||
|
|
||||||
private val channels = Array(maxChannels) { Channel(0, 0f) }
|
|
||||||
|
|
||||||
/** Scan the current spectrogram column and update channel tracking. */
|
|
||||||
fun update(): List<Channel> {
|
|
||||||
val col = spectrogram.getCurrentColumn()
|
|
||||||
val peaks = findPeaks(col, threshold = 0.3f, minDistance = 2)
|
|
||||||
|
|
||||||
// Update existing channels
|
|
||||||
for (ch in channels) {
|
|
||||||
if (ch.active) {
|
|
||||||
if (peaks.contains(ch.bin)) {
|
|
||||||
ch.energy = col[ch.bin]
|
|
||||||
ch.history.add(ch.energy)
|
|
||||||
if (ch.history.size > 40) ch.history.removeAt(0)
|
|
||||||
ch.confidence = computeConfidence(ch.history)
|
|
||||||
} else {
|
|
||||||
// Signal lost — decay confidence
|
|
||||||
ch.history.add(0f)
|
|
||||||
if (ch.history.size > 40) ch.history.removeAt(0)
|
|
||||||
ch.confidence *= 0.9f
|
|
||||||
if (ch.confidence < 0.1f) ch.active = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Assign new peaks to inactive channels
|
|
||||||
var peakIdx = 0
|
|
||||||
for (ch in channels) {
|
|
||||||
if (!ch.active && peakIdx < peaks.size) {
|
|
||||||
val bin = peaks[peakIdx]
|
|
||||||
val freq = spectrogram.binToFreq(bin)
|
|
||||||
// Re-initialize channel
|
|
||||||
channels[peakIdx] = Channel(bin, freq, true, col[bin], mutableListOf(), 0.5f)
|
|
||||||
peakIdx++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return channels.filter { it.active }
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Find peak bins in the spectrum. */
|
|
||||||
private fun findPeaks(spectrum: FloatArray, threshold: Float, minDistance: Int): List<Int> {
|
|
||||||
val peaks = mutableListOf<Int>()
|
|
||||||
for (i in 1 until spectrum.size - 1) {
|
|
||||||
if (spectrum[i] > spectrum[i - 1] && spectrum[i] > spectrum[i + 1] && spectrum[i] > threshold) {
|
|
||||||
if (peaks.isEmpty() || i - peaks.last() >= minDistance) {
|
|
||||||
peaks.add(i)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return peaks.sortedByDescending { spectrum[it] }
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Compute confidence from energy history. Lower variance = higher confidence. */
|
|
||||||
private fun computeConfidence(history: List<Float>): Float {
|
|
||||||
if (history.size < 10) return 0.3f
|
|
||||||
val recent = history.takeLast(10)
|
|
||||||
val mean = recent.average().toFloat()
|
|
||||||
val variance = recent.map { (it - mean) * (it - mean) }.average().toFloat()
|
|
||||||
return if (mean > 0f) (mean / (mean + variance + 0.1f)).coerceIn(0f, 1f) else 0f
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Get the channel with highest confidence. */
|
|
||||||
fun getBestChannel(): Channel? {
|
|
||||||
return channels.filter { it.active }.maxByOrNull { it.confidence }
|
|
||||||
}
|
|
||||||
|
|
||||||
fun reset() {
|
|
||||||
for (i in channels.indices) {
|
|
||||||
channels[i] = Channel(0, 0f)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,165 +0,0 @@
|
|||||||
/*
|
|
||||||
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
|
||||||
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
|
||||||
*
|
|
||||||
* This program is free software: you can redistribute it and/or modify
|
|
||||||
* it under the terms of the GNU General Public License as published by
|
|
||||||
* the Free Software Foundation, either version 3 of the License, or
|
|
||||||
* (at your option) any later version.
|
|
||||||
*
|
|
||||||
* This program is distributed in the hope that it will be useful,
|
|
||||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
* GNU General Public License for more details.
|
|
||||||
*
|
|
||||||
* You should have received a copy of the GNU General Public License
|
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
*/
|
|
||||||
package com.rtbishop.look4sat.core.domain.cw
|
|
||||||
|
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
|
||||||
|
|
||||||
/**
|
|
||||||
* CW (Morse code) decoder v3 — Spectrogram-based multi-channel Bayesian decoder.
|
|
||||||
*
|
|
||||||
* Architecture inspired by Morse Expert / CW Skimmer (VE3NEA):
|
|
||||||
* 1. FFT spectrogram creates a frequency×time matrix
|
|
||||||
* 2. Multi-channel peak detector finds all active signals
|
|
||||||
* 3. Per-channel energy envelope extraction
|
|
||||||
* 4. Bayesian probability for symbol timing (Gaussian likelihood)
|
|
||||||
* 5. Best channel selected for output
|
|
||||||
*
|
|
||||||
* Timing analysis is performed per spectrogram column (hop).
|
|
||||||
* Each column represents hopSize/sampleRate seconds of audio.
|
|
||||||
*/
|
|
||||||
class CwDecoder(
|
|
||||||
val sampleRate: Int = 8000,
|
|
||||||
cwToneFreq: Float = -1f // ignored in v3 (auto-detect via spectrogram)
|
|
||||||
) {
|
|
||||||
companion object {
|
|
||||||
private const val FFT_SIZE = 256
|
|
||||||
private const val HOP_SIZE = 64
|
|
||||||
}
|
|
||||||
|
|
||||||
private val spectrogram = CwSpectrogram(
|
|
||||||
fftSize = FFT_SIZE,
|
|
||||||
hopSize = HOP_SIZE,
|
|
||||||
sampleRate = sampleRate,
|
|
||||||
minBin = 6,
|
|
||||||
maxBin = 38,
|
|
||||||
historyCols = 40
|
|
||||||
)
|
|
||||||
private val channelTracker = CwChannelTracker(spectrogram, maxChannels = 3)
|
|
||||||
private val bayesianDecoder = CwBayesianDecoder()
|
|
||||||
|
|
||||||
// Timing state per channel
|
|
||||||
private data class ChannelTiming(
|
|
||||||
var isSignal: Boolean = false,
|
|
||||||
var toneTicks: Int = 0,
|
|
||||||
var gapTicks: Int = 0
|
|
||||||
)
|
|
||||||
private val timingStates = Array(3) { ChannelTiming() }
|
|
||||||
|
|
||||||
// Time per spectrogram column in milliseconds
|
|
||||||
private val tickMs = 1000f * HOP_SIZE / sampleRate
|
|
||||||
|
|
||||||
// Output flows
|
|
||||||
private val _decodedTextFlow = MutableStateFlow("")
|
|
||||||
val decodedTextFlow: StateFlow<String> = _decodedTextFlow
|
|
||||||
|
|
||||||
private val _signalStrength = MutableStateFlow(0f)
|
|
||||||
val signalStrength: StateFlow<Float> = _signalStrength
|
|
||||||
|
|
||||||
private val _estimatedPitch = MutableStateFlow<Float?>(null)
|
|
||||||
val estimatedPitch: StateFlow<Float?> = _estimatedPitch
|
|
||||||
|
|
||||||
private val _estimatedSpeed = MutableStateFlow<Float?>(null)
|
|
||||||
val estimatedSpeed: StateFlow<Float?> = _estimatedSpeed
|
|
||||||
|
|
||||||
private var frameCount = 0
|
|
||||||
|
|
||||||
init {
|
|
||||||
if (cwToneFreq > 0f) {
|
|
||||||
_estimatedPitch.value = cwToneFreq
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun processBuffer(buffer: FloatArray) {
|
|
||||||
// 1. Feed samples to spectrogram
|
|
||||||
spectrogram.addSamples(buffer)
|
|
||||||
|
|
||||||
// 2. Get number of new columns generated
|
|
||||||
val newCols = spectrogram.getNewColumns()
|
|
||||||
if (newCols == 0) return
|
|
||||||
|
|
||||||
// 3. Update channel tracker (uses latest column for peak detection)
|
|
||||||
val activeChannels = channelTracker.update()
|
|
||||||
|
|
||||||
// 4. Process each new column for timing analysis
|
|
||||||
// Columns are indexed 0..historyCols-1, where historyCols-1 is the newest
|
|
||||||
val baseIdx = (spectrogram.historyCols - newCols).coerceAtLeast(0)
|
|
||||||
for (colOffset in 0 until newCols) {
|
|
||||||
val col = spectrogram.getColumn(baseIdx + colOffset)
|
|
||||||
|
|
||||||
for ((idx, channel) in activeChannels.withIndex()) {
|
|
||||||
if (idx >= timingStates.size) break
|
|
||||||
val state = timingStates[idx]
|
|
||||||
val energy = if (channel.bin in col.indices) col[channel.bin] else 0f
|
|
||||||
|
|
||||||
// Adaptive threshold
|
|
||||||
val threshold = 0.3f + (energy - 0.3f) * 0.3f
|
|
||||||
|
|
||||||
if (energy > threshold) {
|
|
||||||
if (!state.isSignal) {
|
|
||||||
if (state.gapTicks > 0) {
|
|
||||||
val gapMs = state.gapTicks * tickMs
|
|
||||||
bayesianDecoder.processGap(gapMs)
|
|
||||||
}
|
|
||||||
state.gapTicks = 0
|
|
||||||
state.isSignal = true
|
|
||||||
}
|
|
||||||
state.toneTicks++
|
|
||||||
} else {
|
|
||||||
if (state.isSignal) {
|
|
||||||
if (state.toneTicks > 0) {
|
|
||||||
val toneMs = state.toneTicks * tickMs
|
|
||||||
bayesianDecoder.processTone(toneMs)
|
|
||||||
}
|
|
||||||
state.toneTicks = 0
|
|
||||||
state.isSignal = false
|
|
||||||
}
|
|
||||||
state.gapTicks++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 5. Update outputs
|
|
||||||
frameCount++
|
|
||||||
if (frameCount % 5 == 0) {
|
|
||||||
val bestChannel = channelTracker.getBestChannel()
|
|
||||||
if (bestChannel != null) {
|
|
||||||
_estimatedPitch.value = bestChannel.frequency
|
|
||||||
_signalStrength.value = bestChannel.confidence
|
|
||||||
_estimatedSpeed.value = bayesianDecoder.getSpeed()
|
|
||||||
}
|
|
||||||
_decodedTextFlow.value = bayesianDecoder.decodedText
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun resetDecoder() {
|
|
||||||
spectrogram.reset()
|
|
||||||
channelTracker.reset()
|
|
||||||
bayesianDecoder.reset()
|
|
||||||
for (state in timingStates) {
|
|
||||||
state.isSignal = false
|
|
||||||
state.toneTicks = 0
|
|
||||||
state.gapTicks = 0
|
|
||||||
}
|
|
||||||
frameCount = 0
|
|
||||||
_decodedTextFlow.value = ""
|
|
||||||
_signalStrength.value = 0f
|
|
||||||
_estimatedPitch.value = null
|
|
||||||
_estimatedSpeed.value = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,102 +0,0 @@
|
|||||||
/*
|
|
||||||
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
|
||||||
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
|
||||||
*
|
|
||||||
* This program is free software: you can redistribute it and/or modify
|
|
||||||
* it under the terms of the GNU General Public License as published by
|
|
||||||
* the Free Software Foundation, either version 3 of the License, or
|
|
||||||
* (at your option) any later version.
|
|
||||||
*
|
|
||||||
* This program is distributed in the hope that it will be useful,
|
|
||||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
* GNU General Public License for more details.
|
|
||||||
*
|
|
||||||
* You should have received a copy of the GNU General Public License
|
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
*/
|
|
||||||
package com.rtbishop.look4sat.core.domain.cw
|
|
||||||
|
|
||||||
import kotlin.math.PI
|
|
||||||
import kotlin.math.cos
|
|
||||||
import kotlin.math.sin
|
|
||||||
import kotlin.math.sqrt
|
|
||||||
|
|
||||||
/**
|
|
||||||
* DSP utilities for CW (Morse code) decoding.
|
|
||||||
* Pure Kotlin, no NDK required.
|
|
||||||
*/
|
|
||||||
internal object CwDsp {
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Design a simple bandpass FIR filter coefficients using windowed sinc method.
|
|
||||||
* @param lowCutoff lower cutoff frequency (Hz) as fraction of sampleRate
|
|
||||||
* @param highCutoff upper cutoff frequency (Hz) as fraction of sampleRate
|
|
||||||
* @param taps filter length (must be odd)
|
|
||||||
*/
|
|
||||||
fun bandpassFir(lowCutoff: Double, highCutoff: Double, taps: Int): FloatArray {
|
|
||||||
val n = if (taps % 2 == 0) taps + 1 else taps
|
|
||||||
val half = n / 2
|
|
||||||
val coeffs = FloatArray(n)
|
|
||||||
for (i in 0 until n) {
|
|
||||||
val idx = i - half
|
|
||||||
if (idx == 0) {
|
|
||||||
coeffs[i] = (2.0 * (highCutoff - lowCutoff)).toFloat()
|
|
||||||
} else {
|
|
||||||
val x = PI * idx
|
|
||||||
coeffs[i] = ((sin(2 * highCutoff * x) - sin(2 * lowCutoff * x)) / x).toFloat()
|
|
||||||
}
|
|
||||||
// Hamming window
|
|
||||||
coeffs[i] = (coeffs[i] * (0.54 - 0.46 * cos(2 * PI * i / (n - 1)))).toFloat()
|
|
||||||
}
|
|
||||||
// Normalize
|
|
||||||
val sum = coeffs.sum()
|
|
||||||
if (sum != 0f) for (i in 0 until n) coeffs[i] /= sum
|
|
||||||
return coeffs
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Apply FIR filter to a buffer. */
|
|
||||||
fun applyFir(buffer: FloatArray, coeffs: FloatArray): FloatArray {
|
|
||||||
val out = FloatArray(buffer.size)
|
|
||||||
for (i in buffer.indices) {
|
|
||||||
var sum = 0f
|
|
||||||
for (j in coeffs.indices) {
|
|
||||||
val idx = i - j
|
|
||||||
if (idx >= 0) sum += buffer[idx] * coeffs[j]
|
|
||||||
}
|
|
||||||
out[i] = sum
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Simple envelope detector: abs + low-pass smoothing. */
|
|
||||||
fun envelope(signal: FloatArray, alpha: Float = 0.1f): FloatArray {
|
|
||||||
val env = FloatArray(signal.size)
|
|
||||||
var s = 0f
|
|
||||||
for (i in signal.indices) {
|
|
||||||
s = alpha * kotlin.math.abs(signal[i]) + (1 - alpha) * s
|
|
||||||
env[i] = s
|
|
||||||
}
|
|
||||||
return env
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Estimate noise floor from envelope for adaptive thresholding. */
|
|
||||||
fun noiseFloor(env: FloatArray, fraction: Float = 0.3f): Float {
|
|
||||||
val sorted = env.sortedArray()
|
|
||||||
val median = sorted[sorted.size / 2]
|
|
||||||
return median + (sorted[sorted.size * 9 / 10] - median) * fraction
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Simple Goertzel to detect a specific tone frequency. */
|
|
||||||
fun goertzel(buffer: FloatArray, targetFreq: Float, sampleRate: Int): Float {
|
|
||||||
val omega = 2.0 * PI * targetFreq / sampleRate
|
|
||||||
val coeff = 2.0 * cos(omega)
|
|
||||||
var s0 = 0.0; var s1 = 0.0; var s2 = 0.0
|
|
||||||
for (sample in buffer) {
|
|
||||||
s0 = sample.toDouble() + coeff * s1 - s2
|
|
||||||
s2 = s1; s1 = s0
|
|
||||||
}
|
|
||||||
val power = s2 * s2 + s1 * s1 - coeff * s1 * s2
|
|
||||||
return sqrt(kotlin.math.abs(power)).toFloat()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,87 +0,0 @@
|
|||||||
/*
|
|
||||||
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
|
||||||
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
|
||||||
*
|
|
||||||
* This program is free software: you can redistribute it and/or modify
|
|
||||||
* it under the terms of the GNU General Public License as published by
|
|
||||||
* the Free Software Foundation, either version 3 of the License, or
|
|
||||||
* (at your option) any later version.
|
|
||||||
*
|
|
||||||
* This program is distributed in the hope that it will be useful,
|
|
||||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
* GNU General Public License for more details.
|
|
||||||
*
|
|
||||||
* You should have received a copy of the GNU General Public License
|
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
*/
|
|
||||||
package com.rtbishop.look4sat.core.domain.cw
|
|
||||||
|
|
||||||
import kotlin.math.cos
|
|
||||||
import kotlin.math.sqrt
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Radix-2 FFT for real-valued input.
|
|
||||||
* Produces magnitude spectrum for the first N/2+1 bins.
|
|
||||||
* Used by CwSpectrogram for time-frequency analysis.
|
|
||||||
*/
|
|
||||||
internal class CwFFT(private val n: Int) {
|
|
||||||
init {
|
|
||||||
require(n > 0 && n and (n - 1) == 0) { "FFT size must be power of 2, got $n" }
|
|
||||||
}
|
|
||||||
|
|
||||||
private val cosTable = FloatArray(n / 2)
|
|
||||||
private val sinTable = FloatArray(n / 2)
|
|
||||||
|
|
||||||
init {
|
|
||||||
for (i in 0 until n / 2) {
|
|
||||||
val angle = -2.0 * kotlin.math.PI * i / n
|
|
||||||
cosTable[i] = cos(angle).toFloat()
|
|
||||||
sinTable[i] = kotlin.math.sin(angle).toFloat()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Compute magnitude spectrum for real input. Returns array of size n/2+1. */
|
|
||||||
fun magnitudeSpectrum(input: FloatArray): FloatArray {
|
|
||||||
require(input.size == n) { "Input size must be $n, got ${input.size}" }
|
|
||||||
|
|
||||||
val real = input.copyOf()
|
|
||||||
val imag = FloatArray(n)
|
|
||||||
|
|
||||||
// Bit-reversal permutation
|
|
||||||
var j = 0
|
|
||||||
for (i in 1 until n) {
|
|
||||||
var bit = n shr 1
|
|
||||||
while (j and bit != 0) { j = j xor bit; bit = bit shr 1 }
|
|
||||||
j = j xor bit
|
|
||||||
if (i < j) {
|
|
||||||
var tmp = real[i]; real[i] = real[j]; real[j] = tmp
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Radix-2 Cooley-Tukey FFT
|
|
||||||
var len = 2
|
|
||||||
while (len <= n) {
|
|
||||||
val half = len / 2
|
|
||||||
val step = n / len
|
|
||||||
for (i in 0 until n step len) {
|
|
||||||
for (k in 0 until half) {
|
|
||||||
val tReal = real[i + k + half] * cosTable[k * step] - imag[i + k + half] * sinTable[k * step]
|
|
||||||
val tImag = real[i + k + half] * sinTable[k * step] + imag[i + k + half] * cosTable[k * step]
|
|
||||||
real[i + k + half] = real[i + k] - tReal
|
|
||||||
imag[i + k + half] = imag[i + k] - tImag
|
|
||||||
real[i + k] += tReal
|
|
||||||
imag[i + k] += tImag
|
|
||||||
}
|
|
||||||
}
|
|
||||||
len = len shl 1
|
|
||||||
}
|
|
||||||
|
|
||||||
// Magnitude spectrum (first N/2+1 bins)
|
|
||||||
val mag = FloatArray(n / 2 + 1)
|
|
||||||
for (i in 0..n / 2) {
|
|
||||||
mag[i] = sqrt(real[i] * real[i] + imag[i] * imag[i]) / n
|
|
||||||
}
|
|
||||||
return mag
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
/*
|
|
||||||
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
|
||||||
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
|
||||||
*
|
|
||||||
* This program is free software: you can redistribute it and/or modify
|
|
||||||
* it under the terms of the GNU General Public License as published by
|
|
||||||
* the Free Software Foundation, either version 3 of the License, or
|
|
||||||
* (at your option) any later version.
|
|
||||||
*
|
|
||||||
* This program is distributed in the hope that it will be useful,
|
|
||||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
* GNU General Public License for more details.
|
|
||||||
*
|
|
||||||
* You should have received a copy of the GNU General Public License
|
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
*/
|
|
||||||
package com.rtbishop.look4sat.core.domain.cw
|
|
||||||
|
|
||||||
/**
|
|
||||||
* First-order IIR filters.
|
|
||||||
* Ported from ggmorse/src/filter.h
|
|
||||||
*/
|
|
||||||
internal class CwFilter {
|
|
||||||
private var z1 = 0f
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
private const val PI_F = 3.141592653589793f
|
|
||||||
}
|
|
||||||
|
|
||||||
fun highPass(sample: Float, cutoffHz: Float, sampleRate: Float): Float {
|
|
||||||
val rc = 1.0f / (2f * PI_F * cutoffHz)
|
|
||||||
val dt = 1.0f / sampleRate
|
|
||||||
val alpha = dt / (rc + dt)
|
|
||||||
z1 = alpha * (z1 + sample - z1)
|
|
||||||
return sample - z1
|
|
||||||
}
|
|
||||||
|
|
||||||
fun lowPass(sample: Float, cutoffHz: Float, sampleRate: Float): Float {
|
|
||||||
val rc = 1.0f / (2f * PI_F * cutoffHz)
|
|
||||||
val dt = 1.0f / sampleRate
|
|
||||||
val alpha = dt / (rc + dt)
|
|
||||||
z1 += alpha * (sample - z1)
|
|
||||||
return z1
|
|
||||||
}
|
|
||||||
|
|
||||||
fun reset() { z1 = 0f }
|
|
||||||
}
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
/*
|
|
||||||
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
|
||||||
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
|
||||||
*
|
|
||||||
* This program is free software: you can redistribute it and/or modify
|
|
||||||
* it under the terms of the GNU General Public License as published by
|
|
||||||
* the Free Software Foundation, either version 3 of the License, or
|
|
||||||
* (at your option) any later version.
|
|
||||||
*
|
|
||||||
* This program is distributed in the hope that it will be useful,
|
|
||||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
* GNU General Public License for more details.
|
|
||||||
*
|
|
||||||
* You should have received a copy of the GNU General Public License
|
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
*/
|
|
||||||
package com.rtbishop.look4sat.core.domain.cw
|
|
||||||
|
|
||||||
import kotlin.math.cos
|
|
||||||
import kotlin.math.sqrt
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Running Goertzel filter for CW tone detection.
|
|
||||||
* Tracks a specific frequency over time with a sliding window.
|
|
||||||
* Ported from ggmorse/src/goertzel.h
|
|
||||||
*/
|
|
||||||
internal class CwGoertzel {
|
|
||||||
private var s1 = 0.0
|
|
||||||
private var s2 = 0.0
|
|
||||||
private var coeff = 0.0
|
|
||||||
|
|
||||||
fun init(sampleRate: Float, targetFreq: Float) {
|
|
||||||
val omega = 2.0 * kotlin.math.PI * targetFreq / sampleRate
|
|
||||||
coeff = 2.0 * cos(omega)
|
|
||||||
s1 = 0.0
|
|
||||||
s2 = 0.0
|
|
||||||
}
|
|
||||||
|
|
||||||
fun process(sample: Float) {
|
|
||||||
val s0 = sample.toDouble() + coeff * s1 - s2
|
|
||||||
s2 = s1
|
|
||||||
s1 = s0
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getPower(): Float {
|
|
||||||
return sqrt(s2 * s2 + s1 * s1 - coeff * s1 * s2).toFloat()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun reset() {
|
|
||||||
s1 = 0.0
|
|
||||||
s2 = 0.0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
/*
|
|
||||||
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
|
||||||
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
|
||||||
*
|
|
||||||
* This program is free software: you can redistribute it and/or modify
|
|
||||||
* it under the terms of the GNU General Public License as published by
|
|
||||||
* the Free Software Foundation, either version 3 of the License, or
|
|
||||||
* (at your option) any later version.
|
|
||||||
*
|
|
||||||
* This program is distributed in the hope that it will be useful,
|
|
||||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
* GNU General Public License for more details.
|
|
||||||
*
|
|
||||||
* You should have received a copy of the GNU General Public License
|
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
*/
|
|
||||||
package com.rtbishop.look4sat.core.domain.cw
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Simple linear resampler.
|
|
||||||
* Downsamples from input sample rate to output sample rate.
|
|
||||||
* Ported from ggmorse/src/resampler.h
|
|
||||||
*/
|
|
||||||
internal class CwResampler(private val inputRate: Float, private val outputRate: Float) {
|
|
||||||
private val ratio = inputRate / outputRate
|
|
||||||
private var lastSample = 0f
|
|
||||||
|
|
||||||
fun process(input: FloatArray): FloatArray {
|
|
||||||
if (ratio <= 0f || input.isEmpty()) return input
|
|
||||||
val outputLen = (input.size / ratio).toInt() + 1
|
|
||||||
val output = FloatArray(outputLen)
|
|
||||||
var idx = 0f
|
|
||||||
for (i in output.indices) {
|
|
||||||
val intIdx = idx.toInt()
|
|
||||||
val frac = idx - intIdx
|
|
||||||
if (intIdx + 1 < input.size) {
|
|
||||||
output[i] = input[intIdx] * (1 - frac) + input[intIdx + 1] * frac
|
|
||||||
} else if (intIdx < input.size) {
|
|
||||||
output[i] = input[intIdx] * (1 - frac) + lastSample * frac
|
|
||||||
} else {
|
|
||||||
output[i] = lastSample
|
|
||||||
}
|
|
||||||
idx += ratio
|
|
||||||
}
|
|
||||||
lastSample = input.lastOrNull() ?: lastSample
|
|
||||||
return output
|
|
||||||
}
|
|
||||||
|
|
||||||
fun reset() {
|
|
||||||
lastSample = 0f
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
/*
|
|
||||||
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
|
||||||
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
|
||||||
*
|
|
||||||
* This program is free software: you can redistribute it and/or modify
|
|
||||||
* it under the terms of the GNU General Public License as published by
|
|
||||||
* the Free Software Foundation, either version 3 of the License, or
|
|
||||||
* (at your option) any later version.
|
|
||||||
*
|
|
||||||
* This program is distributed in the hope that it will be useful,
|
|
||||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
* GNU General Public License for more details.
|
|
||||||
*
|
|
||||||
* You should have received a copy of the GNU General Public License
|
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
*/
|
|
||||||
package com.rtbishop.look4sat.core.domain.cw
|
|
||||||
|
|
||||||
import kotlin.math.cos
|
|
||||||
import kotlin.math.sin
|
|
||||||
import kotlin.math.sqrt
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Lightweight pitch detector using DFT at specific frequency bins.
|
|
||||||
* Only scans [200, 1200] Hz in configurable steps — much faster than full FFT.
|
|
||||||
* Ported from ggmorse/src/stfft.h (simplified for CW use case).
|
|
||||||
*/
|
|
||||||
internal class CwPitchDetector(
|
|
||||||
private val sampleRate: Float,
|
|
||||||
private val minFreq: Float = 200f,
|
|
||||||
private val maxFreq: Float = 1200f,
|
|
||||||
private val stepHz: Float = 10f
|
|
||||||
) {
|
|
||||||
/**
|
|
||||||
* Find the dominant pitch frequency in the buffer.
|
|
||||||
* Returns null if no significant pitch found.
|
|
||||||
*/
|
|
||||||
fun findPitch(buffer: FloatArray): Float? {
|
|
||||||
if (buffer.isEmpty()) return null
|
|
||||||
var bestFreq = 0f
|
|
||||||
var bestPower = 0f
|
|
||||||
var freq = minFreq
|
|
||||||
while (freq <= maxFreq) {
|
|
||||||
var real = 0.0
|
|
||||||
var imag = 0.0
|
|
||||||
val omega = 2.0 * kotlin.math.PI * freq / sampleRate
|
|
||||||
for (i in buffer.indices) {
|
|
||||||
real += buffer[i] * cos(omega * i)
|
|
||||||
imag += buffer[i] * -sin(omega * i)
|
|
||||||
}
|
|
||||||
val power = (real * real + imag * imag).toFloat()
|
|
||||||
if (power > bestPower) {
|
|
||||||
bestPower = power
|
|
||||||
bestFreq = freq
|
|
||||||
}
|
|
||||||
freq += stepHz
|
|
||||||
}
|
|
||||||
return if (bestPower > 0.001f) bestFreq else null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,170 +0,0 @@
|
|||||||
/*
|
|
||||||
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
|
||||||
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
|
||||||
*
|
|
||||||
* This program is free software: you can redistribute it and/or modify
|
|
||||||
* it under the terms of the GNU General Public License as published by
|
|
||||||
* the Free Software Foundation, either version 3 of the License, or
|
|
||||||
* (at your option) any later version.
|
|
||||||
*
|
|
||||||
* This program is distributed in the hope that it will be useful,
|
|
||||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
* GNU General Public License for more details.
|
|
||||||
*
|
|
||||||
* You should have received a copy of the GNU General Public License
|
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
*/
|
|
||||||
package com.rtbishop.look4sat.core.domain.cw
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sliding-window spectrogram for CW decoding.
|
|
||||||
* Maintains a time-frequency matrix updated with each audio frame.
|
|
||||||
*
|
|
||||||
* FFT size: 256, hop size: 64, sample rate: 4000 (or native)
|
|
||||||
* Frequency bins: 6..38 (187-1187 Hz, covers typical CW range)
|
|
||||||
* History: 40 columns (320 ms window)
|
|
||||||
* Time resolution: 64/4000 = 16 ms, Frequency resolution: 4000/256 = 15.625 Hz
|
|
||||||
*/
|
|
||||||
internal class CwSpectrogram(
|
|
||||||
private val fftSize: Int = 256,
|
|
||||||
private val hopSize: Int = 64,
|
|
||||||
private val sampleRate: Int = 4000,
|
|
||||||
private val minBin: Int = 6,
|
|
||||||
private val maxBin: Int = 38,
|
|
||||||
val historyCols: Int = 40
|
|
||||||
) {
|
|
||||||
private val fft = CwFFT(fftSize)
|
|
||||||
val numBins: Int get() = maxBin - minBin + 1
|
|
||||||
|
|
||||||
// Hanning window
|
|
||||||
private val hanning = FloatArray(fftSize) {
|
|
||||||
(0.5 - 0.5 * kotlin.math.cos(2.0 * kotlin.math.PI * it / (fftSize - 1))).toFloat()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Spectrogram data: [timeCol][freqBin]
|
|
||||||
private val spectrogram = Array(historyCols) { FloatArray(numBins) }
|
|
||||||
private var currentCol = 0
|
|
||||||
private var samplesBuffered = 0
|
|
||||||
private val buffer = FloatArray(fftSize)
|
|
||||||
|
|
||||||
// Per-bin running energy for normalization
|
|
||||||
private val binEnergy = FloatArray(numBins) { 1f }
|
|
||||||
private val alpha = 0.95f
|
|
||||||
|
|
||||||
// Counter for new columns generated since last check
|
|
||||||
private var newColumnCount = 0
|
|
||||||
|
|
||||||
/** Add audio samples, compute FFTs for each complete hop. */
|
|
||||||
fun addSamples(samples: FloatArray) {
|
|
||||||
var offset = 0
|
|
||||||
while (offset < samples.size) {
|
|
||||||
val needed = fftSize - samplesBuffered
|
|
||||||
val copyLen = minOf(needed, samples.size - offset)
|
|
||||||
System.arraycopy(samples, offset, buffer, samplesBuffered, copyLen)
|
|
||||||
samplesBuffered += copyLen
|
|
||||||
offset += copyLen
|
|
||||||
|
|
||||||
if (samplesBuffered >= fftSize) {
|
|
||||||
processFrame()
|
|
||||||
newColumnCount++
|
|
||||||
// Shift buffer: keep last (fftSize - hopSize) samples
|
|
||||||
System.arraycopy(buffer, hopSize, buffer, 0, fftSize - hopSize)
|
|
||||||
samplesBuffered = fftSize - hopSize
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Get number of new columns generated since the last call to this method. */
|
|
||||||
fun getNewColumns(): Int {
|
|
||||||
val count = newColumnCount
|
|
||||||
newColumnCount = 0
|
|
||||||
return count
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun processFrame() {
|
|
||||||
// Apply Hanning window
|
|
||||||
val windowed = FloatArray(fftSize) { buffer[it] * hanning[it] }
|
|
||||||
|
|
||||||
// Compute FFT magnitude spectrum
|
|
||||||
val mag = fft.magnitudeSpectrum(windowed)
|
|
||||||
|
|
||||||
// Update spectrogram column
|
|
||||||
val col = spectrogram[currentCol]
|
|
||||||
for (b in 0 until numBins) {
|
|
||||||
val binIdx = minBin + b
|
|
||||||
val rawMag = mag[binIdx]
|
|
||||||
// Running energy normalization
|
|
||||||
binEnergy[b] = alpha * binEnergy[b] + (1 - alpha) * rawMag
|
|
||||||
col[b] = if (binEnergy[b] > 1e-6f) rawMag / binEnergy[b] else 0f
|
|
||||||
}
|
|
||||||
|
|
||||||
currentCol = (currentCol + 1) % historyCols
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Get the current spectrogram as a 2D array in chronological order. */
|
|
||||||
fun getSpectrogram(): Array<FloatArray> {
|
|
||||||
val result = Array(historyCols) { i ->
|
|
||||||
val srcIdx = (currentCol + i) % historyCols
|
|
||||||
spectrogram[srcIdx].copyOf()
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Get the most recent column (current energy across all frequencies). */
|
|
||||||
fun getCurrentColumn(): FloatArray {
|
|
||||||
val prevCol = (currentCol - 1 + historyCols) % historyCols
|
|
||||||
return spectrogram[prevCol].copyOf()
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Get a column by index from the history (0 = oldest, historyCols-1 = newest). */
|
|
||||||
fun getColumn(index: Int): FloatArray {
|
|
||||||
val clamped = index.coerceIn(0, historyCols - 1)
|
|
||||||
val srcIdx = (currentCol - historyCols + clamped + historyCols) % historyCols
|
|
||||||
return spectrogram[srcIdx].copyOf()
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Find the frequency bin with peak energy. Returns -1 if no significant signal. */
|
|
||||||
fun findPeakBin(): Int {
|
|
||||||
val col = getCurrentColumn()
|
|
||||||
var maxBin = -1
|
|
||||||
var maxVal = 0f
|
|
||||||
for (i in col.indices) {
|
|
||||||
if (col[i] > maxVal) {
|
|
||||||
maxVal = col[i]
|
|
||||||
maxBin = i
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return if (maxVal > 0.3f) maxBin else -1
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Get energy at a specific bin over the last N columns in chronological order. */
|
|
||||||
fun getBinEnergy(bin: Int, numCols: Int): FloatArray {
|
|
||||||
val clamped = minOf(numCols, historyCols)
|
|
||||||
val result = FloatArray(clamped)
|
|
||||||
for (i in 0 until clamped) {
|
|
||||||
val colIdx = (currentCol - clamped + i + historyCols) % historyCols
|
|
||||||
result[i] = spectrogram[colIdx][bin]
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Get the bin index for a frequency in Hz. */
|
|
||||||
fun freqToBin(freqHz: Float): Int {
|
|
||||||
val bin = (freqHz * fftSize / sampleRate).toInt()
|
|
||||||
return (bin - minBin).coerceIn(0, numBins - 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Get the center frequency for a bin. */
|
|
||||||
fun binToFreq(bin: Int): Float {
|
|
||||||
return (minBin + bin).toFloat() * sampleRate / fftSize
|
|
||||||
}
|
|
||||||
|
|
||||||
fun reset() {
|
|
||||||
for (col in spectrogram) col.fill(0f)
|
|
||||||
currentCol = 0
|
|
||||||
samplesBuffered = 0
|
|
||||||
buffer.fill(0f)
|
|
||||||
binEnergy.fill(1f)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,184 +0,0 @@
|
|||||||
/*
|
|
||||||
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
|
||||||
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
|
||||||
*
|
|
||||||
* This program is free software: you can redistribute it and/or modify
|
|
||||||
* it under the terms of the GNU General Public License as published by
|
|
||||||
* the Free Software Foundation, either version 3 of the License, or
|
|
||||||
* (at your option) any later version.
|
|
||||||
*/
|
|
||||||
package com.rtbishop.look4sat.core.domain.logbook
|
|
||||||
|
|
||||||
import java.text.SimpleDateFormat
|
|
||||||
import java.util.Date
|
|
||||||
import java.util.Locale
|
|
||||||
import java.util.TimeZone
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Small ADI codec for the logbook's actual field set (ADIF 3.1.7).
|
|
||||||
*
|
|
||||||
* Grid directions follow ADIF: the un-prefixed fields (GRIDSQUARE, VUCC_GRIDS) belong to
|
|
||||||
* the OPPOSITE station, the MY_* fields (MY_GRIDSQUARE, MY_VUCC_GRIDS) to the operator.
|
|
||||||
*/
|
|
||||||
object AdifCodec {
|
|
||||||
private val utc = TimeZone.getTimeZone("UTC")
|
|
||||||
private val gridPattern = Regex("[A-R]{2}[0-9]{2}([A-X]{2}([0-9]{2})?)?")
|
|
||||||
|
|
||||||
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")
|
|
||||||
records.forEach { record ->
|
|
||||||
val startDate = format(record.startUtcMillis, "yyyyMMdd")
|
|
||||||
val startTime = format(record.startUtcMillis, "HHmmss")
|
|
||||||
append(field("QSO_DATE", startDate))
|
|
||||||
append(field("TIME_ON", startTime))
|
|
||||||
record.endUtcMillis?.let {
|
|
||||||
append(field("QSO_DATE_OFF", format(it, "yyyyMMdd")))
|
|
||||||
append(field("TIME_OFF", format(it, "HHmmss")))
|
|
||||||
}
|
|
||||||
append(field("CALL", record.theirCallsign))
|
|
||||||
if (includeStationCallsign) append(field("STATION_CALLSIGN", record.myCallsign))
|
|
||||||
appendOptional("MODE", record.mode)
|
|
||||||
appendOptional("SUBMODE", record.submode)
|
|
||||||
appendOptional("GRIDSQUARE", record.theirGrid)
|
|
||||||
// The OPPOSITE station's multi-grid set (2+ grids: a grid line or corner).
|
|
||||||
// A single grid is carried by GRIDSQUARE; one value inside the VUCC field
|
|
||||||
// makes Wavelog flag the record in its QSO view.
|
|
||||||
if (record.theirVuccGrids.size > 1) {
|
|
||||||
appendOptional("VUCC_GRIDS", record.theirVuccGrids.joinToString(","))
|
|
||||||
}
|
|
||||||
appendOptional("MY_GRIDSQUARE", record.myGrid)
|
|
||||||
// The grid set this qso went out under (own operated grids, stamped at
|
|
||||||
// upload). ADIF's MY_VUCC_GRIDS is its home — not VUCC_GRIDS, which belongs
|
|
||||||
// to the opposite station and drives Wavelog's grid/distance/QRB display.
|
|
||||||
appendOptional("MY_VUCC_GRIDS", record.vuccGrids.joinToString(","))
|
|
||||||
appendOptional("RST_SENT", record.sentReport)
|
|
||||||
appendOptional("RST_RCVD", record.receivedReport)
|
|
||||||
record.txFrequencyHz?.let { append(field("FREQ", hzToMhz(it))) }
|
|
||||||
record.rxFrequencyHz?.let { append(field("FREQ_RX", hzToMhz(it))) }
|
|
||||||
appendOptional("BAND", record.band)
|
|
||||||
appendOptional("BAND_RX", record.rxBand)
|
|
||||||
appendOptional("PROP_MODE", record.propagationMode.ifBlank { if (record.satelliteName.isNotBlank()) "SAT" else "" })
|
|
||||||
appendOptional("SAT_NAME", record.satelliteName)
|
|
||||||
appendOptional("SAT_MODE", record.satelliteMode)
|
|
||||||
if (record.lotwConfirmed) append(field("LOTW_QSL_RCVD", "Y"))
|
|
||||||
if (record.lotwReceived) append(field("LOTW_QSL_SENT", "Y"))
|
|
||||||
appendOptional("LOTW_QSLRDATE", record.lotwQslDate)
|
|
||||||
record.dxcc?.let { append(field("DXCC", it.toString())) }
|
|
||||||
appendOptional("COUNTRY", record.country)
|
|
||||||
record.cqZone?.let { append(field("CQZ", it.toString())) }
|
|
||||||
appendOptional("STATE", record.region)
|
|
||||||
appendOptional("COMMENT", record.comment)
|
|
||||||
appendOptional("APP_LOOK4SAT_TRANSPONDER", record.transponderName)
|
|
||||||
record.passAosUtcMillis?.let { append(field("APP_LOOK4SAT_PASS_AOS", it.toString())) }
|
|
||||||
append(field("APP_LOOK4SAT_AUTOMATIC", if (record.automatic) "Y" else "N"))
|
|
||||||
append(field("APP_LOOK4SAT_STATUS", record.status.name))
|
|
||||||
append("<EOR>\r\n")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun decode(content: String): List<QsoRecord> = decodeRecords(splitRecords(content))
|
|
||||||
|
|
||||||
fun decodeRecords(records: List<Map<String, String>>): List<QsoRecord> = records.mapNotNull { values ->
|
|
||||||
val call = values["CALL"].orEmpty().trim().uppercase(Locale.US)
|
|
||||||
val date = values["QSO_DATE"].orEmpty()
|
|
||||||
val time = values["TIME_ON"].orEmpty()
|
|
||||||
if (call.isBlank() || date.length != 8 || time.length !in setOf(4, 6)) return@mapNotNull null
|
|
||||||
val start = parse(date, time) ?: return@mapNotNull null
|
|
||||||
val end = values["QSO_DATE_OFF"]?.let { endDate ->
|
|
||||||
values["TIME_OFF"]?.let { endTime -> parse(endDate, endTime) }
|
|
||||||
}
|
|
||||||
QsoRecord(
|
|
||||||
startUtcMillis = start,
|
|
||||||
endUtcMillis = end,
|
|
||||||
theirCallsign = call,
|
|
||||||
myCallsign = values["STATION_CALLSIGN"].orEmpty().uppercase(Locale.US),
|
|
||||||
theirGrid = values["GRIDSQUARE"].orEmpty().uppercase(Locale.US),
|
|
||||||
theirVuccGrids = decodeGrids(values["VUCC_GRIDS"]),
|
|
||||||
myGrid = values["MY_GRIDSQUARE"].orEmpty().uppercase(Locale.US),
|
|
||||||
vuccGrids = decodeGrids(values["MY_VUCC_GRIDS"]),
|
|
||||||
sentReport = values["RST_SENT"].orEmpty(),
|
|
||||||
receivedReport = values["RST_RCVD"].orEmpty(),
|
|
||||||
txFrequencyHz = mhzToHz(values["FREQ"]),
|
|
||||||
rxFrequencyHz = mhzToHz(values["FREQ_RX"]),
|
|
||||||
band = values["BAND"].orEmpty(),
|
|
||||||
rxBand = values["BAND_RX"].orEmpty(),
|
|
||||||
mode = values["MODE"].orEmpty(),
|
|
||||||
submode = values["SUBMODE"].orEmpty(),
|
|
||||||
satelliteName = values["SAT_NAME"].orEmpty(),
|
|
||||||
transponderName = values["APP_LOOK4SAT_TRANSPONDER"].orEmpty(),
|
|
||||||
satelliteMode = values["SAT_MODE"].orEmpty(),
|
|
||||||
passAosUtcMillis = values["APP_LOOK4SAT_PASS_AOS"]?.toLongOrNull(),
|
|
||||||
automatic = values["APP_LOOK4SAT_AUTOMATIC"].equals("Y", true),
|
|
||||||
status = values["APP_LOOK4SAT_STATUS"]?.let { runCatching { QsoStatus.valueOf(it) }.getOrNull() }
|
|
||||||
?: QsoStatus.COMPLETE,
|
|
||||||
propagationMode = values["PROP_MODE"].orEmpty().trim().uppercase(Locale.US),
|
|
||||||
lotwConfirmed = values["LOTW_QSL_RCVD"].equals("Y", true),
|
|
||||||
lotwReceived = values["LOTW_QSL_SENT"].equals("Y", true),
|
|
||||||
lotwQslDate = values["LOTW_QSLRDATE"].orEmpty(),
|
|
||||||
dxcc = values["DXCC"]?.toIntOrNull(),
|
|
||||||
country = values["COUNTRY"].orEmpty(),
|
|
||||||
cqZone = values["CQZ"]?.toIntOrNull()?.takeIf { it in 1..40 },
|
|
||||||
region = values["STATE"].orEmpty().substringBefore(" // ").trim().uppercase(Locale.US),
|
|
||||||
comment = values["COMMENT"].orEmpty()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun splitRecords(content: String): List<Map<String, String>> {
|
|
||||||
val records = mutableListOf<Map<String, String>>()
|
|
||||||
var values = linkedMapOf<String, String>()
|
|
||||||
var index = 0
|
|
||||||
while (index < content.length) {
|
|
||||||
val start = content.indexOf('<', index)
|
|
||||||
if (start < 0) break
|
|
||||||
val end = content.indexOf('>', start + 1)
|
|
||||||
if (end < 0) break
|
|
||||||
val descriptor = content.substring(start + 1, end)
|
|
||||||
val parts = descriptor.split(':')
|
|
||||||
val name = parts.first().trim().uppercase(Locale.US)
|
|
||||||
index = end + 1
|
|
||||||
when (name) {
|
|
||||||
"EOH" -> values.clear()
|
|
||||||
"EOR" -> {
|
|
||||||
if (values.isNotEmpty()) records += values.toMap()
|
|
||||||
values = linkedMapOf()
|
|
||||||
}
|
|
||||||
else -> {
|
|
||||||
val length = parts.getOrNull(1)?.toIntOrNull() ?: continue
|
|
||||||
if (length < 0 || index + length > content.length) continue
|
|
||||||
values[name] = content.substring(index, index + length)
|
|
||||||
index += length
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return records
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Splits an ADIF grid list on commas, keeping only valid 4/6/8-char locators. */
|
|
||||||
private fun decodeGrids(value: String?): List<String> = value.orEmpty().split(',')
|
|
||||||
.map { it.trim().uppercase(Locale.US) }
|
|
||||||
.filter { it.matches(gridPattern) }
|
|
||||||
.distinct()
|
|
||||||
|
|
||||||
private fun StringBuilder.appendOptional(name: String, value: String) {
|
|
||||||
if (value.isNotBlank()) append(field(name, value))
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun field(name: String, value: String): String = "<$name:${value.length}>$value"
|
|
||||||
|
|
||||||
private fun hzToMhz(value: Long): String = String.format(Locale.US, "%.6f", value / 1_000_000.0)
|
|
||||||
|
|
||||||
private fun mhzToHz(value: String?): Long? = value?.toDoubleOrNull()?.let { (it * 1_000_000.0).toLong() }
|
|
||||||
|
|
||||||
private fun format(value: Long, pattern: String): String =
|
|
||||||
SimpleDateFormat(pattern, Locale.US).apply { timeZone = utc }.format(Date(value))
|
|
||||||
|
|
||||||
private fun parse(date: String, time: String): Long? = runCatching {
|
|
||||||
val normalizedTime = if (time.length == 4) "${time}00" else time
|
|
||||||
SimpleDateFormat("yyyyMMddHHmmss", Locale.US).apply {
|
|
||||||
isLenient = false
|
|
||||||
timeZone = utc
|
|
||||||
}.parse(date + normalizedTime)?.time
|
|
||||||
}.getOrNull()
|
|
||||||
}
|
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
/*
|
|
||||||
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
|
||||||
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
|
||||||
*
|
|
||||||
* This program is free software: you can redistribute it and/or modify
|
|
||||||
* it under the terms of the GNU General Public License as published by
|
|
||||||
* the Free Software Foundation, either version 3 of the License, or
|
|
||||||
* (at your option) any later version.
|
|
||||||
*/
|
|
||||||
package com.rtbishop.look4sat.core.domain.logbook
|
|
||||||
|
|
||||||
import kotlinx.coroutines.flow.Flow
|
|
||||||
|
|
||||||
interface IQsoRepository {
|
|
||||||
val records: Flow<List<QsoRecord>>
|
|
||||||
|
|
||||||
suspend fun find(id: Long): QsoRecord?
|
|
||||||
suspend fun save(record: QsoRecord): Long
|
|
||||||
suspend fun delete(id: Long)
|
|
||||||
/**
|
|
||||||
* 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(), certificateCallsign: String = "")
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Rewrites the operator's callsign on the given records. This is the answer to "these contacts
|
|
||||||
* were logged under another certificate — upload them under the one installed now". Returns the
|
|
||||||
* number of records that changed.
|
|
||||||
*/
|
|
||||||
suspend fun rewriteMyCallsign(ids: List<Long>, callsign: String): Int
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Marks the batch as uploaded to Wavelog (independent of the LoTW upload state) and
|
|
||||||
* stamps the station profile (台址) the batch went out through, so the logbook's
|
|
||||||
* 台址 selector can group the records. An empty [stationId] leaves the stamp as-is.
|
|
||||||
*/
|
|
||||||
suspend fun markWavelogUploaded(ids: List<Long>, stationId: String = "")
|
|
||||||
|
|
||||||
suspend fun exportAdi(ids: Set<Long>? = null, includeIncomplete: Boolean = false): String
|
|
||||||
suspend fun importAdi(content: String): AdifImportResult
|
|
||||||
suspend fun mergeConfirmed(records: List<QsoRecord>): AdifImportResult
|
|
||||||
suspend fun mergeLoTW(records: List<QsoRecord>): AdifImportResult
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Merges QSO records pulled from Wavelog by a sync (the download direction):
|
|
||||||
* matches by the same stable identity as every other import, tags matched and
|
|
||||||
* freshly inserted rows with their Wavelog station profile, and marks them as
|
|
||||||
* already uploaded so a later upload never sends them straight back.
|
|
||||||
*/
|
|
||||||
suspend fun mergeWavelog(records: List<QsoRecord>): AdifImportResult
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Folds LoTW confirmations that were imported as their own rows back into the local
|
|
||||||
* record they belong to (see [com.rtbishop.look4sat.core.domain.logbook.splitConfirmationPairs]).
|
|
||||||
* 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
|
|
||||||
}
|
|
||||||
-48
@@ -1,48 +0,0 @@
|
|||||||
/*
|
|
||||||
* 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
|
|
||||||
|
|
||||||
/** Bridge a LoTW report QSO (confirmed, download side) into a logbook record
|
|
||||||
* so the logbook can show downloaded confirmations and mark local uploads
|
|
||||||
* as confirmed (matched by [sameConfirmedContact]). */
|
|
||||||
fun GridQso.toConfirmedRecord(accountCallsign: String): QsoRecord = QsoRecord(
|
|
||||||
startUtcMillis = epochMs,
|
|
||||||
theirCallsign = call,
|
|
||||||
myCallsign = accountCallsign.trim().uppercase(),
|
|
||||||
theirGrid = theirGrids.firstOrNull().orEmpty(),
|
|
||||||
theirVuccGrids = theirGrids,
|
|
||||||
myGrid = (myGrids.firstOrNull() ?: myGrid).orEmpty(),
|
|
||||||
vuccGrids = myGrids.toList(),
|
|
||||||
band = bandUp,
|
|
||||||
rxBand = bandDown,
|
|
||||||
mode = mode,
|
|
||||||
// On satellites, MODE=MFSK means FT4 (ADIF pairing); keep the sub-mode so
|
|
||||||
// confirmations match local FT4 records (mode=MFSK, submode=FT4).
|
|
||||||
submode = if (mode.equals("MFSK", true)) "FT4" else "",
|
|
||||||
satelliteName = satName,
|
|
||||||
propagationMode = "SAT",
|
|
||||||
status = QsoStatus.COMPLETE,
|
|
||||||
lotwConfirmed = true,
|
|
||||||
dxcc = dxcc,
|
|
||||||
country = country.orEmpty(),
|
|
||||||
cqZone = cqz,
|
|
||||||
region = state.orEmpty()
|
|
||||||
)
|
|
||||||
-150
@@ -1,150 +0,0 @@
|
|||||||
/*
|
|
||||||
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
|
||||||
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
|
||||||
*
|
|
||||||
* This program is free software: you can redistribute it and/or modify
|
|
||||||
* it under the terms of the GNU General Public License as published by
|
|
||||||
* the Free Software Foundation, either version 3 of the License, or
|
|
||||||
* (at your option) any later version.
|
|
||||||
*
|
|
||||||
* This program is distributed in the hope that it will be useful,
|
|
||||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
* GNU General Public License for more details.
|
|
||||||
*
|
|
||||||
* You should have received a copy of the GNU General Public License
|
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
*/
|
|
||||||
package com.rtbishop.look4sat.core.domain.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".
|
|
||||||
* Added 2026-10-08: config.tq6 v11.35 lists QMR-KWT 2 as "RS95S"; SatNOGS DB carries it as
|
|
||||||
* "QMR-KWT 2" (alias "RS95S") and other sources spell it "RS-95S", hence the identity entry.
|
|
||||||
* 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", "QMRKWT2" to "RS95S",
|
|
||||||
"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", "RS95S" to "RS95S",
|
|
||||||
"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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-83
@@ -1,83 +0,0 @@
|
|||||||
/*
|
|
||||||
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
|
||||||
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
|
||||||
*
|
|
||||||
* This program is free software: you can redistribute it and/or modify
|
|
||||||
* it under the terms of the GNU General Public License as published by
|
|
||||||
* the Free Software Foundation, either version 3 of the License, or
|
|
||||||
* (at your option) any later version.
|
|
||||||
*
|
|
||||||
* This program is distributed in the hope that it will be useful,
|
|
||||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
* GNU General Public License for more details.
|
|
||||||
*
|
|
||||||
* You should have received a copy of the GNU General Public License
|
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
*/
|
|
||||||
package com.rtbishop.look4sat.core.domain.logbook
|
|
||||||
|
|
||||||
import com.rtbishop.look4sat.core.domain.repository.LoTWProblem
|
|
||||||
import com.rtbishop.look4sat.core.domain.repository.LoTWUploadAudit
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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"
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* True when a batch that has nothing uploadable still has to reach the preview dialog.
|
|
||||||
*
|
|
||||||
* The preview is the only place that carries the callsign-conflict actions (rewrite the records, or
|
|
||||||
* upload them under another certificate). Short-circuiting such a batch into the plain "can't be
|
|
||||||
* uploaded" message strands exactly the records the operator needs to act on — the message has no
|
|
||||||
* buttons to get out of, so those QSOs could never be uploaded.
|
|
||||||
*/
|
|
||||||
fun LoTWUploadAudit.needsPreviewForConflicts(): Boolean =
|
|
||||||
pending == 0 && (reasons[LoTWProblem.CALLSIGN_MISMATCH] ?: 0) > 0
|
|
||||||
-20
@@ -1,20 +0,0 @@
|
|||||||
/*
|
|
||||||
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
|
||||||
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
|
||||||
*
|
|
||||||
* This program is free software: you can redistribute it and/or modify
|
|
||||||
* it under the terms of the GNU General Public License as published by
|
|
||||||
* the Free Software Foundation, either version 3 of the License, or
|
|
||||||
* (at your option) any later version.
|
|
||||||
*/
|
|
||||||
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 }
|
|
||||||
-239
@@ -1,239 +0,0 @@
|
|||||||
/*
|
|
||||||
* 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 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 =
|
|
||||||
sameContactIdentity(local, remote) && sameBandOrBlank(local.band, remote.band)
|
|
||||||
|
|
||||||
fun QsoRecord.confirmationLookupKey(): String = listOf(
|
|
||||||
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 },
|
|
||||||
txFrequencyHz = txFrequencyHz ?: confirmed.txFrequencyHz,
|
|
||||||
rxFrequencyHz = rxFrequencyHz ?: confirmed.rxFrequencyHz,
|
|
||||||
band = band.ifBlank { confirmed.band },
|
|
||||||
rxBand = rxBand.ifBlank { confirmed.rxBand },
|
|
||||||
propagationMode = propagationMode.ifBlank { confirmed.propagationMode },
|
|
||||||
status = QsoStatus.COMPLETE,
|
|
||||||
lotwConfirmed = lotwConfirmed || confirmed.lotwConfirmed,
|
|
||||||
lotwReceived = lotwReceived || confirmed.lotwReceived || confirmed.lotwConfirmed,
|
|
||||||
lotwQslDate = confirmed.lotwQslDate.ifBlank { lotwQslDate },
|
|
||||||
vuccGrids = confirmed.vuccGrids.ifEmpty { vuccGrids },
|
|
||||||
dxcc = confirmed.dxcc ?: dxcc,
|
|
||||||
country = confirmed.country.ifBlank { country },
|
|
||||||
cqZone = confirmed.cqZone ?: cqZone,
|
|
||||||
region = confirmed.region.ifBlank { region }
|
|
||||||
)
|
|
||||||
|
|
||||||
val QsoRecord.displayMode: String
|
|
||||||
get() {
|
|
||||||
// Satellite FT4 is MODE=MFSK + SUBMODE=FT4; any other mode keeps its own
|
|
||||||
// label even when a stale submode default ("FT4") was persisted.
|
|
||||||
val label = if (mode.equals("MFSK", true) && submode.isNotBlank()) submode else mode
|
|
||||||
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())
|
|
||||||
|
|
||||||
fun frequencyBand(hz: Long?): String = when (hz) {
|
|
||||||
null -> ""
|
|
||||||
in 28_000_000L..29_700_000L -> "10M"
|
|
||||||
in 50_000_000L..54_000_000L -> "6M"
|
|
||||||
in 144_000_000L..148_000_000L -> "2M"
|
|
||||||
in 219_000_000L..225_000_000L -> "1.25M"
|
|
||||||
in 420_000_000L..450_000_000L -> "70CM"
|
|
||||||
in 902_000_000L..928_000_000L -> "33CM"
|
|
||||||
in 1_240_000_000L..1_300_000_000L -> "23CM"
|
|
||||||
in 2_300_000_000L..2_450_000_000L -> "13CM"
|
|
||||||
in 5_650_000_000L..5_925_000_000L -> "6CM"
|
|
||||||
in 10_000_000_000L..10_500_000_000L -> "3CM"
|
|
||||||
else -> ""
|
|
||||||
}
|
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
/*
|
|
||||||
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
|
||||||
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
|
||||||
*
|
|
||||||
* This program is free software: you can redistribute it and/or modify
|
|
||||||
* it under the terms of the GNU General Public License as published by
|
|
||||||
* the Free Software Foundation, either version 3 of the License, or
|
|
||||||
* (at your option) any later version.
|
|
||||||
*/
|
|
||||||
package com.rtbishop.look4sat.core.domain.logbook
|
|
||||||
|
|
||||||
enum class QsoStatus { DRAFT, COMPLETE, ABORTED }
|
|
||||||
|
|
||||||
data class QsoRecord(
|
|
||||||
val id: Long = 0L,
|
|
||||||
val startUtcMillis: Long,
|
|
||||||
val endUtcMillis: Long? = null,
|
|
||||||
val theirCallsign: String,
|
|
||||||
val myCallsign: String,
|
|
||||||
val theirGrid: String = "",
|
|
||||||
/** 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 = "",
|
|
||||||
val txFrequencyHz: Long? = null,
|
|
||||||
val rxFrequencyHz: Long? = null,
|
|
||||||
val band: String = "",
|
|
||||||
val rxBand: String = "",
|
|
||||||
val mode: String = "FM",
|
|
||||||
val submode: String = "",
|
|
||||||
val satelliteName: String = "",
|
|
||||||
val transponderName: String = "",
|
|
||||||
val satelliteMode: String = "",
|
|
||||||
val passAosUtcMillis: Long? = null,
|
|
||||||
val automatic: Boolean = false,
|
|
||||||
val status: QsoStatus = QsoStatus.DRAFT,
|
|
||||||
val propagationMode: String = if (satelliteName.isNotBlank()) "SAT" else "",
|
|
||||||
val lotwConfirmed: Boolean = false,
|
|
||||||
val lotwUploaded: Boolean = false,
|
|
||||||
/** Internal: set after a successful Wavelog upload; keeps the pending filter small. Not shown in any list. */
|
|
||||||
val wavelogUploaded: Boolean = false,
|
|
||||||
/** Wavelog station profile (台址) this QSO belongs to: the profile it was uploaded
|
|
||||||
* through, or the profile a sync pulled it from. Empty for records the app never
|
|
||||||
* involved with Wavelog — the logbook's 台址 selector shows those under "All" only. */
|
|
||||||
val wavelogStation: String = "",
|
|
||||||
val lotwReceived: Boolean = false,
|
|
||||||
val lotwQslDate: String = "",
|
|
||||||
/** Own operated grid set this QSO went out under — uploaded as ADIF MY_VUCC_GRIDS
|
|
||||||
* (never VUCC_GRIDS, which belongs to the opposite station). Stamped at upload. */
|
|
||||||
val vuccGrids: List<String> = emptyList(),
|
|
||||||
val dxcc: Int? = null,
|
|
||||||
val country: String = "",
|
|
||||||
val cqZone: Int? = null,
|
|
||||||
val region: String = "",
|
|
||||||
val comment: String = ""
|
|
||||||
)
|
|
||||||
|
|
||||||
data class AdifImportResult(val imported: Int, val skipped: Int, val updated: Int = 0)
|
|
||||||
-90
@@ -1,90 +0,0 @@
|
|||||||
/*
|
|
||||||
* 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"
|
|
||||||
-18
@@ -1,21 +1,3 @@
|
|||||||
/*
|
|
||||||
* 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
|
package com.rtbishop.look4sat.core.domain.model
|
||||||
|
|
||||||
/** One public AMSAT satellite status report submission. */
|
/** One public AMSAT satellite status report submission. */
|
||||||
|
|||||||
@@ -1,221 +0,0 @@
|
|||||||
/*
|
|
||||||
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
|
||||||
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
|
||||||
*
|
|
||||||
* This program is free software: you can redistribute it and/or modify
|
|
||||||
* it under the terms of the GNU General Public License as published by
|
|
||||||
* the Free Software Foundation, either version 3 of the License, or
|
|
||||||
* (at your option) any later version.
|
|
||||||
*
|
|
||||||
* This program is distributed in the hope that it will be useful,
|
|
||||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
* GNU General Public License for more details.
|
|
||||||
*
|
|
||||||
* You should have received a copy of the GNU General Public License
|
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
*/
|
|
||||||
package com.rtbishop.look4sat.core.domain.model
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Award progress computed from the confirmed-QSO store.
|
|
||||||
*
|
|
||||||
* @param type which award
|
|
||||||
* @param workedKeys matching keys for the boundary assets: each is compared
|
|
||||||
* against the region "code" field of the award's GeoJSON
|
|
||||||
* (e.g. WAPC -> "GD", WAJA -> "34", WAZ -> "24",
|
|
||||||
* WAS -> "CA", DXCC -> "318", VUCC -> "OL62").
|
|
||||||
* @param count distinct worked units (grids / entities / provinces / ...)
|
|
||||||
* @param target units required for the award
|
|
||||||
*/
|
|
||||||
data class AwardProgress(
|
|
||||||
val type: AwardType,
|
|
||||||
val workedKeys: Set<String>,
|
|
||||||
val count: Int,
|
|
||||||
val target: Int
|
|
||||||
)
|
|
||||||
|
|
||||||
enum class AwardType { DXCC, VUCC, WAPC, WAJA, WAZ, WAS }
|
|
||||||
|
|
||||||
object AwardTargets {
|
|
||||||
const val DXCC = 100
|
|
||||||
const val VUCC = 100
|
|
||||||
const val WAPC = 34
|
|
||||||
const val WAJA = 47
|
|
||||||
const val WAZ = 40
|
|
||||||
const val WAS = 50
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Derives six-award progress from the confirmed QSO store.
|
|
||||||
*
|
|
||||||
* Award rules (ADIF / award-program semantics):
|
|
||||||
* - VUCC: distinct 4-char gridsquares.
|
|
||||||
* - DXCC: distinct ARRL DXCC entities (from the QSO's dxcc field; falls back
|
|
||||||
* to a callsign-prefix guess when LoTW omitted it).
|
|
||||||
* - WAPC: distinct Chinese provinces (31 mainland STATE codes) plus the
|
|
||||||
* Hong Kong / Macao / Taiwan entities (counted as their own provinces).
|
|
||||||
* - WAJA: distinct Japanese prefectures (STATE code "01".."47").
|
|
||||||
* - WAZ: distinct CQ zones.
|
|
||||||
* - WAS: distinct US states (STATE abbreviations).
|
|
||||||
*
|
|
||||||
* The ADIF STATE field's meaning depends on the DXCC entity
|
|
||||||
* ("STATE depends on DXCC"), so every QSO is first classified by entity.
|
|
||||||
*/
|
|
||||||
object AwardCalculator {
|
|
||||||
|
|
||||||
fun calculate(qsosByGrid: Map<String, List<GridQso>>): List<AwardProgress> {
|
|
||||||
val all = qsosByGrid.values.flatten()
|
|
||||||
return listOf(
|
|
||||||
AwardProgress(
|
|
||||||
AwardType.VUCC,
|
|
||||||
workedKeys = qsosByGrid.keys,
|
|
||||||
count = qsosByGrid.size,
|
|
||||||
target = AwardTargets.VUCC
|
|
||||||
),
|
|
||||||
calculateDxcc(all),
|
|
||||||
calculateWapc(all),
|
|
||||||
calculateWaja(all),
|
|
||||||
calculateWaz(all),
|
|
||||||
calculateWas(all)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The award unit one QSO counts toward for [type] — the match key of the
|
|
||||||
* award boundary assets' region "code" (WAPC -> "GD"/"HK", WAJA -> "34",
|
|
||||||
* WAZ -> "24", WAS -> "CA", DXCC -> "318"). Null when the QSO does not
|
|
||||||
* count toward that award (wrong entity or missing field). VUCC has no
|
|
||||||
* per-QSO key — its units are the store's grid keys — and returns null.
|
|
||||||
*
|
|
||||||
* Single source of truth shared by the progress counters above and the
|
|
||||||
* map's region-detail dialogs, so a tapped region lists exactly the QSOs
|
|
||||||
* that light it up.
|
|
||||||
*/
|
|
||||||
fun awardKey(type: AwardType, q: GridQso): String? = when (type) {
|
|
||||||
AwardType.DXCC -> (q.dxcc ?: prefixEntityCode(q.call))?.toString()
|
|
||||||
AwardType.WAPC -> wapcKey(q)
|
|
||||||
AwardType.WAJA -> if (entityOf(q) == Entity.JAPAN) {
|
|
||||||
q.state?.trim()?.takeIf { it.length == 2 && it.all(Char::isDigit) }
|
|
||||||
} else null
|
|
||||||
|
|
||||||
AwardType.WAZ -> q.cqz?.takeIf { it in 1..40 }?.toString()
|
|
||||||
AwardType.WAS -> if (entityOf(q) == Entity.USA) {
|
|
||||||
q.state?.trim()?.uppercase()?.takeIf { it in US_STATES }
|
|
||||||
} else null
|
|
||||||
|
|
||||||
AwardType.VUCC -> null
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Every confirmed QSO counted for award region [code] of [type], oldest
|
|
||||||
* first. The store keeps one entry per WORKED GRID, so a QSO spanning
|
|
||||||
* several grids arrives as several copies — [distinct] collapses them.
|
|
||||||
* Not scoped by 台址: the non-VUCC awards count (and fill the map)
|
|
||||||
* globally, so their detail lists do too.
|
|
||||||
*/
|
|
||||||
fun regionQsos(
|
|
||||||
qsosByGrid: Map<String, List<GridQso>>,
|
|
||||||
type: AwardType,
|
|
||||||
code: String
|
|
||||||
): List<GridQso> =
|
|
||||||
qsosByGrid.values.flatten()
|
|
||||||
.filter { awardKey(type, it) == code }
|
|
||||||
.distinct()
|
|
||||||
.sortedBy { it.epochMs }
|
|
||||||
|
|
||||||
private fun wapcKey(q: GridQso): String? = when (entityOf(q)) {
|
|
||||||
Entity.CHINA -> q.state?.trim()?.uppercase()?.takeIf { it in CN_STATES }
|
|
||||||
Entity.HONG_KONG -> HK_CODE
|
|
||||||
Entity.MACAO -> MO_CODE
|
|
||||||
Entity.TAIWAN -> TW_CODE
|
|
||||||
else -> null
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun calculateDxcc(all: List<GridQso>): AwardProgress {
|
|
||||||
val keys = all.mapNotNull { awardKey(AwardType.DXCC, it) }.toSet()
|
|
||||||
return AwardProgress(AwardType.DXCC, keys, keys.size, AwardTargets.DXCC)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun calculateWapc(all: List<GridQso>): AwardProgress {
|
|
||||||
val keys = all.mapNotNull { awardKey(AwardType.WAPC, it) }.toSet()
|
|
||||||
return AwardProgress(AwardType.WAPC, keys, keys.size, AwardTargets.WAPC)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun calculateWaja(all: List<GridQso>): AwardProgress {
|
|
||||||
val keys = all.mapNotNull { awardKey(AwardType.WAJA, it) }.toSet()
|
|
||||||
return AwardProgress(AwardType.WAJA, keys, keys.size, AwardTargets.WAJA)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun calculateWaz(all: List<GridQso>): AwardProgress {
|
|
||||||
val keys = all.mapNotNull { awardKey(AwardType.WAZ, it) }.toSet()
|
|
||||||
return AwardProgress(AwardType.WAZ, keys, keys.size, AwardTargets.WAZ)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun calculateWas(all: List<GridQso>): AwardProgress {
|
|
||||||
val keys = all.mapNotNull { awardKey(AwardType.WAS, it) }.toSet()
|
|
||||||
return AwardProgress(AwardType.WAS, keys, keys.size, AwardTargets.WAS)
|
|
||||||
}
|
|
||||||
|
|
||||||
private enum class Entity { CHINA, HONG_KONG, MACAO, TAIWAN, JAPAN, USA, OTHER }
|
|
||||||
|
|
||||||
private fun entityOf(q: GridQso): Entity = when (q.dxcc) {
|
|
||||||
318 -> Entity.CHINA
|
|
||||||
321 -> Entity.HONG_KONG
|
|
||||||
152 -> Entity.MACAO
|
|
||||||
386 -> Entity.TAIWAN
|
|
||||||
339 -> Entity.JAPAN
|
|
||||||
291 -> Entity.USA
|
|
||||||
else -> prefixEntity(q.call)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Coarse entity from callsign prefix, used when LoTW omitted the DXCC field. */
|
|
||||||
private fun prefixEntity(call: String): Entity {
|
|
||||||
val c = call.uppercase().substringBefore('/')
|
|
||||||
return when {
|
|
||||||
CN_PREFIXES.any { c.startsWith(it) } -> Entity.CHINA
|
|
||||||
c.startsWith("VR") -> Entity.HONG_KONG
|
|
||||||
c.startsWith("XX9") -> Entity.MACAO
|
|
||||||
TW_PREFIXES.any { c.startsWith(it) } -> Entity.TAIWAN
|
|
||||||
JP_PREFIXES.any { c.startsWith(it) } -> Entity.JAPAN
|
|
||||||
US_PREFIXES.any { c.startsWith(it) } -> Entity.USA
|
|
||||||
else -> Entity.OTHER
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** ARRL DXCC entity code guessed from callsign prefix (very coarse). */
|
|
||||||
private fun prefixEntityCode(call: String): Int? {
|
|
||||||
return when (prefixEntity(call)) {
|
|
||||||
Entity.CHINA -> 318
|
|
||||||
Entity.HONG_KONG -> 321
|
|
||||||
Entity.MACAO -> 152
|
|
||||||
Entity.TAIWAN -> 386
|
|
||||||
Entity.JAPAN -> 339
|
|
||||||
Entity.USA -> 291
|
|
||||||
else -> null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private val CN_PREFIXES = listOf("BA", "BD", "BG", "BH", "BI", "BY", "BJ", "BK", "BL", "BQ", "BR", "BS")
|
|
||||||
private val TW_PREFIXES = listOf("BM", "BV", "BU", "BW", "BX", "BN", "BO", "BT", "BZ")
|
|
||||||
private val JP_PREFIXES =
|
|
||||||
listOf("7J", "7K", "7L", "7M", "7N") + ('A'..'Z').map { "J$it" }
|
|
||||||
private val US_PREFIXES =
|
|
||||||
listOf("AA", "AB", "AC", "AD", "AE", "AF", "AG", "AH", "AI", "AJ", "AK", "AL", "K", "N", "W")
|
|
||||||
|
|
||||||
private val CN_STATES = setOf(
|
|
||||||
"AH", "BJ", "CQ", "FJ", "GD", "GS", "GX", "GZ", "HA", "HB", "HE", "HI", "HL", "HN", "JL",
|
|
||||||
"JS", "JX", "LN", "NM", "NX", "QH", "SC", "SD", "SH", "SN", "SX", "TJ", "XJ", "XZ", "YN", "ZJ"
|
|
||||||
)
|
|
||||||
|
|
||||||
private val US_STATES = setOf(
|
|
||||||
"AL", "AK", "AZ", "AR", "CA", "CO", "CT", "DE", "FL", "GA", "HI", "ID", "IL", "IN", "IA",
|
|
||||||
"KS", "KY", "LA", "ME", "MD", "MA", "MI", "MN", "MS", "MO", "MT", "NE", "NV", "NH", "NJ",
|
|
||||||
"NM", "NY", "NC", "ND", "OH", "OK", "OR", "PA", "RI", "SC", "SD", "TN", "TX", "UT", "VT",
|
|
||||||
"VA", "WA", "WV", "WI", "WY"
|
|
||||||
)
|
|
||||||
|
|
||||||
private const val HK_CODE = "HK"
|
|
||||||
private const val MO_CODE = "MO"
|
|
||||||
private const val TW_CODE = "TW"
|
|
||||||
}
|
|
||||||
@@ -1,153 +0,0 @@
|
|||||||
/*
|
|
||||||
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
|
||||||
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
|
||||||
*
|
|
||||||
* This program is free software: you can redistribute it and/or modify
|
|
||||||
* it under the terms of the GNU General Public License as published by
|
|
||||||
* the Free Software Foundation, either version 3 of the License, or
|
|
||||||
* (at your option) any later version.
|
|
||||||
*
|
|
||||||
* This program is distributed in the hope that it will be useful,
|
|
||||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
* GNU General Public License for more details.
|
|
||||||
*
|
|
||||||
* You should have received a copy of the GNU General Public License
|
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
*/
|
|
||||||
package com.rtbishop.look4sat.core.domain.model
|
|
||||||
|
|
||||||
/**
|
|
||||||
* One confirmed satellite QSO as reported by LoTW, kept per worked gridsquare
|
|
||||||
* so the map can show, for any worked grid, which callsigns were worked there
|
|
||||||
* and when.
|
|
||||||
*
|
|
||||||
* @param call opposite station callsign (as logged by the opposite op)
|
|
||||||
* @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, 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
|
|
||||||
* @param cqz CQ zone of the opposite station (ADIF <CQZ>), or null
|
|
||||||
* @param state Primary administrative subdivision of the opposite station
|
|
||||||
* (ADIF <STATE>). Interpretation depends on the DXCC entity:
|
|
||||||
* China -> province code ("GD"), Japan -> prefecture number
|
|
||||||
* ("34"), USA -> state abbreviation ("CA"). Null when absent.
|
|
||||||
*/
|
|
||||||
data class GridQso(
|
|
||||||
val call: String,
|
|
||||||
val epochMs: Long,
|
|
||||||
val satName: String,
|
|
||||||
val mode: String,
|
|
||||||
val bandUp: String,
|
|
||||||
val bandDown: String,
|
|
||||||
val dxcc: Int? = null,
|
|
||||||
val country: String? = null,
|
|
||||||
val cqz: Int? = null,
|
|
||||||
val state: String? = null,
|
|
||||||
/** 4-char grid the station itself operated from (ADIF MY_GRIDSQUARE).
|
|
||||||
* Kept as the first entry of [myGrids] for backward compatibility with
|
|
||||||
* consumers that predate multi-grid station locations. */
|
|
||||||
val myGrid: String? = null,
|
|
||||||
/** Every 4-char grid the station operated from for this QSO — ADIF
|
|
||||||
* <MY_GRIDSQUARE> plus every field of <MY_VUCC_GRIDS> (a comma-separated
|
|
||||||
* list LoTW emits when one station location roams across several grid
|
|
||||||
* squares; MY_GRIDSQUARE may be absent on those records). A station
|
|
||||||
* location (台址) can legitimately span multiple grids, and the map's
|
|
||||||
* stripes and the operated-grid selector must show all of them. */
|
|
||||||
val myGrids: Set<String> = emptySet(),
|
|
||||||
/** Callsign of the station location this QSO was logged under (ADIF
|
|
||||||
* <STATION_CALLSIGN>; e.g. "BH6RJD", "BH6RJD/P"). NOT the 台址 identity
|
|
||||||
* on its own — one callsign can have several station locations. */
|
|
||||||
val myCallsign: String? = null,
|
|
||||||
/** Stable key of the station location (台址) this QSO was logged under,
|
|
||||||
* derived from the MY_* station fields (STATION_CALLSIGN + MY_DXCC +
|
|
||||||
* 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,
|
|
||||||
/** 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
|
|
||||||
get() {
|
|
||||||
val up = bandAbbrev(bandUp)
|
|
||||||
val down = bandAbbrev(bandDown)
|
|
||||||
return when {
|
|
||||||
up.isNotEmpty() && down.isNotEmpty() -> "$up/$down"
|
|
||||||
up.isNotEmpty() -> up
|
|
||||||
else -> ""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun bandAbbrev(band: String): String = when (band.trim().uppercase()) {
|
|
||||||
"70CM" -> "U"
|
|
||||||
"2M" -> "V"
|
|
||||||
"10M" -> "A"
|
|
||||||
"6M" -> "V"
|
|
||||||
"1.2G" -> "L"
|
|
||||||
"2.4G" -> "S"
|
|
||||||
"23CM" -> "L"
|
|
||||||
"13CM" -> "S"
|
|
||||||
else -> band.trim().uppercase()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Every MY-side grid of this QSO: [myGrids] (MY_GRIDSQUARE + MY_VUCC_GRIDS)
|
|
||||||
* with a single-grid fallback to [myGrid] for records synced before
|
|
||||||
* multi-grid support. Empty when the record carries no MY-side grid at all.
|
|
||||||
*/
|
|
||||||
fun GridQso.myStationGrids(): Set<String> =
|
|
||||||
myGrids.ifEmpty { myGrid?.let { setOf(it) }.orEmpty() }
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Station-location (台址) grouping key: the QSO's MY grid set, sorted and
|
|
||||||
* comma-joined ("OL62", "OM60,PM01"). Records covering the same grid set
|
|
||||||
* belong to the same 台址 — they are VUCC-equivalent. Null when the record
|
|
||||||
* carries no MY-side grid. Backs both the map's operated-grid selector and
|
|
||||||
* its per-台址 grid-detail filtering, so the two scope by one identity.
|
|
||||||
*/
|
|
||||||
fun GridQso.stationGridSetKey(): String? =
|
|
||||||
myStationGrids().takeIf { it.isNotEmpty() }?.sorted()?.joinToString(",")
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Scopes a per-grid QSO list to the operated-grid selector's choice with
|
|
||||||
* COVERING semantics (2026-10-05): [stationId] is a grid-set key such as
|
|
||||||
* "OL62" or "OL62,OL63", and a record stays when its own [myStationGrids]
|
|
||||||
* COVER that key (record grid set ⊇ key set). So a multi-grid 台址 that
|
|
||||||
* covers a single-grid one (OL62/63 covers OL62) contributes its records to
|
|
||||||
* the single-grid scope's statistics — map fills, dialog lists and first-call
|
|
||||||
* labels all derive from this one rule. The reverse does NOT hold: records
|
|
||||||
* under a narrower 台址 never leak into a wider scope (single OL62 records
|
|
||||||
* stay out of the OL62/63 scope). Records without a MY-side grid belong to
|
|
||||||
* no 台址 and are dropped under any specific scope.
|
|
||||||
*/
|
|
||||||
fun List<GridQso>.scopedToStation(stationId: String?): List<GridQso> =
|
|
||||||
if (stationId == null) this
|
|
||||||
else {
|
|
||||||
val scope = stationId.split(',').toSet()
|
|
||||||
filter { it.myStationGrids().containsAll(scope) }
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* For every grid in the store, the callsign of the earliest QSO — scoped to the
|
|
||||||
* operated-grid selector's choice first, so a specific 台址 labels each worked
|
|
||||||
* cell with ITS first contact; null ("All") keeps the global first call.
|
|
||||||
*/
|
|
||||||
fun Map<String, List<GridQso>>.firstCallsByGrid(stationId: String?): Map<String, String> =
|
|
||||||
mapNotNull { (grid, qsos) ->
|
|
||||||
qsos.scopedToStation(stationId).minByOrNull { it.epochMs }?.let { grid to it.call }
|
|
||||||
}.toMap()
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
/*
|
|
||||||
* 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. */
|
|
||||||
data class LatestRelease(
|
|
||||||
val versionTag: String, // e.g. "v4.4.6-ba7opf.6"
|
|
||||||
val title: String, // release name, may be empty
|
|
||||||
val body: String, // release notes / update description
|
|
||||||
val apkUrl: String? // first .apk asset download URL, null if absent
|
|
||||||
)
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
/*
|
|
||||||
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
|
||||||
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
|
||||||
*
|
|
||||||
* This program is free software: you can redistribute it and/or modify
|
|
||||||
* it under the terms of the GNU General Public License as published by
|
|
||||||
* the Free Software Foundation, either version 3 of the License, or
|
|
||||||
* (at your option) any later version.
|
|
||||||
*
|
|
||||||
* This program is distributed in the hope that it will be useful,
|
|
||||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
* GNU General Public License for more details.
|
|
||||||
*
|
|
||||||
* You should have received a copy of the GNU General Public License
|
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
*/
|
|
||||||
package com.rtbishop.look4sat.core.domain.model
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A station the user marked in a gridsquare they have NOT yet worked — a
|
|
||||||
* reminder to try to contact that callsign. Stored per 4-char grid (one mark
|
|
||||||
* per grid).
|
|
||||||
*
|
|
||||||
* @param call the callsign the user wants to work (uppercase)
|
|
||||||
* @param epochMs the moment the mark was created, UTC milliseconds
|
|
||||||
*/
|
|
||||||
data class MarkedStation(
|
|
||||||
val call: String,
|
|
||||||
val epochMs: Long
|
|
||||||
)
|
|
||||||
@@ -20,23 +20,19 @@ package com.rtbishop.look4sat.core.domain.model
|
|||||||
import kotlinx.serialization.SerialName
|
import kotlinx.serialization.SerialName
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
/** Only the uuid is mandatory: third party sources rarely publish the full SatNOGS schema, and a
|
||||||
|
* missing optional field should not cost the whole transceiver. */
|
||||||
@Serializable
|
@Serializable
|
||||||
data class SatRadio(
|
data class SatRadio(
|
||||||
@SerialName("uuid") val uuid: String,
|
@SerialName("uuid") val uuid: String,
|
||||||
@SerialName("description") val info: String,
|
@SerialName("description") val info: String = "",
|
||||||
@SerialName("alive") val isAlive: Boolean,
|
@SerialName("alive") val isAlive: Boolean = true,
|
||||||
@SerialName("downlink_low") val downlinkLow: Long?,
|
@SerialName("downlink_low") val downlinkLow: Long? = null,
|
||||||
@SerialName("downlink_high") val downlinkHigh: Long?,
|
@SerialName("downlink_high") val downlinkHigh: Long? = null,
|
||||||
@SerialName("mode") val downlinkMode: String?,
|
@SerialName("mode") val downlinkMode: String? = null,
|
||||||
@SerialName("uplink_low") val uplinkLow: Long?,
|
@SerialName("uplink_low") val uplinkLow: Long? = null,
|
||||||
@SerialName("uplink_high") val uplinkHigh: Long?,
|
@SerialName("uplink_high") val uplinkHigh: Long? = null,
|
||||||
@SerialName("uplink_mode") val uplinkMode: String?,
|
@SerialName("uplink_mode") val uplinkMode: String? = null,
|
||||||
@SerialName("invert") val isInverted: Boolean,
|
@SerialName("invert") val isInverted: Boolean = false,
|
||||||
@SerialName("norad_cat_id") val catnum: Int?,
|
@SerialName("norad_cat_id") val catnum: Int? = null
|
||||||
/** Service class from the transceiver source ("Amateur"/"Unknown" etc.).
|
|
||||||
* Used by the SSTV virtual filter to tell amateur satellites apart from
|
|
||||||
* debris / weather birds (TIROS) / launcher stages (Ariane 6 R/B). */
|
|
||||||
@SerialName("service") val service: String? = null,
|
|
||||||
/** Set for manually imported transceivers, which remote updates must not replace. */
|
|
||||||
val isCustom: Boolean = false
|
|
||||||
)
|
)
|
||||||
@@ -1,21 +1,3 @@
|
|||||||
/*
|
|
||||||
* 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
|
package com.rtbishop.look4sat.core.domain.model
|
||||||
|
|
||||||
/** One satellite status report (AMSAT site tooltip data) */
|
/** One satellite status report (AMSAT site tooltip data) */
|
||||||
@@ -30,23 +12,21 @@ data class SatReport(
|
|||||||
|
|
||||||
/** State of one 2-hour slot */
|
/** State of one 2-hour slot */
|
||||||
data class SatSlot(
|
data class SatSlot(
|
||||||
val statusColor: Long, // ARGB 状态色(灰=无报告;判定见 AmSatRepository.slotStatusOf)
|
val statusColor: Long, // ARGB 状态色(-1 = 无报告)
|
||||||
val count: Int, // 官网页口径:多数方计数(冲突槽/异常槽=总条数;0 = 无)
|
val count: Int, // 报告数量(0 = 无)
|
||||||
val reportIds: List<String> = emptyList(), // 该槽报告 ID 列表
|
val reportIds: List<String> = emptyList() // 该槽报告 ID 列表
|
||||||
val isConflicted: Boolean = false // 官网页"Conflicting reports":该槽无严格多数(弹窗据此标注)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
/** One satellite day (12 two-hour slots) */
|
/** One satellite day (12 two-hour slots) */
|
||||||
data class SatDay(
|
data class SatDay(
|
||||||
val dateLabel: String, // "Aug 4"
|
val dateLabel: String, // "Aug 4"
|
||||||
val slots: List<SatSlot>, // 12 槽(00-02 ... 22-24)
|
val slots: List<SatSlot> // 12 槽(00-02 ... 22-24)
|
||||||
val streakCount: Int = 0 // 当天最近连续相同状态报告数(0 = 当天无报告)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
/** One satellite, 3 days of state */
|
/** One satellite, 5 days of state */
|
||||||
data class SatStatus(
|
data class SatStatus(
|
||||||
val name: String, // "AO-123_[FM]"
|
val name: String, // "AO-123_[FM]"
|
||||||
val days: List<SatDay> // 3 days (newest first)
|
val days: List<SatDay> // 5 天(新→旧)
|
||||||
)
|
)
|
||||||
|
|
||||||
/** Overall page parse result */
|
/** Overall page parse result */
|
||||||
|
|||||||
@@ -29,10 +29,7 @@ data class PassesSettings(
|
|||||||
val minElevation: Double,
|
val minElevation: Double,
|
||||||
val aosStartMinute: Int = 0,
|
val aosStartMinute: Int = 0,
|
||||||
val aosEndMinute: Int = 23 * 60 + 59,
|
val aosEndMinute: Int = 23 * 60 + 59,
|
||||||
val invertAosTimeWindow: Boolean = false,
|
val invertAosTimeWindow: Boolean = false
|
||||||
val selectedModes: List<String>,
|
|
||||||
/** 历史过境回看窗口(小时): 过境列表从 now-hoursBefore 开始计算. */
|
|
||||||
val hoursBefore: Int = 0
|
|
||||||
)
|
)
|
||||||
|
|
||||||
data class RCSettings(
|
data class RCSettings(
|
||||||
@@ -61,37 +58,21 @@ data class OtherSettings(
|
|||||||
val stateOfUtc: Boolean,
|
val stateOfUtc: Boolean,
|
||||||
val stateOfLightTheme: Boolean,
|
val stateOfLightTheme: Boolean,
|
||||||
val stateOfNightMode: Boolean = false,
|
val stateOfNightMode: Boolean = false,
|
||||||
val stateOfMapGrid: Boolean = false,
|
|
||||||
/** Grid mode: label worked (green) cells with the FIRST callsign worked
|
|
||||||
* in that grid instead of the Maidenhead code; non-worked cells get no
|
|
||||||
* label at all. */
|
|
||||||
val stateOfMapFirstCall: Boolean = false,
|
|
||||||
val shouldSeeWarning: Boolean,
|
val shouldSeeWarning: Boolean,
|
||||||
val shouldSeeWhatsNew: Boolean,
|
val shouldSeeWhatsNew: Boolean,
|
||||||
val sstvMode: String = "Auto",
|
val sstvMode: String = "Auto",
|
||||||
val lowElevation: Double = 15.0,
|
val lowElevation: Double = 15.0,
|
||||||
val highElevation: Double = 45.0,
|
val highElevation: Double = 45.0,
|
||||||
/** Independent auto-sync toggle for LoTW confirmed grids (separate from the
|
val radarCompassOffset: Float = 0f,
|
||||||
* ephemeris auto-update; only effective after at least one manual sync). */
|
val radarCompassOffsetElev: Float = 0f
|
||||||
val stateOfAutoLotwSync: Boolean = true,
|
|
||||||
/** 指南针手动航向偏置(度, -180..180): 雷达方位 = 传感器方位 + 磁偏角 + 该偏置. */
|
|
||||||
val compassOffsetDegrees: Float = 0f,
|
|
||||||
val mapSource: String = MapSource.OSM,
|
|
||||||
val tiandituKey: String = ""
|
|
||||||
)
|
)
|
||||||
|
|
||||||
object MapSource {
|
/**
|
||||||
const val OSM = "osm"
|
* Data source URLs (TLE / transceivers) and their per-source enabled flags.
|
||||||
const val TIANDITU_VECTOR = "tianditu_vector"
|
* [satelliteEnabled] and [transceiversEnabled] must be positionally aligned with the
|
||||||
const val TIANDITU_IMAGE = "tianditu_image"
|
* corresponding URL list: the flag at index [i] applies to the URL at index [i].
|
||||||
private const val LEGACY_TIANDITU = "tianditu"
|
* Missing flags are treated as enabled (see [isSatelliteEnabled]/[isTransceiverEnabled]).
|
||||||
|
*/
|
||||||
fun normalize(source: String): String = when (source) {
|
|
||||||
OSM, TIANDITU_VECTOR, TIANDITU_IMAGE -> source
|
|
||||||
LEGACY_TIANDITU -> TIANDITU_VECTOR
|
|
||||||
else -> OSM
|
|
||||||
}
|
|
||||||
}
|
|
||||||
data class DataSourcesSettings(
|
data class DataSourcesSettings(
|
||||||
val satelliteUrls: List<String>,
|
val satelliteUrls: List<String>,
|
||||||
val transceiversUrls: List<String>,
|
val transceiversUrls: List<String>,
|
||||||
@@ -102,26 +83,6 @@ data class DataSourcesSettings(
|
|||||||
fun isTransceiverEnabled(index: Int): Boolean = transceiversEnabled.getOrElse(index) { true }
|
fun isTransceiverEnabled(index: Int): Boolean = transceiversEnabled.getOrElse(index) { true }
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Wavelog configuration — QSO upload both ways; one of LoTW or Wavelog (mutually exclusive). */
|
|
||||||
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 = ""
|
|
||||||
) {
|
|
||||||
val isConfigured: Boolean get() = callsign.isNotBlank() && password.isNotBlank()
|
|
||||||
}
|
|
||||||
|
|
||||||
data class RadioControlSettings(
|
data class RadioControlSettings(
|
||||||
val enabled: Boolean,
|
val enabled: Boolean,
|
||||||
val radioModel: String,
|
val radioModel: String,
|
||||||
|
|||||||
+1
-1
@@ -432,7 +432,7 @@ object CelestialComputer {
|
|||||||
daynum = sunrise + 0.001
|
daynum = sunrise + 0.001
|
||||||
sunPos = getSunPosition(observer, daynumToMillis(daynum))
|
sunPos = getSunPosition(observer, daynumToMillis(daynum))
|
||||||
guard = 0
|
guard = 0
|
||||||
while (sunPos.elevation >= -threshold && guard++ < 500) {
|
while (sunPos.elevation > -threshold && guard++ < 500) {
|
||||||
daynum += 0.008
|
daynum += 0.008
|
||||||
sunPos = getSunPosition(observer, daynumToMillis(daynum))
|
sunPos = getSunPosition(observer, daynumToMillis(daynum))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,4 +31,15 @@ data class OrbitalPass(
|
|||||||
val catNum: Int = orbitalObject.data.catnum
|
val catNum: Int = orbitalObject.data.catnum
|
||||||
val name: String = if (hasDecayed) "${orbitalObject.data.name} (decayed?)" else orbitalObject.data.name
|
val name: String = if (hasDecayed) "${orbitalObject.data.name} (decayed?)" else orbitalObject.data.name
|
||||||
val isDeepSpace: Boolean = orbitalObject.data.isDeepSpace
|
val isDeepSpace: Boolean = orbitalObject.data.isDeepSpace
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fraction of the pass elapsed at [timeMillis], derived on demand so that a ticking clock
|
||||||
|
* never has to rewrite the pass list. A DeepSpace object is always in view, so it reads 1.
|
||||||
|
*/
|
||||||
|
fun progressAt(timeMillis: Long): Float {
|
||||||
|
if (isDeepSpace) return 1f
|
||||||
|
val total = (losTime - aosTime).toFloat()
|
||||||
|
if (total <= 0f) return 0f
|
||||||
|
return ((timeMillis - aosTime) / total).coerceIn(0f, 1f)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+2
-29
@@ -1,21 +1,3 @@
|
|||||||
/*
|
|
||||||
* 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
|
package com.rtbishop.look4sat.core.domain.repository
|
||||||
|
|
||||||
import com.rtbishop.look4sat.core.domain.model.AmSatReportSubmission
|
import com.rtbishop.look4sat.core.domain.model.AmSatReportSubmission
|
||||||
@@ -24,17 +6,8 @@ import com.rtbishop.look4sat.core.domain.model.SatStatusPage
|
|||||||
|
|
||||||
/** AMSAT satellite status data source */
|
/** AMSAT satellite status data source */
|
||||||
interface IAmSatRepository {
|
interface IAmSatRepository {
|
||||||
/** Cached AMSAT status page for the current foreground session, if any. */
|
/** Fetch and parse the AMSAT status page; null on failure */
|
||||||
fun getCachedStatus(): SatStatusPage?
|
suspend fun fetchStatus(): SatStatusPage?
|
||||||
|
|
||||||
/** Fetch and parse the AMSAT status page; null on failure. */
|
|
||||||
suspend fun fetchStatus(forceRefresh: Boolean = false): SatStatusPage?
|
|
||||||
|
|
||||||
/** Warm the foreground-session cache without forcing a network reload. */
|
|
||||||
suspend fun prefetchStatus()
|
|
||||||
|
|
||||||
/** Clear the foreground-session status cache. */
|
|
||||||
fun clearStatusCache()
|
|
||||||
|
|
||||||
/** Submit a public AMSAT satellite status report. */
|
/** Submit a public AMSAT satellite status report. */
|
||||||
suspend fun submitReport(submission: AmSatReportSubmission): AmSatReportSubmitResult
|
suspend fun submitReport(submission: AmSatReportSubmission): AmSatReportSubmitResult
|
||||||
|
|||||||
-72
@@ -1,72 +0,0 @@
|
|||||||
/*
|
|
||||||
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
|
||||||
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
|
||||||
*
|
|
||||||
* This program is free software: you can redistribute it and/or modify
|
|
||||||
* it under the terms of the GNU General Public License as published by
|
|
||||||
* the Free Software Foundation, either version 3 of the License, or
|
|
||||||
* (at your option) any later version.
|
|
||||||
*
|
|
||||||
* This program is distributed in the hope that it will be useful,
|
|
||||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
* GNU General Public License for more details.
|
|
||||||
*
|
|
||||||
* You should have received a copy of the GNU General Public License
|
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
*/
|
|
||||||
package com.rtbishop.look4sat.core.domain.repository
|
|
||||||
|
|
||||||
/** Fetches confirmed gridsquares directly from ARRL LoTW. */
|
|
||||||
interface ILoTWRepository {
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetch all confirmed (QSL_RCVD=Y) gridsquares for the given LoTW account.
|
|
||||||
* Returns the 4-char grid set with an explicit failure cause on error.
|
|
||||||
*/
|
|
||||||
suspend fun fetchConfirmedGrids(callsign: String, password: String): LoTWResult
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Same report, but keeps the per-QSO detail of every confirmed satellite
|
|
||||||
* QSO (call / time / satellite / mode / bands), grouped by worked 4-char
|
|
||||||
* gridsquare. Failure cause is kept explicit.
|
|
||||||
*
|
|
||||||
* @param since QSL-since date ("yyyyMMdd" or "yyyy-MM-dd"); use an early
|
|
||||||
* date for a full report, or the last sync date for an
|
|
||||||
* incremental pull. Empty falls back to a full report.
|
|
||||||
* @param onProgress streamed download progress (phase + bytes + estimate),
|
|
||||||
* invoked from the IO dispatcher; may be called on every
|
|
||||||
* read chunk.
|
|
||||||
*/
|
|
||||||
suspend fun fetchConfirmedGridQsos(
|
|
||||||
callsign: String,
|
|
||||||
password: String,
|
|
||||||
since: String = "",
|
|
||||||
onProgress: (LoTWProgress) -> Unit = {}
|
|
||||||
): LoTWResult
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Streamed progress of a LoTW report download. */
|
|
||||||
data class LoTWProgress(
|
|
||||||
val phase: LoTWPhase,
|
|
||||||
/** Bytes read so far (uncompressed body). */
|
|
||||||
val bytesRead: Long = 0,
|
|
||||||
/** Estimated total body bytes, derived from <APP_LoTW_NUMREC>; 0 before known. */
|
|
||||||
val expectedBytes: Long = 0,
|
|
||||||
/** Smoothed download speed in bytes/second; 0 while unknown. */
|
|
||||||
val speedBps: Long = 0,
|
|
||||||
/** Total QSL records reported in the header (<APP_LoTW_NUMREC>); 0 before known. */
|
|
||||||
val qsoCount: Long = 0
|
|
||||||
) {
|
|
||||||
/** Fraction 0..1 of the estimated body already read; 0 while unknown. */
|
|
||||||
val fraction: Float
|
|
||||||
get() = if (expectedBytes > 0 && bytesRead > 0)
|
|
||||||
(bytesRead.toFloat() / expectedBytes).coerceIn(0f, 1f) else 0f
|
|
||||||
|
|
||||||
/** Estimated seconds remaining; 0 while size or speed is unknown. */
|
|
||||||
val remainingSeconds: Long
|
|
||||||
get() = if (expectedBytes > 0 && speedBps > 0)
|
|
||||||
((expectedBytes - bytesRead) / speedBps).coerceAtLeast(0) else 0L
|
|
||||||
}
|
|
||||||
|
|
||||||
enum class LoTWPhase { Connecting, Downloading }
|
|
||||||
-178
@@ -1,178 +0,0 @@
|
|||||||
/*
|
|
||||||
* 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?
|
|
||||||
suspend fun station(): LoTWStation?
|
|
||||||
suspend fun importCertificate(data: ByteArray, password: CharArray): LoTWCertificate
|
|
||||||
/** Parse a .p12 without persisting it — enough to learn the certificate's DXCC
|
|
||||||
* entity so the station form can offer its region field before the import. */
|
|
||||||
suspend fun previewCertificate(data: ByteArray, password: CharArray): LoTWCertificate
|
|
||||||
suspend fun saveCertificatePassword(password: CharArray): LoTWCertificate
|
|
||||||
suspend fun saveStation(station: LoTWStation): LoTWStation
|
|
||||||
suspend fun removeCertificate()
|
|
||||||
/** 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
|
|
||||||
fun discardPreview()
|
|
||||||
}
|
|
||||||
|
|
||||||
data class LoTWCertificate(
|
|
||||||
val callsign: String,
|
|
||||||
val dxcc: Int,
|
|
||||||
val serial: String,
|
|
||||||
val expires: String,
|
|
||||||
val firstQsoDate: String,
|
|
||||||
val lastQsoDate: String,
|
|
||||||
val passwordSaved: Boolean = false
|
|
||||||
)
|
|
||||||
|
|
||||||
/** The location is explicitly confirmed for the selected contacts, not inferred from today's GPS. */
|
|
||||||
data class LoTWStation(
|
|
||||||
val grid: String = "",
|
|
||||||
val cqZone: String = "",
|
|
||||||
val ituZone: String = "",
|
|
||||||
val region: String = "",
|
|
||||||
val county: String = "",
|
|
||||||
val iota: String = ""
|
|
||||||
)
|
|
||||||
|
|
||||||
/** One (ITU:CQ) pair from a LoTW zonemap, e.g. Guangdong is 44:24. */
|
|
||||||
data class LoTWZonePair(val itu: Int, val cq: Int)
|
|
||||||
|
|
||||||
/** One selectable region code with the zones it maps to (cross-zone regions carry several pairs). */
|
|
||||||
data class LoTWRegionOption(val code: String, val name: String, val zones: List<LoTWZonePair>)
|
|
||||||
|
|
||||||
/** Country-dependent region field (US_STATE, CN_PROVINCE, …) with its selectable options. */
|
|
||||||
data class LoTWRegionField(val id: String, val label: String, val options: List<LoTWRegionOption>)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Everything the station-location UI needs for one DXCC entity:
|
|
||||||
* the region field to show (null when the country has none) and the national
|
|
||||||
* zonemap (used to prefill CQZ/ITUZ when the country has a single zone pair).
|
|
||||||
*/
|
|
||||||
data class LoTWStationMeta(
|
|
||||||
val regionField: LoTWRegionField? = null,
|
|
||||||
val countryZones: List<LoTWZonePair> = emptyList()
|
|
||||||
)
|
|
||||||
|
|
||||||
data class LoTWUploadPreview(
|
|
||||||
val id: String,
|
|
||||||
val callsign: String,
|
|
||||||
val dxcc: Int,
|
|
||||||
val grid: String,
|
|
||||||
val count: Int,
|
|
||||||
val skipped: Int,
|
|
||||||
val firstUtc: String,
|
|
||||||
val lastUtc: String,
|
|
||||||
val contacts: List<String>,
|
|
||||||
val unknownSkipped: Int = 0,
|
|
||||||
/** 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,
|
|
||||||
/** Records in this batch whose own callsign is blank (logged before a certificate was
|
|
||||||
* installed). They are signed with [callsign]; a successful upload writes it into them. */
|
|
||||||
val missingCallsign: Int = 0,
|
|
||||||
/** Records refused because they name a different own callsign than [callsign]. The upload
|
|
||||||
* flow offers either rewriting them with [callsign] or using the other certificate. */
|
|
||||||
val callsignConflicts: List<Long> = emptyList()
|
|
||||||
)
|
|
||||||
|
|
||||||
/** 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,
|
|
||||||
/** 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 {
|
|
||||||
data class Accepted(val count: Int) : LoTWUploadResult
|
|
||||||
data class Rejected(val message: String = "") : LoTWUploadResult
|
|
||||||
/** May have reached LoTW. Do not retry automatically. */
|
|
||||||
data object Unknown : LoTWUploadResult
|
|
||||||
data object ExpiredPreview : LoTWUploadResult
|
|
||||||
}
|
|
||||||
|
|
||||||
class LoTWOperationException(val reason: LoTWProblem, val detail: String = "") : Exception(reason.name)
|
|
||||||
|
|
||||||
enum class LoTWProblem {
|
|
||||||
CERTIFICATE_PASSWORD, CERTIFICATE_INVALID, CERTIFICATE_EXPIRED, CERTIFICATE_MISSING,
|
|
||||||
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 }
|
|
||||||
-65
@@ -1,65 +0,0 @@
|
|||||||
/*
|
|
||||||
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
|
||||||
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
|
||||||
*
|
|
||||||
* This program is free software: you can redistribute it and/or modify
|
|
||||||
* it under the terms of the GNU General Public License as published by
|
|
||||||
* the Free Software Foundation, either version 3 of the License, or
|
|
||||||
* (at your option) any later version.
|
|
||||||
*
|
|
||||||
* This program is distributed in the hope that it will be useful,
|
|
||||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
* GNU General Public License for more details.
|
|
||||||
*
|
|
||||||
* You should have received a copy of the GNU General Public License
|
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
*/
|
|
||||||
package com.rtbishop.look4sat.core.domain.repository
|
|
||||||
|
|
||||||
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()
|
|
||||||
}
|
|
||||||
Loaded 100 of 310 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user