mirror of
https://github.com/atsunatsu/Look4Sat.git
synced 2026-10-04 11:37:22 +00:00
Compare commits
28
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c1277763de | ||
|
|
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,46 +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.
|
|
||||||
@@ -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,7 @@ 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.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,12 +47,9 @@ class MainActivity : ComponentActivity() {
|
|||||||
installSplashScreen()
|
installSplashScreen()
|
||||||
enableEdgeToEdge()
|
enableEdgeToEdge()
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
val settingsRepo = (applicationContext as IContainerProvider).getMainContainer().settingsRepo
|
|
||||||
observeNightFilterState()
|
observeNightFilterState()
|
||||||
setContent {
|
setContent {
|
||||||
val otherSettings by settingsRepo.otherSettings.collectAsStateWithLifecycle()
|
MainTheme(isDarkTheme = true) { NavRoot() }
|
||||||
// Light theme is opt-in: the app stays dark until the user turns it on.
|
|
||||||
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,42 +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
|
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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,50 +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>)
|
|
||||||
}
|
|
||||||
@@ -1,40 +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 = 3, 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 ''")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,56 +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 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
|
||||||
)
|
)
|
||||||
+3
-4
@@ -330,13 +330,12 @@ object IcomCivProtocol {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Parse frequency + mode from a CMD_READ_FREQ reply payload.
|
* Parse frequency + mode from a CMD_READ_FREQ reply payload.
|
||||||
* Payload layout after stripping command byte: [5 freq bytes]
|
* Payload layout after stripping command byte: [5 freq bytes] [mode byte] [filter byte]
|
||||||
* The IC-705 does not include a mode byte; if one is present it is parsed, otherwise mode is empty.
|
|
||||||
*/
|
*/
|
||||||
fun parseFreqModePayload(payload: ByteArray): Pair<Long, String>? {
|
fun parseFreqModePayload(payload: ByteArray): Pair<Long, String>? {
|
||||||
if (payload.size < 5) return null
|
if (payload.size < 6) return null
|
||||||
val freqHz = decodeFrequencyBcd(payload.copyOfRange(0, 5))
|
val freqHz = decodeFrequencyBcd(payload.copyOfRange(0, 5))
|
||||||
val mode = if (payload.size > 5) BYTE_TO_MODE[payload[5]] ?: return null else ""
|
val mode = BYTE_TO_MODE[payload[5]] ?: return null
|
||||||
return freqHz to mode
|
return freqHz to mode
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+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
-71
@@ -17,60 +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.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.WavelogRepository
|
|
||||||
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.IWavelogRepository
|
|
||||||
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
|
||||||
@@ -82,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 {
|
||||||
|
|
||||||
@@ -98,40 +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 wavelogRepo: IWavelogRepository by lazy { WavelogRepository() }
|
|
||||||
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)
|
|
||||||
.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)
|
||||||
|
|
||||||
@@ -192,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)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -233,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.34 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,175 +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 {
|
|
||||||
fun read(bytes: ByteArray, password: CharArray, now: Long): LoTWKeyMaterial {
|
|
||||||
if (bytes.isEmpty() || bytes.size > MAX_CERTIFICATE_BYTES) fail(LoTWProblem.CERTIFICATE_INVALID)
|
|
||||||
val key: PrivateKey
|
|
||||||
val cert: X509Certificate
|
|
||||||
val store = try {
|
|
||||||
KeyStore.getInstance("PKCS12").apply { bytes.inputStream().use { load(it, password) } }
|
|
||||||
} catch (_: Exception) {
|
|
||||||
// Modern TQSL / OpenSSL 3 exports use PBES2+AES-CBC which Android's legacy
|
|
||||||
// Bouncy Castle parser cannot read. Fall back to our own PBES2 reader; if
|
|
||||||
// that fails too, report the real reason (format vs password).
|
|
||||||
if (isPbes2(bytes)) {
|
|
||||||
try {
|
|
||||||
val parsed = Pkcs12Reader.read(bytes, password)
|
|
||||||
parsed.first to parsed.second
|
|
||||||
} 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 ""
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
fail(LoTWProblem.CERTIFICATE_PASSWORD)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (store is Pair<*, *>) {
|
|
||||||
@Suppress("UNCHECKED_CAST")
|
|
||||||
key = store.first as PrivateKey
|
|
||||||
@Suppress("UNCHECKED_CAST")
|
|
||||||
cert = store.second as X509Certificate
|
|
||||||
} else {
|
|
||||||
val ks = store as KeyStore
|
|
||||||
val aliases = Collections.list(ks.aliases()).filter { ks.isKeyEntry(it) }
|
|
||||||
if (aliases.size != 1) fail(LoTWProblem.CERTIFICATE_INVALID)
|
|
||||||
val alias = aliases.single()
|
|
||||||
key = try { ks.getKey(alias, password) as? PrivateKey }
|
|
||||||
catch (_: Exception) { fail(LoTWProblem.CERTIFICATE_PASSWORD) }
|
|
||||||
?: fail(LoTWProblem.CERTIFICATE_INVALID)
|
|
||||||
cert = ks.getCertificate(alias) as? X509Certificate ?: fail(LoTWProblem.CERTIFICATE_INVALID)
|
|
||||||
}
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 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,105 +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)
|
|
||||||
if (!record.myCallsign.trim().equals(key.info.callsign, true)) fail(LoTWProblem.CALLSIGN_MISMATCH, call)
|
|
||||||
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,468 +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 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 contacts = records.sortedBy { it.startUtcMillis }.mapNotNull { record ->
|
|
||||||
coroutineContext.ensureActive()
|
|
||||||
if (record.status != QsoStatus.COMPLETE || ((record.lotwReceived || record.lotwUploaded) && !resubmit)) { skipped++; return@mapNotNull null }
|
|
||||||
val contact = try {
|
|
||||||
signing.signer.contact(record, signing.key, signing.location, now())
|
|
||||||
} catch (e: LoTWOperationException) {
|
|
||||||
// One un-signable record must not abort the whole batch:
|
|
||||||
// skip it, count the real reason, and let the rest upload.
|
|
||||||
unavailable++
|
|
||||||
reasons[e.reason] = (reasons[e.reason] ?: 0) + 1
|
|
||||||
return@mapNotNull null
|
|
||||||
}
|
|
||||||
val previous = ledger[contact.fingerprint]
|
|
||||||
when {
|
|
||||||
(previous == "accepted" && !resubmit) -> { skipped++; null }
|
|
||||||
!unique.add(duplicateKey(contact.record)) -> {
|
|
||||||
skipped++
|
|
||||||
duplicates++
|
|
||||||
null
|
|
||||||
}
|
|
||||||
|
|
||||||
previous == "unknown" && !resubmit -> { skipped++; unknown++; null }
|
|
||||||
else -> contact
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// The station-location grids this batch is signed with: the primary grid plus the
|
|
||||||
// MY_VUCC_GRIDS set (1 inside a grid, 2 on a line, 4 on a corner — see [LoTWConfig]).
|
|
||||||
val locationGrids = buildList {
|
|
||||||
signing.location["GRIDSQUARE"]?.takeIf(String::isNotBlank)?.let(::add)
|
|
||||||
signing.location["MY_VUCC_GRIDS"]?.split(',')
|
|
||||||
?.map(String::trim)?.filter(String::isNotBlank)?.let(::addAll)
|
|
||||||
}.distinct()
|
|
||||||
val preview = LoTWUploadPreview(
|
|
||||||
UUID.randomUUID().toString(), signing.key.info.callsign, signing.key.info.dxcc, signing.location.getValue("GRIDSQUARE"),
|
|
||||||
contacts.size, skipped,
|
|
||||||
contacts.firstOrNull()?.record?.let { utc(it.startUtcMillis, "yyyy-MM-dd HH:mm:ss") }.orEmpty(),
|
|
||||||
contacts.lastOrNull()?.record?.let { utc(it.startUtcMillis, "yyyy-MM-dd HH:mm:ss") }.orEmpty(),
|
|
||||||
contacts.map { "${utc(it.record.startUtcMillis, "MM-dd HH:mm")} ${it.record.theirCallsign} ${it.fields["MODE"]} ${it.fields["SAT_NAME"].orEmpty()}" },
|
|
||||||
unknown,
|
|
||||||
unavailable,
|
|
||||||
reasons.toMap(),
|
|
||||||
duplicates,
|
|
||||||
contacts.map { it.record.id },
|
|
||||||
locationGrids,
|
|
||||||
resubmit
|
|
||||||
)
|
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+8
-105
@@ -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
|
||||||
@@ -53,85 +29,27 @@ private data class ApiReport(
|
|||||||
)
|
)
|
||||||
|
|
||||||
/** 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) {
|
||||||
@@ -214,7 +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. */
|
||||||
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"))
|
||||||
@@ -239,22 +157,7 @@ class AmSatRepository(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
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)
|
||||||
}
|
}
|
||||||
|
|||||||
+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,369 +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) &&
|
|
||||||
!record.sameEditableContentAs(previous)
|
|
||||||
) {
|
|
||||||
dao.save(record.copy(
|
|
||||||
id = 0L,
|
|
||||||
lotwUploaded = false, lotwConfirmed = false, lotwReceived = false,
|
|
||||||
lotwQslDate = "", vuccGrids = emptyList(), theirVuccGrids = emptyList(),
|
|
||||||
dxcc = null, country = "", cqZone = null, region = ""
|
|
||||||
).toEntity())
|
|
||||||
} else {
|
|
||||||
val saved = if (previous?.lotwConfirmed == true && sameConfirmedContact(record, previous)) {
|
|
||||||
record.withConfirmation(previous)
|
|
||||||
} else if (previous?.lotwConfirmed == true) record.copy(
|
|
||||||
lotwConfirmed = false, lotwQslDate = "", vuccGrids = emptyList(), theirVuccGrids = emptyList(),
|
|
||||||
dxcc = null, country = "", cqZone = null, region = ""
|
|
||||||
) else record
|
|
||||||
val received = if (previous != null && (
|
|
||||||
stableQsoKey(record) != stableQsoKey(previous) || record.myGrid != previous.myGrid ||
|
|
||||||
record.rxFrequencyHz != previous.rxFrequencyHz || record.band != previous.band ||
|
|
||||||
record.rxBand != previous.rxBand || record.propagationMode != previous.propagationMode
|
|
||||||
)) false else saved.lotwReceived
|
|
||||||
dao.save(saved.copy(lotwReceived = received).toEntity())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun delete(id: Long) = withContext(dispatcher) { importMutex.withLock { dao.delete(id) } }
|
|
||||||
|
|
||||||
override suspend fun markUploaded(ids: List<Long>, grids: List<String>) = withContext(dispatcher) {
|
|
||||||
if (ids.isEmpty()) return@withContext
|
|
||||||
dao.markUploaded(ids)
|
|
||||||
// Stamp the station grid set this batch went out under (a line/corner prefill from
|
|
||||||
// the grid finder carries 2–4 grids). The logbook row shows it after the timestamp.
|
|
||||||
val normalized = grids.map { it.trim().uppercase(Locale.US).take(4) }
|
|
||||||
.filter { it.length >= 4 }.distinct().sorted()
|
|
||||||
if (normalized.isEmpty()) return@withContext
|
|
||||||
val stamped = ids.mapNotNull { id ->
|
|
||||||
dao.find(id)?.toDomain()?.takeIf { it.vuccGrids != normalized }?.copy(vuccGrids = normalized)
|
|
||||||
}
|
|
||||||
if (stamped.isNotEmpty()) dao.saveBatch(stamped.map { it.toEntity() })
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun 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) }
|
|
||||||
}
|
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 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,
|
|
||||||
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,
|
|
||||||
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
|
|
||||||
+9
-30
@@ -35,11 +35,10 @@ import kotlinx.coroutines.delay
|
|||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.combine
|
import kotlinx.coroutines.flow.combine
|
||||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
|
||||||
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 +64,22 @@ 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
|
) { selectedIds, _ -> selectedIds }
|
||||||
// AOS time window is reapplied in the newly selected timezone.
|
|
||||||
settingsRepo.otherSettings.map { it.stateOfUtc }.distinctUntilChanged()
|
|
||||||
) { 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 +164,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,22 +176,13 @@ class SatelliteRepo(
|
|||||||
aosEndMinute: Int,
|
aosEndMinute: Int,
|
||||||
invertAosTimeWindow: Boolean
|
invertAosTimeWindow: Boolean
|
||||||
): Boolean {
|
): Boolean {
|
||||||
// Follow the UTC display toggle: with UTC enabled the window is interpreted
|
val offsetMillis = TimeZone.getDefault().getOffset(aosTime).toLong()
|
||||||
// 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) {
|
|
||||||
TimeZone.getTimeZone("UTC")
|
|
||||||
} else {
|
|
||||||
TimeZone.getDefault()
|
|
||||||
}
|
|
||||||
val offsetMillis = tz.getOffset(aosTime).toLong()
|
|
||||||
val localMillis = Math.floorMod(aosTime + offsetMillis, 24L * 60L * 60L * 1000L)
|
val localMillis = Math.floorMod(aosTime + offsetMillis, 24L * 60L * 60L * 1000L)
|
||||||
val aosMinute = (localMillis / 60_000L).toInt()
|
val aosMinute = (localMillis / 60_000L).toInt()
|
||||||
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
|
||||||
}
|
}
|
||||||
|
|||||||
+19
-82
@@ -38,28 +38,17 @@ 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 }
|
||||||
@@ -67,52 +56,18 @@ class SelectionRepo(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private val itemsWithQuery = currentQuery.flatMapLatest { query ->
|
private val itemsWithQuery = currentQuery.flatMapLatest { query ->
|
||||||
itemsWithTypes.map { items -> filterByQuery(items, query) }
|
itemsWithModes.map { items ->
|
||||||
}
|
filterByQuery(items, query).sortedWith(
|
||||||
|
compareByDescending<SatItem> { it.isSelected }
|
||||||
override fun getCurrentTypes() = currentTypes.value
|
.thenBy { it.name }
|
||||||
|
.thenBy { it.catnum }
|
||||||
/**
|
)
|
||||||
* 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 {
|
override fun getCurrentModes() = settingsRepo.selectedSatModes.value
|
||||||
// 三个转发器/活动虚拟类型排在最前.
|
|
||||||
addAll(Sources.virtualTypeNames)
|
override fun getModesList() = Sources.satelliteModes
|
||||||
// 所有真实 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()
|
||||||
@@ -122,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) {
|
||||||
@@ -160,29 +114,12 @@ class SelectionRepo(
|
|||||||
/**
|
/**
|
||||||
* Filters items by query. Uses toIntOrNull() instead of exception-based flow,
|
* Filters items by query. Uses toIntOrNull() instead of exception-based flow,
|
||||||
* and lowercases the query once up front instead of per-item.
|
* and lowercases the query once up front instead of per-item.
|
||||||
*
|
|
||||||
* Fuzzy search: the query is split into space-separated tokens and every
|
|
||||||
* token must appear in the satellite name after both sides are normalized
|
|
||||||
* (lowercased, non-alphanumeric separators such as dashes, spaces, brackets
|
|
||||||
* and dots stripped). This makes "ao7" match "AO-7 (AMSAT-OSCAR 7)" and
|
|
||||||
* "iss zarya" match "ISS (ZARYA)" — exact continuous-substring matching
|
|
||||||
* previously failed whenever the name contained a separator the query lacked.
|
|
||||||
*/
|
*/
|
||||||
private fun filterByQuery(items: List<SatItem>, query: String): List<SatItem> {
|
private fun filterByQuery(items: List<SatItem>, query: String): List<SatItem> {
|
||||||
if (query.isBlank()) return items
|
if (query.isBlank()) return items
|
||||||
val catnum = query.toIntOrNull()
|
val catnum = query.toIntOrNull()
|
||||||
if (catnum != null) return items.filter { it.catnum == catnum }
|
if (catnum != null) return items.filter { it.catnum == catnum }
|
||||||
val tokens = query.split(' ')
|
val lowerQuery = query.lowercase()
|
||||||
.map { normalizeForSearch(it) }
|
return items.filter { it.name.lowercase().contains(lowerQuery) }
|
||||||
.filter { it.isNotEmpty() }
|
|
||||||
if (tokens.isEmpty()) return items
|
|
||||||
return items.filter { item ->
|
|
||||||
val normalizedName = normalizeForSearch(item.name)
|
|
||||||
tokens.all { normalizedName.contains(it) }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Lowercases and strips all non-alphanumeric chars for fuzzy matching. */
|
|
||||||
private fun normalizeForSearch(text: String): String =
|
|
||||||
text.lowercase().filter { it.isLetterOrDigit() }
|
|
||||||
}
|
}
|
||||||
@@ -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
-493
@@ -25,14 +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.WavelogSettings
|
import com.rtbishop.look4sat.core.domain.model.Constants
|
||||||
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
|
||||||
@@ -41,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"
|
||||||
@@ -59,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"
|
||||||
@@ -76,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"
|
||||||
@@ -95,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)
|
||||||
@@ -128,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> {
|
||||||
@@ -140,287 +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 # Wavelog worked-grids settings
|
//region # Passes filter settings
|
||||||
private val keyWavelogUrl = "wavelogUrl"
|
|
||||||
private val keyWavelogToken = "wavelogToken"
|
|
||||||
private val keyWorkedGrids = "workedGrids"
|
|
||||||
|
|
||||||
private val _wavelogSettings = MutableStateFlow(getWavelogSettings())
|
|
||||||
override val wavelogSettings: StateFlow<WavelogSettings> = _wavelogSettings
|
|
||||||
|
|
||||||
override fun updateWavelogSettings(settings: WavelogSettings) {
|
|
||||||
preferences.edit {
|
|
||||||
putString(keyWavelogUrl, settings.url.trim())
|
|
||||||
putString(keyWavelogToken, settings.token.trim())
|
|
||||||
}
|
|
||||||
_wavelogSettings.value = settings.copy(url = settings.url.trim(), token = settings.token.trim())
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun getWavelogSettings(): WavelogSettings = WavelogSettings(
|
|
||||||
url = preferences.getString(keyWavelogUrl, null).orEmpty(),
|
|
||||||
token = preferences.getString(keyWavelogToken, null).orEmpty()
|
|
||||||
)
|
|
||||||
|
|
||||||
override fun getWorkedGrids(): Set<String> {
|
|
||||||
val json = preferences.getString(keyWorkedGrids, null).orEmpty()
|
|
||||||
if (json.isBlank()) return emptySet()
|
|
||||||
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 # 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
|
||||||
@@ -468,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()
|
||||||
@@ -528,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)
|
||||||
@@ -645,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
|
||||||
}
|
}
|
||||||
@@ -676,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
|
||||||
|
|
||||||
@@ -708,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
|
||||||
@@ -864,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) {
|
||||||
@@ -896,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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-97
@@ -1,97 +0,0 @@
|
|||||||
/*
|
|
||||||
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
|
||||||
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
|
||||||
*
|
|
||||||
* This program is free software: you can redistribute it and/or modify
|
|
||||||
* it under the terms of the GNU General Public License as published by
|
|
||||||
* the Free Software Foundation, either version 3 of the License, or
|
|
||||||
* (at your option) any later version.
|
|
||||||
*
|
|
||||||
* This program is distributed in the hope that it will be useful,
|
|
||||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
* GNU General Public License for more details.
|
|
||||||
*
|
|
||||||
* You should have received a copy of the GNU General Public License
|
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
*/
|
|
||||||
package com.rtbishop.look4sat.core.data.repository
|
|
||||||
|
|
||||||
import com.rtbishop.look4sat.core.domain.repository.IWavelogRepository
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.withContext
|
|
||||||
import org.json.JSONArray
|
|
||||||
import org.json.JSONObject
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetches the list of worked gridsquares from a self-hosted Wavelog instance.
|
|
||||||
*
|
|
||||||
* Uses the Wavelog v1 API endpoint (verified against Wavelog 3.0.2):
|
|
||||||
* POST {base}/index.php/api/logbook_get_worked_grids
|
|
||||||
* Body: {"key": "<api key>", "logbook_id": "<logbook id>"}
|
|
||||||
* Response 201: JSON array of 4-char gridsquares, e.g. ["JN47","JO30"]
|
|
||||||
* (see Wavelog Api.php logbook_get_worked_grids / Api_model::get_grids_worked_in_logbook)
|
|
||||||
*
|
|
||||||
* The token field carries "<api_key>:<logbook_id>" so one credential line
|
|
||||||
* configures both values (the dialog explains this to the user).
|
|
||||||
*/
|
|
||||||
class WavelogRepository : IWavelogRepository {
|
|
||||||
|
|
||||||
override suspend fun fetchWorkedGrids(url: String, token: String): Set<String>? = withContext(Dispatchers.IO) {
|
|
||||||
val parts = token.trim().split(":")
|
|
||||||
val apiKey = parts.getOrNull(0)?.trim().orEmpty()
|
|
||||||
val logbookId = parts.getOrNull(1)?.trim().orEmpty()
|
|
||||||
if (url.isBlank() || apiKey.isBlank() || logbookId.isBlank()) return@withContext null
|
|
||||||
val base = url.trim().trimEnd('/')
|
|
||||||
val requestUrl = "$base/index.php/api/logbook_get_worked_grids"
|
|
||||||
try {
|
|
||||||
val connection = java.net.URL(requestUrl).openConnection() as java.net.HttpURLConnection
|
|
||||||
connection.connectTimeout = 10_000
|
|
||||||
connection.readTimeout = 15_000
|
|
||||||
connection.requestMethod = "POST"
|
|
||||||
connection.doOutput = true
|
|
||||||
connection.setRequestProperty("Content-Type", "application/json")
|
|
||||||
val body = JSONObject().apply {
|
|
||||||
put("key", apiKey)
|
|
||||||
put("logbook_id", logbookId)
|
|
||||||
}.toString().toByteArray()
|
|
||||||
connection.outputStream.use { it.write(body) }
|
|
||||||
val code = connection.responseCode
|
|
||||||
if (code !in 200..299) {
|
|
||||||
connection.disconnect()
|
|
||||||
return@withContext null
|
|
||||||
}
|
|
||||||
val response = connection.inputStream.bufferedReader().use { it.readText() }
|
|
||||||
connection.disconnect()
|
|
||||||
parseWorkedGrids(response)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
println("WavelogRepository fetch failure: $e")
|
|
||||||
null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
internal fun parseWorkedGrids(body: String): Set<String>? {
|
|
||||||
return try {
|
|
||||||
val array = when {
|
|
||||||
body.trimStart().startsWith("[") -> JSONArray(body)
|
|
||||||
else -> {
|
|
||||||
// Tolerate {"grids": [...]} wrappers too
|
|
||||||
JSONObject(body).optJSONArray("grids") ?: return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
val result = mutableSetOf<String>()
|
|
||||||
for (i in 0 until array.length()) {
|
|
||||||
val grid = array.optString(i, "").trim().uppercase()
|
|
||||||
// Keep only well-formed 4-character Maidenhead squares (e.g. OL62)
|
|
||||||
if (grid.length == 4 && grid[0] in 'A'..'R' && grid[1] in 'A'..'R'
|
|
||||||
&& grid[2] in '0'..'9' && grid[3] in '0'..'9'
|
|
||||||
) {
|
|
||||||
result.add(grid)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
result
|
|
||||||
} catch (_: Exception) {
|
|
||||||
null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
||||||
|
|||||||
-51
@@ -1,51 +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.framework
|
|
||||||
|
|
||||||
import org.junit.Assert.assertEquals
|
|
||||||
import org.junit.Assert.assertNotNull
|
|
||||||
import org.junit.Assert.assertNull
|
|
||||||
import org.junit.Test
|
|
||||||
|
|
||||||
class IcomCivProtocolTest {
|
|
||||||
|
|
||||||
private fun bytes(vararg values: Int) = ByteArray(values.size) { values[it].toByte() }
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun parseFreqModePayload_ic705ReadFreqReply() {
|
|
||||||
// Reply captured from an IC-705 to CMD 0x03: frequency only, no mode byte
|
|
||||||
val reply = bytes(0xFE, 0xFE, 0xE0, 0xA4, 0x03, 0x60, 0x74, 0x95, 0x45, 0x01, 0xFD)
|
|
||||||
val response = IcomCivProtocol.parseResponse(reply, IcomCivProtocol.CMD_READ_FREQ)
|
|
||||||
assertNotNull(response)
|
|
||||||
assertEquals(145957460L to "", IcomCivProtocol.parseFreqModePayload(response!!.payload))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun parseFreqModePayload_withModeByte() {
|
|
||||||
assertEquals(
|
|
||||||
435611000L to "USB",
|
|
||||||
IcomCivProtocol.parseFreqModePayload(bytes(0x00, 0x10, 0x61, 0x35, 0x04, 0x01, 0x01))
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun parseFreqModePayload_tooShort() {
|
|
||||||
assertNull(IcomCivProtocol.parseFreqModePayload(bytes(0x60, 0x74, 0x95, 0x45)))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,126 +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("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,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.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())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-189
@@ -1,189 +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.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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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
-136
@@ -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,61 +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 wavelogSettings: StateFlow<com.rtbishop.look4sat.core.domain.model.WavelogSettings> =
|
|
||||||
MutableStateFlow(com.rtbishop.look4sat.core.domain.model.WavelogSettings())
|
|
||||||
|
|
||||||
override fun updateWavelogSettings(settings: com.rtbishop.look4sat.core.domain.model.WavelogSettings) = Unit
|
|
||||||
|
|
||||||
override 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 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
|
|
||||||
}
|
|
||||||
-397
@@ -1,397 +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)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 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,
|
|
||||||
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) } }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-391
@@ -1,391 +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 `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())
|
|
||||||
}
|
|
||||||
|
|
||||||
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 wavelogSettings: StateFlow<com.rtbishop.look4sat.core.domain.model.WavelogSettings> =
|
|
||||||
MutableStateFlow(com.rtbishop.look4sat.core.domain.model.WavelogSettings())
|
|
||||||
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 updateWavelogSettings(settings: com.rtbishop.look4sat.core.domain.model.WavelogSettings) = Unit
|
|
||||||
override fun getWorkedGrids(): Set<String> = emptySet()
|
|
||||||
override fun setWorkedGrids(grids: Set<String>) = Unit
|
|
||||||
override fun getWorkedGridQsos(): Map<String, List<com.rtbishop.look4sat.core.domain.model.GridQso>> = emptyMap()
|
|
||||||
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
|
|
||||||
}
|
|
||||||
+194
@@ -0,0 +1,194 @@
|
|||||||
|
/*
|
||||||
|
* 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 })
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
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,163 +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). */
|
|
||||||
object AdifCodec {
|
|
||||||
private val utc = TimeZone.getTimeZone("UTC")
|
|
||||||
|
|
||||||
fun encode(records: List<QsoRecord>): 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))
|
|
||||||
append(field("STATION_CALLSIGN", record.myCallsign))
|
|
||||||
appendOptional("MODE", record.mode)
|
|
||||||
appendOptional("SUBMODE", record.submode)
|
|
||||||
appendOptional("GRIDSQUARE", record.theirGrid)
|
|
||||||
appendOptional("MY_GRIDSQUARE", record.myGrid)
|
|
||||||
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)
|
|
||||||
appendOptional("VUCC_GRIDS", record.vuccGrids.joinToString(","))
|
|
||||||
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),
|
|
||||||
myGrid = values["MY_GRIDSQUARE"].orEmpty().uppercase(Locale.US),
|
|
||||||
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(),
|
|
||||||
vuccGrids = values["VUCC_GRIDS"].orEmpty().split(',').map { it.trim().uppercase(Locale.US) }
|
|
||||||
.filter { it.matches(Regex("[A-R]{2}[0-9]{2}([A-X]{2}([0-9]{2})?)?")) }.distinct(),
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
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,38 +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())
|
|
||||||
suspend fun exportAdi(ids: Set<Long>? = null, includeIncomplete: Boolean = false): String
|
|
||||||
suspend fun importAdi(content: String): AdifImportResult
|
|
||||||
suspend fun mergeConfirmed(records: List<QsoRecord>): AdifImportResult
|
|
||||||
suspend fun mergeLoTW(records: List<QsoRecord>): AdifImportResult
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Folds LoTW confirmations that were imported as their own rows back into the local
|
|
||||||
* record they belong to (see [com.rtbishop.look4sat.core.domain.logbook.splitConfirmationPairs]).
|
|
||||||
* Confirmations merged before the tracker-name/band identity fix split every contact in
|
|
||||||
* two; this repairs the existing logbook. Returns the number of merged rows.
|
|
||||||
*/
|
|
||||||
suspend fun consolidateConfirmations(): Int
|
|
||||||
}
|
|
||||||
-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()
|
|
||||||
)
|
|
||||||
-148
@@ -1,148 +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".
|
|
||||||
* Placeholder names ("OBJECT AY" and friends) are deliberately NOT mapped: they are recycled
|
|
||||||
* between objects and would sign the wrong satellite. LoTWConfigTest asserts that every target
|
|
||||||
* here is a real config.tq6 name.
|
|
||||||
*/
|
|
||||||
object LoTWSatelliteAliases {
|
|
||||||
|
|
||||||
val table: Map<String, String> = mapOf(
|
|
||||||
"AMSATOSCAR7" to "AO-7", "AO123" to "AO-123", "AO16" to "AO-16",
|
|
||||||
"AO27" to "AO-27", "AO7" to "AO-7", "AO73" to "AO-73",
|
|
||||||
"AO85" to "AO-85", "AO91" to "AO-91", "AO92" to "AO-92",
|
|
||||||
"ARISSAT1" to "KEDR", "ASRTU1" to "AO-123", "ASRTUFRIENDSHIP" to "AO-123",
|
|
||||||
"BJ2CR" to "AO-123", "BREEZEKMRB" to "RS-44", "BRICSAT2" to "NO-103",
|
|
||||||
"BRICSATP" to "NO-83", "BY701" to "BY70-1", "CAMSAT" to "FO-118",
|
|
||||||
"CAS10" to "HO-119", "CAS2F" to "XW-2F", "CAS2T" to "CAS-2T",
|
|
||||||
"CAS3A" to "XW-2A", "CAS3B" to "XW-2B", "CAS3C" to "XW-2C",
|
|
||||||
"CAS3CBJ1SD" to "XW-2C", "CAS3D" to "XW-2D", "CAS3E" to "XW-2E",
|
|
||||||
"CAS3F" to "XW-2F", "CAS4A" to "CAS-4A", "CAS4B" to "CAS-4B",
|
|
||||||
"CAS5A" to "FO-118", "CAS6" to "TO-108", "CAS7B" to "BO-102",
|
|
||||||
"CAS9" to "HO-113", "CZ11RB" to "CAS-2T", "DELFIC3" to "DO-64",
|
|
||||||
"DIWATA2" to "PO-101", "DIWATA2B" to "PO-101", "DO64" to "DO-64",
|
|
||||||
"DOSAAF85" to "RS-44", "DRUZHBAATURK" to "AO-123", "EO79" to "EO-79",
|
|
||||||
"EO88" to "EO-88", "ESHAIL2" to "QO-100", "EUROPEANOSCAR79" to "EO-79",
|
|
||||||
"EYESAT1" to "AO-27", "EYESATA" to "AO-27", "FENGTAIOSCAR118" to "FO-118",
|
|
||||||
"FO118" to "FO-118", "FO29" to "FO-29", "FOX1" to "AO-85",
|
|
||||||
"FOX1A" to "AO-85", "FOX1B" to "AO-91", "FOX1D" to "AO-92",
|
|
||||||
"FRESCO" to "LO-87", "FUNCUBE1" to "AO-73", "FUNCUBE2" to "UKUBE1",
|
|
||||||
"FUNCUBE3" to "EO-79", "FUNCUBE5" to "EO-88", "GREENCUBE" to "IO-117",
|
|
||||||
"HADESD" to "SO-121", "HADESICM" to "SO-125", "HADESR" to "SO-124",
|
|
||||||
"HAIL2" to "QO-100", "HAMSAT" to "VO-52", "HO113" to "HO-113",
|
|
||||||
"HO119" to "HO-119", "HOPE4" to "HO-119", "HYDRA1" to "SO-121",
|
|
||||||
"INSPIRESAT7" to "INSPR7", "IO117" to "IO-117", "IO86" to "IO-86",
|
|
||||||
"ISS" to "ARISS", "ISSZARYA" to "ARISS", "JAS2" to "FO-29",
|
|
||||||
"JINNIUZUO1" to "TAURUS", "JO97" to "JO-97", "JORDANOSCAR97" to "JO-97",
|
|
||||||
"JY1SAT" to "JO-97", "LAPANA2" to "IO-86", "LILACSAT1" to "LO-90",
|
|
||||||
"LILACSAT2" to "CAS-3H", "LITUANICASAT1" to "LO-78", "LO19" to "LO-19",
|
|
||||||
"LO87" to "LO-87", "LUSAT" to "LO-19", "LUSEX" to "LO-87",
|
|
||||||
"MAYA3" to "MAYA-3", "MAYA4" to "MAYA-4", "MESAT1" to "MO-122",
|
|
||||||
"MESAT1OSCAR" to "MO-122", "MESAT1OSCAR122" to "MO-122", "MIRSAT1" to "MO-112",
|
|
||||||
"MO122" to "MO-122", "MTCUBE2" to "IO-117", "NA1SS" to "ARISS",
|
|
||||||
"NAYIF1" to "EO-88", "NEWSAT1" to "LO-87", "NO103" to "NO-103",
|
|
||||||
"NO104" to "NO-104", "NO44" to "NO-44", "NO83" to "NO-83",
|
|
||||||
"NO84" to "NO-84", "NUSAT1" to "LO-87", "ORARI" to "IO-86",
|
|
||||||
"OSCAR16PACSAT" to "AO-16", "OSCAR19LUSAT" to "LO-19", "OSCAR64" to "DO-64",
|
|
||||||
"OSCAR7" to "AO-7", "PACSAT" to "AO-16", "PARKINSONSAT" to "NO-84",
|
|
||||||
"PCSAT" to "NO-44", "PHILLIPINESOSCAR101" to "PO-101", "PO101" to "PO-101",
|
|
||||||
"PSAT" to "NO-84", "PSAT2" to "NO-104", "QB50P1" to "EO-79",
|
|
||||||
"QO100" to "QO-100", "RADFXSAT" to "AO-91", "RADIOROSTO" to "RS-15",
|
|
||||||
"RS0ISS" to "ARISS", "RS14" to "AO-21", "RS15" to "RS-15",
|
|
||||||
"RS44" to "RS-44", "RS64S" to "AO-123", "SAUDISAT1C" to "SO-50",
|
|
||||||
"SO121" to "SO-121", "SO124" to "SO-124", "SO125" to "SO-125",
|
|
||||||
"SO50" to "SO-50", "SOLUTUSNANOSATTELITE" to "SONATE", "SONATE2" to "SONATE",
|
|
||||||
"TAURUS1" to "TAURUS", "TEVEL21" to "TEV2-1", "TEVEL22" to "TEV2-2",
|
|
||||||
"TEVEL23" to "TEV2-3", "TEVEL24" to "TEV2-4", "TEVEL25" to "TEV2-5",
|
|
||||||
"TEVEL26" to "TEV2-6", "TEVEL27" to "TEV2-7", "TEVEL28" to "TEV2-8",
|
|
||||||
"TEVEL29" to "TEV2-9", "USAT1" to "LO-87", "USNAP1" to "NO-103",
|
|
||||||
"VO52" to "VO-52", "W3ADO" to "NO-44", "XW2A" to "XW-2A",
|
|
||||||
"XW2B" to "XW-2B", "XW2C" to "XW-2C", "XW2D" to "XW-2D",
|
|
||||||
"XW2E" to "XW-2E", "XW2F" to "XW-2F", "XW3" to "HO-113",
|
|
||||||
"XW3CAS9" to "HO-113", "XW4" to "HO-119", "YB0X" to "IO-86",
|
|
||||||
"ZARYA" to "ARISS",
|
|
||||||
|
|
||||||
)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Tracker name -> ARRL name. An exact table hit wins; otherwise the longest alias key
|
|
||||||
* contained in the name matches, so a name carrying an extra suffix ("SAUDISAT 1C
|
|
||||||
* (SO-50)") still resolves. Keys shorter than 5 characters never match loosely.
|
|
||||||
*/
|
|
||||||
fun lookup(name: String): String? {
|
|
||||||
val normalized = normalize(name)
|
|
||||||
table[normalized]?.let { return it }
|
|
||||||
return table.entries
|
|
||||||
.filter { it.key.length >= 5 && normalized.contains(it.key) }
|
|
||||||
.maxByOrNull { it.key.length }
|
|
||||||
?.value
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Upper case, everything that is not A-Z0-9 removed ("ISS (ZARYA)" -> "ISSZARYA"). */
|
|
||||||
fun normalize(name: String): String = name.uppercase(Locale.US).filter { it.isLetterOrDigit() }
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Resolve any tracker name against the ARRL catalogue: exact official name, then an alias
|
|
||||||
* from [table], then an official name contained in the tracker name. Returns the official
|
|
||||||
* name, or null when the tracker name is not an ARRL satellite at all.
|
|
||||||
*/
|
|
||||||
fun resolve(name: String, officialNames: Collection<String>): String? {
|
|
||||||
val trimmed = name.trim()
|
|
||||||
if (trimmed.isEmpty()) return null
|
|
||||||
val normalized = trimmed.uppercase(Locale.US)
|
|
||||||
officialNames.firstOrNull { it.equals(trimmed, true) }?.let { return it }
|
|
||||||
lookup(trimmed)?.let { alias -> officialNames.firstOrNull { it.equals(alias, true) }?.let { return it } }
|
|
||||||
return officialNames.firstOrNull { it.length > 1 && normalized.contains(it) }
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Catalogue candidates for a partly typed name. Alias keys are matched too, so the
|
|
||||||
* operator's own spelling ("diwata", "saudisat 1c", "ao9") reaches the ARRL name even
|
|
||||||
* when the two share no characters.
|
|
||||||
*/
|
|
||||||
fun suggestions(query: String, officialNames: Collection<String>, limit: Int = 6): List<String> {
|
|
||||||
val normalized = normalize(query)
|
|
||||||
if (normalized.isEmpty()) return emptyList()
|
|
||||||
val fromAliases = table.entries
|
|
||||||
.filter { it.key.contains(normalized) || normalized.contains(it.key) }
|
|
||||||
.map { it.value }
|
|
||||||
val fromNames = officialNames.filter { normalize(it).contains(normalized) }
|
|
||||||
return (fromAliases + fromNames + listOfNotNull(lookup(query)))
|
|
||||||
.distinct()
|
|
||||||
.filter { candidate -> officialNames.any { it.equals(candidate, true) } }
|
|
||||||
.take(limit)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-71
@@ -1,71 +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
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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"
|
|
||||||
}
|
|
||||||
-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,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.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,
|
|
||||||
val lotwReceived: Boolean = false,
|
|
||||||
val lotwQslDate: String = "",
|
|
||||||
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)
|
|
||||||
-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,194 +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)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun calculateDxcc(all: List<GridQso>): AwardProgress {
|
|
||||||
val keys = mutableSetOf<String>()
|
|
||||||
for (q in all) {
|
|
||||||
val code = q.dxcc ?: prefixEntityCode(q.call)
|
|
||||||
if (code != null) keys.add(code.toString())
|
|
||||||
}
|
|
||||||
return AwardProgress(AwardType.DXCC, keys, keys.size, AwardTargets.DXCC)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun calculateWapc(all: List<GridQso>): AwardProgress {
|
|
||||||
val keys = mutableSetOf<String>()
|
|
||||||
for (q in all) {
|
|
||||||
when (entityOf(q)) {
|
|
||||||
Entity.CHINA -> q.state?.trim()?.uppercase()?.takeIf { it in CN_STATES }?.let { keys.add(it) }
|
|
||||||
Entity.HONG_KONG -> keys.add(HK_CODE)
|
|
||||||
Entity.MACAO -> keys.add(MO_CODE)
|
|
||||||
Entity.TAIWAN -> keys.add(TW_CODE)
|
|
||||||
else -> {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return AwardProgress(AwardType.WAPC, keys, keys.size, AwardTargets.WAPC)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun calculateWaja(all: List<GridQso>): AwardProgress {
|
|
||||||
val keys = mutableSetOf<String>()
|
|
||||||
for (q in all) {
|
|
||||||
if (entityOf(q) != Entity.JAPAN) continue
|
|
||||||
q.state?.trim()?.takeIf { it.length == 2 && it.all(Char::isDigit) }?.let { keys.add(it) }
|
|
||||||
}
|
|
||||||
return AwardProgress(AwardType.WAJA, keys, keys.size, AwardTargets.WAJA)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun calculateWaz(all: List<GridQso>): AwardProgress {
|
|
||||||
val keys = mutableSetOf<String>()
|
|
||||||
for (q in all) {
|
|
||||||
q.cqz?.takeIf { it in 1..40 }?.let { keys.add(it.toString()) }
|
|
||||||
}
|
|
||||||
return AwardProgress(AwardType.WAZ, keys, keys.size, AwardTargets.WAZ)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun calculateWas(all: List<GridQso>): AwardProgress {
|
|
||||||
val keys = mutableSetOf<String>()
|
|
||||||
for (q in all) {
|
|
||||||
if (entityOf(q) != Entity.USA) continue
|
|
||||||
q.state?.trim()?.uppercase()?.takeIf { it in US_STATES }?.let { keys.add(it) }
|
|
||||||
}
|
|
||||||
return AwardProgress(AwardType.WAS, keys, keys.size, AwardTargets.WAS)
|
|
||||||
}
|
|
||||||
|
|
||||||
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,106 +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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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) */
|
||||||
@@ -38,14 +20,13 @@ data class SatSlot(
|
|||||||
/** 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,20 +83,6 @@ data class DataSourcesSettings(
|
|||||||
fun isTransceiverEnabled(index: Int): Boolean = transceiversEnabled.getOrElse(index) { true }
|
fun isTransceiverEnabled(index: Int): Boolean = transceiversEnabled.getOrElse(index) { true }
|
||||||
}
|
}
|
||||||
|
|
||||||
data class WavelogSettings(
|
|
||||||
val url: String = "",
|
|
||||||
val token: String = ""
|
|
||||||
) {
|
|
||||||
val isConfigured: Boolean get() = url.isNotBlank() && token.isNotBlank()
|
|
||||||
}
|
|
||||||
|
|
||||||
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))
|
||||||
}
|
}
|
||||||
|
|||||||
+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 }
|
|
||||||
-169
@@ -1,169 +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
|
|
||||||
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
|
|
||||||
)
|
|
||||||
|
|
||||||
/** 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()
|
|
||||||
}
|
|
||||||
+2
-33
@@ -16,14 +16,12 @@
|
|||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
* 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.logbook.IQsoRepository
|
|
||||||
import com.rtbishop.look4sat.core.domain.model.SatRadio
|
|
||||||
import com.rtbishop.look4sat.core.domain.usecase.IAddToCalendar
|
import com.rtbishop.look4sat.core.domain.usecase.IAddToCalendar
|
||||||
import com.rtbishop.look4sat.core.domain.usecase.IAudioCapture
|
import com.rtbishop.look4sat.core.domain.usecase.IAudioCapture
|
||||||
import com.rtbishop.look4sat.core.domain.usecase.ISaveImage
|
import com.rtbishop.look4sat.core.domain.usecase.ISaveImage
|
||||||
import com.rtbishop.look4sat.core.domain.usecase.IShowToast
|
import com.rtbishop.look4sat.core.domain.usecase.IShowToast
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
|
||||||
|
|
||||||
interface IMainContainer {
|
interface IMainContainer {
|
||||||
val appScope: CoroutineScope
|
val appScope: CoroutineScope
|
||||||
@@ -32,18 +30,7 @@ interface IMainContainer {
|
|||||||
val satelliteRepo: ISatelliteRepo
|
val satelliteRepo: ISatelliteRepo
|
||||||
val databaseRepo: IDatabaseRepo
|
val databaseRepo: IDatabaseRepo
|
||||||
val amSatRepo: IAmSatRepository
|
val amSatRepo: IAmSatRepository
|
||||||
val updateRepo: IUpdateRepository
|
|
||||||
val wavelogRepo: IWavelogRepository
|
|
||||||
val lotwRepo: com.rtbishop.look4sat.core.domain.repository.ILoTWRepository
|
|
||||||
val qsoRepository: IQsoRepository
|
|
||||||
val lotwUploadRepository: ILoTWUploadRepository
|
|
||||||
val radioTrackingService: IRadioTrackingService
|
val radioTrackingService: IRadioTrackingService
|
||||||
val locationRepo: ILocationRepo
|
|
||||||
val mutualPassData: StateFlow<MutualPassData>
|
|
||||||
/** Grid Finder → LoTW station page prefill, consumed once by that page. */
|
|
||||||
val pendingLoTWStationGrid: StateFlow<String?>
|
|
||||||
fun setMutualPassData(data: MutualPassData)
|
|
||||||
fun setPendingLoTWStationGrid(grid: String?)
|
|
||||||
fun provideAddToCalendar(): IAddToCalendar
|
fun provideAddToCalendar(): IAddToCalendar
|
||||||
fun provideShowToast(): IShowToast
|
fun provideShowToast(): IShowToast
|
||||||
fun provideBluetoothReporter(): IReporter
|
fun provideBluetoothReporter(): IReporter
|
||||||
@@ -53,27 +40,9 @@ interface IMainContainer {
|
|||||||
fun provideRxRadioController(): IRadioController
|
fun provideRxRadioController(): IRadioController
|
||||||
fun provideAudioCapture(): IAudioCapture
|
fun provideAudioCapture(): IAudioCapture
|
||||||
fun provideSaveImage(): ISaveImage
|
fun provideSaveImage(): ISaveImage
|
||||||
|
fun providePairedBluetoothDevices(): List<Pair<String, String>>
|
||||||
}
|
}
|
||||||
|
|
||||||
data class MutualPassData(
|
|
||||||
val samples: List<Pair<Long, Pair<Double, Double>>> = emptyList(),
|
|
||||||
val trackSamples: List<TrackSampleData> = emptyList(),
|
|
||||||
val startTime: Long = 0L,
|
|
||||||
val endTime: Long = 0L,
|
|
||||||
val maxElev: Double = 10.0,
|
|
||||||
val labelA: String = "你",
|
|
||||||
val labelB: String = "友台"
|
|
||||||
)
|
|
||||||
|
|
||||||
/** Minimal track sample for cross-module sharing (angles in degrees). */
|
|
||||||
data class TrackSampleData(
|
|
||||||
val time: Long = 0L,
|
|
||||||
val azimuthA: Double,
|
|
||||||
val elevationA: Double,
|
|
||||||
val azimuthB: Double,
|
|
||||||
val elevationB: Double
|
|
||||||
)
|
|
||||||
|
|
||||||
interface IContainerProvider {
|
interface IContainerProvider {
|
||||||
fun getMainContainer(): IMainContainer
|
fun getMainContainer(): IMainContainer
|
||||||
}
|
}
|
||||||
-15
@@ -65,19 +65,4 @@ interface ISatelliteRepo {
|
|||||||
|
|
||||||
/** Fetch radio transceivers for a satellite by its catalog number. */
|
/** Fetch radio transceivers for a satellite by its catalog number. */
|
||||||
suspend fun getRadiosWithId(id: Int): List<SatRadio>
|
suspend fun getRadiosWithId(id: Int): List<SatRadio>
|
||||||
|
|
||||||
/** Satellite catnums whose radios carry any of the given downlink modes.
|
|
||||||
* Empty modes -> empty result; used to filter the mutual-match satellite
|
|
||||||
* set to transponder satellites (FM voice / linear). */
|
|
||||||
suspend fun getSatelliteIdsWithModes(modes: List<String>): List<Int>
|
|
||||||
|
|
||||||
/** Like [getSatelliteIdsWithModes] but only matches records with an uplink
|
|
||||||
* (real transponders), excluding downlink-only beacons/telemetry that share
|
|
||||||
* the same mode label. Used by the FM/Linear fallback filter. */
|
|
||||||
suspend fun getSatelliteIdsWithModesAndUplink(modes: List<String>): List<Int>
|
|
||||||
|
|
||||||
/** Like [getSatelliteIdsWithModes] but only matches records whose service
|
|
||||||
* class is "Amateur", so SSTV never matches weather birds (TIROS),
|
|
||||||
* launcher debris or other non-amateur transmitters. */
|
|
||||||
suspend fun getSatelliteIdsWithModesAndAmateur(modes: List<String>): List<Int>
|
|
||||||
}
|
}
|
||||||
+3
-3
@@ -21,10 +21,10 @@ import com.rtbishop.look4sat.core.domain.model.SatItem
|
|||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
|
||||||
interface ISelectionRepo {
|
interface ISelectionRepo {
|
||||||
fun getCurrentTypes(): List<String>
|
fun getCurrentModes(): List<String>
|
||||||
fun getTypesList(): List<String>
|
fun getModesList(): List<String>
|
||||||
suspend fun getEntriesFlow(): Flow<List<SatItem>>
|
suspend fun getEntriesFlow(): Flow<List<SatItem>>
|
||||||
suspend fun setTypes(types: List<String>)
|
suspend fun setModes(modes: List<String>)
|
||||||
suspend fun setQuery(query: String)
|
suspend fun setQuery(query: String)
|
||||||
suspend fun setSelection(selectAll: Boolean)
|
suspend fun setSelection(selectAll: Boolean)
|
||||||
suspend fun setSelection(ids: List<Int>, isTicked: Boolean)
|
suspend fun setSelection(ids: List<Int>, isTicked: Boolean)
|
||||||
|
|||||||
-11
@@ -20,19 +20,8 @@ package com.rtbishop.look4sat.core.domain.repository
|
|||||||
import com.rtbishop.look4sat.core.domain.predict.GeoPos
|
import com.rtbishop.look4sat.core.domain.predict.GeoPos
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
|
||||||
/** 指南针校准精度等级, 由磁场传感器 accuracy 事件映射而来. */
|
|
||||||
enum class CompassAccuracy {
|
|
||||||
UNAVAILABLE,
|
|
||||||
UNRELIABLE,
|
|
||||||
LOW,
|
|
||||||
MEDIUM,
|
|
||||||
HIGH
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ISensorsRepo {
|
interface ISensorsRepo {
|
||||||
val sensorData: StateFlow<Pair<Float, Float>>
|
val sensorData: StateFlow<Pair<Float, Float>>
|
||||||
/** 指南针当前校准精度 (磁场传感器 accuracy), 用于校准对话框进度. */
|
|
||||||
val compassAccuracy: StateFlow<CompassAccuracy>
|
|
||||||
fun getMagDeclination(geoPos: GeoPos, time: Long = System.currentTimeMillis()): Float
|
fun getMagDeclination(geoPos: GeoPos, time: Long = System.currentTimeMillis()): Float
|
||||||
fun enableSensor()
|
fun enableSensor()
|
||||||
fun disableSensor()
|
fun disableSensor()
|
||||||
|
|||||||
+3
-46
@@ -23,19 +23,19 @@ import com.rtbishop.look4sat.core.domain.model.OtherSettings
|
|||||||
import com.rtbishop.look4sat.core.domain.model.PassesSettings
|
import com.rtbishop.look4sat.core.domain.model.PassesSettings
|
||||||
import com.rtbishop.look4sat.core.domain.model.RCSettings
|
import com.rtbishop.look4sat.core.domain.model.RCSettings
|
||||||
import com.rtbishop.look4sat.core.domain.model.RadioControlSettings
|
import com.rtbishop.look4sat.core.domain.model.RadioControlSettings
|
||||||
import com.rtbishop.look4sat.core.domain.model.WavelogSettings
|
|
||||||
import com.rtbishop.look4sat.core.domain.predict.GeoPos
|
import com.rtbishop.look4sat.core.domain.predict.GeoPos
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
|
||||||
interface ISettingsRepo {
|
interface ISettingsRepo {
|
||||||
|
|
||||||
val appVersionName: String
|
val appVersionName: String
|
||||||
|
val appVersionCode: Long
|
||||||
|
|
||||||
//region # Satellites selection settings
|
//region # Satellites selection settings
|
||||||
val selectedIds: StateFlow<List<Int>>
|
val selectedIds: StateFlow<List<Int>>
|
||||||
val selectedTypes: StateFlow<List<String>>
|
val selectedSatModes: StateFlow<List<String>>
|
||||||
fun setSelectedIds(ids: List<Int>)
|
fun setSelectedIds(ids: List<Int>)
|
||||||
fun setSelectedTypes(types: List<String>)
|
fun setSelectedSatModes(modes: List<String>)
|
||||||
//endregion
|
//endregion
|
||||||
|
|
||||||
//region # Passes filter settings
|
//region # Passes filter settings
|
||||||
@@ -48,18 +48,10 @@ interface ISettingsRepo {
|
|||||||
fun setStationPosition(latitude: Double, longitude: Double, altitude: Double): Boolean
|
fun setStationPosition(latitude: Double, longitude: Double, altitude: Double): Boolean
|
||||||
fun setStationPosition(): Boolean
|
fun setStationPosition(): Boolean
|
||||||
fun setStationPosition(locator: String): Boolean
|
fun setStationPosition(locator: String): Boolean
|
||||||
/**
|
|
||||||
* Freshest known position grid for the roaming check, read-only: the newer of a fresh
|
|
||||||
* (≤ 24 h) system location fix and the stored station position. Null only when neither
|
|
||||||
* yields a usable locator. Never writes the station position.
|
|
||||||
*/
|
|
||||||
fun getCurrentGrid(): String?
|
|
||||||
//endregion
|
//endregion
|
||||||
|
|
||||||
//region # Database update settings
|
//region # Database update settings
|
||||||
val databaseState: StateFlow<DatabaseState>
|
val databaseState: StateFlow<DatabaseState>
|
||||||
fun getSatelliteTypesIds(types: List<String>): List<Int>
|
|
||||||
fun setSatelliteTypeIds(type: String, ids: List<Int>)
|
|
||||||
fun updateDatabaseState(state: DatabaseState)
|
fun updateDatabaseState(state: DatabaseState)
|
||||||
//endregion
|
//endregion
|
||||||
|
|
||||||
@@ -95,43 +87,8 @@ interface ISettingsRepo {
|
|||||||
fun setSatelliteOffset(catnum: Int, offset: String)
|
fun setSatelliteOffset(catnum: Int, offset: String)
|
||||||
//endregion
|
//endregion
|
||||||
|
|
||||||
//region # Per-satellite logbook mode preset settings
|
|
||||||
/** Last selected logbook mode (CW/SSB/FT4) for a satellite; empty when unset. */
|
|
||||||
fun getSatelliteMode(catnum: Int): String
|
|
||||||
fun setSatelliteMode(catnum: Int, mode: String)
|
|
||||||
//endregion
|
|
||||||
|
|
||||||
//region # AMSAT status report settings
|
//region # AMSAT status report settings
|
||||||
fun getAmSatCallsign(): String
|
fun getAmSatCallsign(): String
|
||||||
fun setAmSatCallsign(callsign: String)
|
fun setAmSatCallsign(callsign: String)
|
||||||
//endregion
|
//endregion
|
||||||
|
|
||||||
//region # Wavelog worked-grids settings
|
|
||||||
val wavelogSettings: StateFlow<WavelogSettings>
|
|
||||||
fun updateWavelogSettings(settings: WavelogSettings)
|
|
||||||
fun getWorkedGrids(): Set<String>
|
|
||||||
fun setWorkedGrids(grids: Set<String>)
|
|
||||||
/** Confirmed satellite QSOs grouped by worked 4-char gridsquare (map tap detail). */
|
|
||||||
fun getWorkedGridQsos(): Map<String, List<com.rtbishop.look4sat.core.domain.model.GridQso>>
|
|
||||||
fun setWorkedGridQsos(qsos: Map<String, List<com.rtbishop.look4sat.core.domain.model.GridQso>>)
|
|
||||||
/** Distinct 4-char gridsquares the account operated from (LoTW <MY_GRIDSQUARE>). */
|
|
||||||
fun getRoamedGrids(): Set<String>
|
|
||||||
fun setRoamedGrids(grids: Set<String>)
|
|
||||||
/** Stations the user marked as "want to contact" in unworked gridsquares
|
|
||||||
* (grid -> ordered list of marks; the first is the pinned/top one). The
|
|
||||||
* whole grid clears automatically once it becomes worked. */
|
|
||||||
fun getMarkedGridStations(): Map<String, List<com.rtbishop.look4sat.core.domain.model.MarkedStation>>
|
|
||||||
fun setMarkedGridStations(stations: Map<String, List<com.rtbishop.look4sat.core.domain.model.MarkedStation>>)
|
|
||||||
//endregion
|
|
||||||
|
|
||||||
//region # LoTW confirmed-grids settings
|
|
||||||
val lotwSettings: StateFlow<com.rtbishop.look4sat.core.domain.model.LoTWSettings>
|
|
||||||
fun updateLoTWSettings(settings: com.rtbishop.look4sat.core.domain.model.LoTWSettings)
|
|
||||||
/** Last successful LoTW sync date ("yyyyMMdd", empty when never synced). */
|
|
||||||
fun getLastLotwSyncDate(): String
|
|
||||||
fun setLastLotwSyncDate(date: String)
|
|
||||||
/** Callsign of the last successful LoTW sync (empty when never synced). */
|
|
||||||
fun getLastLotwSyncCallsign(): String
|
|
||||||
fun setLastLotwSyncCallsign(callsign: String)
|
|
||||||
//endregion
|
|
||||||
}
|
}
|
||||||
-30
@@ -1,30 +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.model.LatestRelease
|
|
||||||
import java.io.File
|
|
||||||
|
|
||||||
interface IUpdateRepository {
|
|
||||||
/** Fetch the latest release of the fork repo from GitHub; null on failure. */
|
|
||||||
suspend fun getLatestRelease(): LatestRelease?
|
|
||||||
|
|
||||||
/** Download the release APK from [url] into [dest]; true on success. */
|
|
||||||
suspend fun downloadApk(url: String, dest: File): Boolean
|
|
||||||
}
|
|
||||||
-28
@@ -1,28 +0,0 @@
|
|||||||
/*
|
|
||||||
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
|
||||||
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
|
||||||
*
|
|
||||||
* This program is free software: you can redistribute it and/or modify
|
|
||||||
* it under the terms of the GNU General Public License as published by
|
|
||||||
* the Free Software Foundation, either version 3 of the License, or
|
|
||||||
* (at your option) any later version.
|
|
||||||
*
|
|
||||||
* This program is distributed in the hope that it will be useful,
|
|
||||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
* GNU General Public License for more details.
|
|
||||||
*
|
|
||||||
* You should have received a copy of the GNU General Public License
|
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
*/
|
|
||||||
package com.rtbishop.look4sat.core.domain.repository
|
|
||||||
|
|
||||||
/** Fetches worked gridsquares from a self-hosted Wavelog instance (API v2). */
|
|
||||||
interface IWavelogRepository {
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetch worked grids for the configured Wavelog URL/token.
|
|
||||||
* Returns the grid set, or null on any failure.
|
|
||||||
*/
|
|
||||||
suspend fun fetchWorkedGrids(url: String, token: String): Set<String>?
|
|
||||||
}
|
|
||||||
-169
@@ -1,169 +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 com.rtbishop.look4sat.core.domain.model.GridQso
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.withContext
|
|
||||||
import java.text.SimpleDateFormat
|
|
||||||
import java.util.Date
|
|
||||||
import java.util.Locale
|
|
||||||
import java.util.TimeZone
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Shared LoTW sync orchestration used by BOTH the manual sync (settings screen)
|
|
||||||
* and the automatic sync on app start (MainApplication), so the two paths can
|
|
||||||
* never drift apart:
|
|
||||||
*
|
|
||||||
* - [LoTWSyncMode.Incremental] pulls only the confirmations since the last sync
|
|
||||||
* and MERGES them into the stored grids/QSOs (grids only grow; QSO detail is
|
|
||||||
* deduped by call + QSO time).
|
|
||||||
* - [LoTWSyncMode.Full] pulls everything and REPLACES the stored data.
|
|
||||||
*/
|
|
||||||
|
|
||||||
/**
|
|
||||||
* UTC date ("yyyyMMdd") of the given instant — the "already synced today"
|
|
||||||
* gate granularity.
|
|
||||||
*/
|
|
||||||
fun lotwSyncToday(now: Long = System.currentTimeMillis()): String =
|
|
||||||
SimpleDateFormat("yyyyMMdd", Locale.US)
|
|
||||||
.apply { timeZone = TimeZone.getTimeZone("UTC") }
|
|
||||||
.format(Date(now))
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Full UTC timestamp cursor ("yyyy-MM-dd HH:mm:ss") written after a
|
|
||||||
* successful sync. This is exactly the format LoTW's qso_qslsince accepts
|
|
||||||
* (measured live 2026-09-17: the legacy "yyyyMMdd" cursor is silently
|
|
||||||
* ignored by LoTW, which falls back to its own system-supplied default),
|
|
||||||
* and it keeps enough precision to display the last-sync time like the
|
|
||||||
* ephemeris update time.
|
|
||||||
*/
|
|
||||||
fun lotwSyncCursor(now: Long = System.currentTimeMillis()): String =
|
|
||||||
SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US)
|
|
||||||
.apply { timeZone = TimeZone.getTimeZone("UTC") }
|
|
||||||
.format(Date(now))
|
|
||||||
|
|
||||||
/** Date part of a stored cursor as "yyyyMMdd", tolerating the legacy
|
|
||||||
* "yyyyMMdd" format and the current "yyyy-MM-dd HH:mm:ss" format. */
|
|
||||||
fun lotwCursorDate(cursor: String): String = when {
|
|
||||||
cursor.length >= 10 && cursor[4] == '-' -> cursor.substring(0, 10).replace("-", "")
|
|
||||||
else -> cursor.take(8)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** qso_qslsince value for the API: passes the full timestamp through and
|
|
||||||
* normalizes a legacy "yyyyMMdd" cursor to the documented "yyyy-MM-dd". */
|
|
||||||
fun lotwCursorApi(cursor: String): String = when {
|
|
||||||
cursor.isBlank() -> ""
|
|
||||||
cursor.length >= 10 && cursor[4] == '-' -> cursor
|
|
||||||
else -> "${cursor.take(4)}-${cursor.substring(4, 6)}-${cursor.substring(6, 8)}"
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Stored cursor parsed to UTC epoch ms for display; 0 when unparseable. */
|
|
||||||
fun lotwCursorEpochMs(cursor: String): Long = try {
|
|
||||||
val pattern = if (cursor.length >= 10 && cursor[4] == '-') "yyyy-MM-dd HH:mm:ss" else "yyyyMMdd"
|
|
||||||
SimpleDateFormat(pattern, Locale.US)
|
|
||||||
.apply { timeZone = TimeZone.getTimeZone("UTC") }
|
|
||||||
.parse(cursor)?.time ?: 0L
|
|
||||||
} catch (_: Exception) {
|
|
||||||
0L
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Resolves the effective sync mode for a given request:
|
|
||||||
* - an explicit [LoTWSyncMode.Full] request is always honored;
|
|
||||||
* - an [LoTWSyncMode.Incremental] request is honored only when the callsign
|
|
||||||
* matches the last sync — a first sync, an unknown/empty record or a
|
|
||||||
* callsign change falls back to [LoTWSyncMode.Full] so no stale grids from
|
|
||||||
* another account linger.
|
|
||||||
* With [requested] == null (automatic sync) it picks incremental whenever
|
|
||||||
* possible and full otherwise.
|
|
||||||
*/
|
|
||||||
fun resolveLoTWSyncMode(
|
|
||||||
lastSyncCallsign: String,
|
|
||||||
callsign: String,
|
|
||||||
requested: LoTWSyncMode?
|
|
||||||
): LoTWSyncMode {
|
|
||||||
if (requested == LoTWSyncMode.Full) return LoTWSyncMode.Full
|
|
||||||
val sameCall = lastSyncCallsign.isNotBlank() && lastSyncCallsign == callsign
|
|
||||||
return if (sameCall) LoTWSyncMode.Incremental else LoTWSyncMode.Full
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Whether the automatic LoTW sync should run now — mirrors the ephemeris
|
|
||||||
* auto-update check: credentials configured, the LoTW auto-sync toggle on, at
|
|
||||||
* least one prior manual sync (the first sync stays manual/full), and not
|
|
||||||
* already synced today (ARRL limits report pulls, ~once a day per account).
|
|
||||||
*/
|
|
||||||
fun shouldAutoSyncLoTW(
|
|
||||||
isConfigured: Boolean,
|
|
||||||
autoLotwSyncEnabled: Boolean,
|
|
||||||
lastSyncDate: String,
|
|
||||||
today: String
|
|
||||||
): Boolean {
|
|
||||||
val lastDate = lotwCursorDate(lastSyncDate)
|
|
||||||
return isConfigured && autoLotwSyncEnabled && lastDate.isNotBlank() && lastDate != today
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Applies a successful LoTW report to the stored grid data and advances the
|
|
||||||
* sync cursor so the next incremental pull asks LoTW for only the
|
|
||||||
* confirmations since today. Persistence runs on the IO dispatcher — building
|
|
||||||
* the per-grid QSO JSON and writing SharedPreferences blocks the calling
|
|
||||||
* thread, and for large accounts (multi-MB JSON) that froze the main thread
|
|
||||||
* after a manual sync ('bar done but app stuck').
|
|
||||||
*
|
|
||||||
* @return the resulting number of worked grids.
|
|
||||||
*/
|
|
||||||
suspend fun applyLoTWGridResult(
|
|
||||||
settingsRepo: ISettingsRepo,
|
|
||||||
result: LoTWResult.Success,
|
|
||||||
mode: LoTWSyncMode,
|
|
||||||
callsign: String,
|
|
||||||
now: Long = System.currentTimeMillis()
|
|
||||||
): Int {
|
|
||||||
val existingGrids = settingsRepo.getWorkedGrids()
|
|
||||||
val existingQsos = settingsRepo.getWorkedGridQsos()
|
|
||||||
val existingRoamed = settingsRepo.getRoamedGrids()
|
|
||||||
val mergedGrids = if (mode == LoTWSyncMode.Incremental) existingGrids + result.grids else result.grids
|
|
||||||
val mergedQsos = if (mode == LoTWSyncMode.Incremental) mergeGridQsos(existingQsos, result.qsos) else result.qsos
|
|
||||||
val mergedRoamed = if (mode == LoTWSyncMode.Incremental) existingRoamed + result.roamedGrids else result.roamedGrids
|
|
||||||
settingsRepo.setLastLotwSyncDate(lotwSyncCursor(now))
|
|
||||||
settingsRepo.setLastLotwSyncCallsign(callsign)
|
|
||||||
withContext(Dispatchers.IO) {
|
|
||||||
settingsRepo.setWorkedGrids(mergedGrids)
|
|
||||||
settingsRepo.setWorkedGridQsos(mergedQsos)
|
|
||||||
settingsRepo.setRoamedGrids(mergedRoamed)
|
|
||||||
}
|
|
||||||
return mergedGrids.size
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Merge fresh QSO detail into existing per-grid lists, dedup by call + QSO time. */
|
|
||||||
fun mergeGridQsos(
|
|
||||||
existing: Map<String, List<GridQso>>,
|
|
||||||
fresh: Map<String, List<GridQso>>
|
|
||||||
): Map<String, List<GridQso>> {
|
|
||||||
val merged = existing.mapValues { (_, list) -> list.toMutableList() }.toMutableMap()
|
|
||||||
fresh.forEach { (grid, list) ->
|
|
||||||
val target = merged.getOrPut(grid) { mutableListOf() }
|
|
||||||
val known = target.mapTo(mutableSetOf()) { it.call to it.epochMs }
|
|
||||||
list.forEach { qso ->
|
|
||||||
if (known.add(qso.call to qso.epochMs)) target.add(qso)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return merged
|
|
||||||
}
|
|
||||||
@@ -1,51 +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
|
|
||||||
|
|
||||||
/** Result of a LoTW report fetch, with the failure cause kept explicit. */
|
|
||||||
sealed class LoTWResult {
|
|
||||||
|
|
||||||
/** Report downloaded and parsed successfully. */
|
|
||||||
data class Success(
|
|
||||||
val grids: Set<String>,
|
|
||||||
val qsos: Map<String, List<com.rtbishop.look4sat.core.domain.model.GridQso>>,
|
|
||||||
/** Distinct 4-char gridsquares the account itself operated from
|
|
||||||
* (ADIF <MY_GRIDSQUARE>, satellite QSOs only) — "roamed/activated" grids. */
|
|
||||||
val roamedGrids: Set<String> = emptySet()
|
|
||||||
) : LoTWResult()
|
|
||||||
|
|
||||||
/** HTTP 200 but LoTW replied with its login-error page (bad callsign/password). */
|
|
||||||
data object BadCredentials : LoTWResult()
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Report endpoint refused the request without a usable error page. LoTW
|
|
||||||
* limits downloads to one in progress per user id and flags accounts that
|
|
||||||
* pull the full report too often; the server answers those with a bare
|
|
||||||
* non-ADIF body (no <eoh>).
|
|
||||||
*/
|
|
||||||
data object RateLimited : LoTWResult()
|
|
||||||
|
|
||||||
/** Connect/read timed out — typically a slow route to the ARRL servers. */
|
|
||||||
data object Timeout : LoTWResult()
|
|
||||||
|
|
||||||
/** Any other network-level failure (DNS, TLS, connection reset, HTTP 5xx). */
|
|
||||||
data class NetworkError(val detail: String) : LoTWResult()
|
|
||||||
}
|
|
||||||
|
|
||||||
/** What a LoTW sync does: pull everything, or only new QSLs since the last sync. */
|
|
||||||
enum class LoTWSyncMode { Full, Incremental }
|
|
||||||
@@ -33,13 +33,11 @@ interface ILocalSource {
|
|||||||
suspend fun insertEntries(entries: List<OrbitalData>)
|
suspend fun insertEntries(entries: List<OrbitalData>)
|
||||||
suspend fun deleteEntries()
|
suspend fun deleteEntries()
|
||||||
suspend fun getIdsWithModes(modes: List<String>): List<Int>
|
suspend fun getIdsWithModes(modes: List<String>): List<Int>
|
||||||
suspend fun getIdsWithModesAndUplink(modes: List<String>): List<Int>
|
|
||||||
suspend fun getIdsWithModesAndAmateur(modes: List<String>): List<Int>
|
|
||||||
suspend fun getRadiosTotal(): Int
|
suspend fun getRadiosTotal(): Int
|
||||||
suspend fun getRadiosWithId(id: Int): List<SatRadio>
|
suspend fun getRadiosWithId(id: Int): List<SatRadio>
|
||||||
suspend fun insertRadios(radios: List<SatRadio>, isCustom: Boolean = false)
|
suspend fun insertRadios(radios: List<SatRadio>, isCustom: Boolean)
|
||||||
|
|
||||||
/** Delete all non-custom transceivers, keeping manually imported ones. */
|
|
||||||
suspend fun deleteManagedRadios()
|
|
||||||
suspend fun deleteRadios()
|
suspend fun deleteRadios()
|
||||||
|
|
||||||
|
/** Drops the transceivers provided by the remote sources, keeping the imported ones. */
|
||||||
|
suspend fun deleteManagedRadios()
|
||||||
}
|
}
|
||||||
Loaded 100 of 287 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user