mirror of
https://github.com/atsunatsu/Look4Sat.git
synced 2026-10-02 19:17:21 +00:00
Compare commits
317
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
74c245a7d2 | ||
|
|
135ecb861d | ||
|
|
1fd1459ddf | ||
|
|
56a788138b | ||
|
|
2e704ee1d3 | ||
|
|
5b7c7d6df5 | ||
|
|
d13af978af | ||
|
|
b48e23804f | ||
|
|
a2b3ce5d69 | ||
|
|
6ef994e4cd | ||
|
|
4c3fdeaa9c | ||
|
|
33a7f23d7a | ||
|
|
fb4f14f75d | ||
|
|
473c3bf228 | ||
|
|
2110bf0cd6 | ||
|
|
95d2d792cc | ||
|
|
dd29c6681c | ||
|
|
45cd72faf7 | ||
|
|
5acf71b2a6 | ||
|
|
1871c05002 | ||
|
|
e9f58f76f6 | ||
|
|
800494b978 | ||
|
|
f1091acbbd | ||
|
|
99bf3d34a9 | ||
|
|
1dd185971f | ||
|
|
fe85703e56 | ||
|
|
caddba5b81 | ||
|
|
52b733a450 | ||
|
|
474762cbd3 | ||
|
|
8fe88fbc93 | ||
|
|
7728b5c38f | ||
|
|
a769f7dea7 | ||
|
|
3be2e4b9e8 | ||
|
|
5957f30af2 | ||
|
|
d8d0825f54 | ||
|
|
bc5a8e4bba | ||
|
|
851bc25d73 | ||
|
|
238511aa78 | ||
|
|
e606afb0f3 | ||
|
|
e19f098aa5 | ||
|
|
797b1c19f7 | ||
|
|
8e549cd43c | ||
|
|
d33629f015 | ||
|
|
7081ffaa60 | ||
|
|
3b5b5fe627 | ||
|
|
ebe32ed153 | ||
|
|
091d3e867b | ||
|
|
26e50ddab5 | ||
|
|
5f90eebab4 | ||
|
|
70ada0d608 | ||
|
|
317f6d877f | ||
|
|
865fa9c6f2 | ||
|
|
a082abb72c | ||
|
|
a2e41a0c1a | ||
|
|
e1daf70f11 | ||
|
|
ca9f93c1ed | ||
|
|
42bd55e180 | ||
|
|
7401e1451b | ||
|
|
fa51fb2b44 | ||
|
|
75a251eb98 | ||
|
|
91b5269740 | ||
|
|
522cac25e8 | ||
|
|
732412deb2 | ||
|
|
30000a913e | ||
|
|
e7754a4c9e | ||
|
|
cf1e2b99b4 | ||
|
|
b295500d14 | ||
|
|
0163c7b217 | ||
|
|
85a4b9cfa3 | ||
|
|
c4c4dd682d | ||
|
|
62ad995aeb | ||
|
|
c8fdd2e93e | ||
|
|
dc013f920d | ||
|
|
f36a426855 | ||
|
|
4da59bdd17 | ||
|
|
b8138c948b | ||
|
|
0e28231c4a | ||
|
|
d6bdf5ecb6 | ||
|
|
6962a4bfa1 | ||
|
|
94a1a4f7b7 | ||
|
|
dbf2229774 | ||
|
|
f2fc1b7b28 | ||
|
|
d600836e19 | ||
|
|
94bffe80b0 | ||
|
|
4260fbb096 | ||
|
|
3d5b55faa3 | ||
|
|
466f2f6088 | ||
|
|
14bd984327 | ||
|
|
d5c100903b | ||
|
|
1c6388f3bc | ||
|
|
3e0874e6a8 | ||
|
|
6beaeb3e1a | ||
|
|
91f0cb1c56 | ||
|
|
3b72f8d68a | ||
|
|
3bc2983d26 | ||
|
|
0ec4c5dc6c | ||
|
|
8e8a3849df | ||
|
|
cd65e6c077 | ||
|
|
a3d370b1fa | ||
|
|
bf492e3f8b | ||
|
|
3591960d55 | ||
|
|
3f7a087938 | ||
|
|
4b82332133 | ||
|
|
dcd16a0196 | ||
|
|
9e88609efc | ||
|
|
322becdda8 | ||
|
|
7f93a10556 | ||
|
|
3cd5b49f98 | ||
|
|
4dbe896124 | ||
|
|
b47bd78ecd | ||
|
|
c89e79e28f | ||
|
|
b40a79e3c6 | ||
|
|
6c23696e22 | ||
|
|
f997d95cd4 | ||
|
|
e0a1d279ce | ||
|
|
059c816075 | ||
|
|
60dfa5d600 | ||
|
|
5f1eb239ed | ||
|
|
01ca06b43f | ||
|
|
22363c2549 | ||
|
|
45fe755470 | ||
|
|
0ce177cfbf | ||
|
|
f3fdf61784 | ||
|
|
fc028f852c | ||
|
|
7a865fffd7 | ||
|
|
89220a0b4b | ||
|
|
03d0055ce2 | ||
|
|
afb1e434e8 | ||
|
|
1bed7849a3 | ||
|
|
06f00e1601 | ||
|
|
462c3de155 | ||
|
|
4020381b03 | ||
|
|
b8389f63fe | ||
|
|
e64beee44f | ||
|
|
d2dffc039c | ||
|
|
d7d4a752ed | ||
|
|
6a3ab54226 | ||
|
|
3b3579696b | ||
|
|
826f44829b | ||
|
|
b979524b37 | ||
|
|
9e748f5534 | ||
|
|
25b982ac59 | ||
|
|
6a6399b606 | ||
|
|
c1cc992abd | ||
|
|
212dced3c4 | ||
|
|
7ef555e377 | ||
|
|
2773145e0c | ||
|
|
d8d3d81648 | ||
|
|
25a41def35 | ||
|
|
d9dc0a1735 | ||
|
|
692e917893 | ||
|
|
6371bb114d | ||
|
|
2d76aeda23 | ||
|
|
ecc24fd836 | ||
|
|
689f08881c | ||
|
|
d467200a45 | ||
|
|
6277e36a96 | ||
|
|
08a5c8c460 | ||
|
|
1ffb5ed9e8 | ||
|
|
cc684b6e3a | ||
|
|
6cb5b062d3 | ||
|
|
9c12b1eccb | ||
|
|
d56392f3c6 | ||
|
|
14b98cc7b1 | ||
|
|
200d2274de | ||
|
|
3d950f90ad | ||
|
|
1cc0bc770c | ||
|
|
84ba25c163 | ||
|
|
5bf4546ee3 | ||
|
|
030a6eecc9 | ||
|
|
42174da0be | ||
|
|
06421f8cfc | ||
|
|
9ea5b533c1 | ||
|
|
b6a9331ba3 | ||
|
|
390fd7717d | ||
|
|
ef0a34723b | ||
|
|
36d091a7e1 | ||
|
|
20143cf096 | ||
|
|
a64f9c80b9 | ||
|
|
db0320f521 | ||
|
|
adc9ac008b | ||
|
|
4a33d1b19a | ||
|
|
cce9209b3d | ||
|
|
237e4cb27a | ||
|
|
cd3eded77c | ||
|
|
7d7f8f1eda | ||
|
|
11b46c6710 | ||
|
|
da1facac64 | ||
|
|
cb1f341ed9 | ||
|
|
c2e496c406 | ||
|
|
7afce7ee68 | ||
|
|
86048ee1fd | ||
|
|
42a6ffa4c9 | ||
|
|
cf1d8e6539 | ||
|
|
ab8b072f1f | ||
|
|
1d4036e3d0 | ||
|
|
ab22244f86 | ||
|
|
3b0874d707 | ||
|
|
2c5b1479f1 | ||
|
|
39d4498a54 | ||
|
|
9ed2aebc9d | ||
|
|
72d5e13acf | ||
|
|
736dc23aae | ||
|
|
052ba79c94 | ||
|
|
cb648caf65 | ||
|
|
05170541ed | ||
|
|
e75d2dc410 | ||
|
|
d55a07bc26 | ||
|
|
c372386a77 | ||
|
|
de6ade025c | ||
|
|
2eb6d6cd5b | ||
|
|
c09f718aae | ||
|
|
a9fe0b74e9 | ||
|
|
2c9a5b376f | ||
|
|
422e480b2f | ||
|
|
efd6931abb | ||
|
|
c7ca77a943 | ||
|
|
be6473e692 | ||
|
|
75d162301b | ||
|
|
07c09ec3e9 | ||
|
|
513d50e56a | ||
|
|
366edd8bbb | ||
|
|
13df0dfcec | ||
|
|
eb1a5d51a2 | ||
|
|
4e3547649f | ||
|
|
7abbeb6eb3 | ||
|
|
5c73d5ed7a | ||
|
|
a663ef7f1b | ||
|
|
1f1e1fb144 | ||
|
|
5166daa297 | ||
|
|
604c2ec59d | ||
|
|
1904a6b3e6 | ||
|
|
7f390117b8 | ||
|
|
346803fe6e | ||
|
|
47b87a1604 | ||
|
|
935fe6d99f | ||
|
|
69af1c38bb | ||
|
|
5c35f2418d | ||
|
|
2aa9858660 | ||
|
|
bc6e065804 | ||
|
|
7924a6f69e | ||
|
|
9a2a715d1f | ||
|
|
ee32c7a1eb | ||
|
|
1d54ac4718 | ||
|
|
433ce85561 | ||
|
|
cebe3b7ec4 | ||
|
|
d8f60cf270 | ||
|
|
4a3332fdb5 | ||
|
|
9e7fdbfe71 | ||
|
|
463df58eb9 | ||
|
|
58b8801c0c | ||
|
|
2581ba92c0 | ||
|
|
e5131ebb72 | ||
|
|
f9accdd517 | ||
|
|
ee3c1e7058 | ||
|
|
a08a28df39 | ||
|
|
759a6de2c6 | ||
|
|
ba5a984c16 | ||
|
|
083b8a2847 | ||
|
|
e8ebfb1ef3 | ||
|
|
12eed1d9a9 | ||
|
|
b3859fbece | ||
|
|
f40a67e1b7 | ||
|
|
16dce5df2a | ||
|
|
80a58bed61 | ||
|
|
a8afc63bba | ||
|
|
dbf9a2a65f | ||
|
|
ddecaa6017 | ||
|
|
418a05e343 | ||
|
|
c80e351212 | ||
|
|
a333192df5 | ||
|
|
a7b41c95c7 | ||
|
|
2298d8eea4 | ||
|
|
1813e1bfa4 | ||
|
|
bd09ad22ff | ||
|
|
7e90071c4d | ||
|
|
d1cc944be2 | ||
|
|
fb28283679 | ||
|
|
81ae8772d2 | ||
|
|
571145838d | ||
|
|
017b178f2e | ||
|
|
670444858c | ||
|
|
7bdff5df81 | ||
|
|
df083840ac | ||
|
|
a0279bac17 | ||
|
|
3d630041e1 | ||
|
|
ea92d3c150 | ||
|
|
86ee3dcbac | ||
|
|
43788ba7a7 | ||
|
|
1a3f1bb34c | ||
|
|
998c98267b | ||
|
|
28fa373db0 | ||
|
|
4ca33c37ad | ||
|
|
bd42e643ea | ||
|
|
4baf3821e5 | ||
|
|
d7e1ce11c9 | ||
|
|
5de370983d | ||
|
|
f272e268ab | ||
|
|
e59507a4d3 | ||
|
|
0bae306312 | ||
|
|
65f61a5147 | ||
|
|
1064dc739e | ||
|
|
8aedd38b51 | ||
|
|
ad8f08a577 | ||
|
|
2afce54472 | ||
|
|
8e50fcf8fb | ||
|
|
e11b03d56b | ||
|
|
fa55b82a5a | ||
|
|
d57832b0d4 | ||
|
|
1d0e8921a9 | ||
|
|
23d95b8241 | ||
|
|
8af80866bc | ||
|
|
b9e5ff70f5 | ||
|
|
c176d6ad88 | ||
|
|
5a6b162e4f | ||
|
|
33712c29fc | ||
|
|
eac1e6e273 |
No files matched your search
@@ -4,7 +4,3 @@ updates:
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
- package-ecosystem: "bundler"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "never"
|
||||
@@ -19,7 +19,7 @@ jobs:
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Setup Java
|
||||
uses: actions/setup-java@v6
|
||||
uses: actions/setup-java@v5
|
||||
with:
|
||||
distribution: 'temurin'
|
||||
java-version: '21'
|
||||
|
||||
+5
-1
@@ -1,6 +1,9 @@
|
||||
# Built application files
|
||||
*.ap_
|
||||
|
||||
# Hermes AI plans and metadata
|
||||
.hermes/
|
||||
|
||||
# Files for the ART/Dalvik VM
|
||||
*.dex
|
||||
|
||||
@@ -15,6 +18,7 @@ out/
|
||||
# Gradle files
|
||||
.gradle/
|
||||
build/
|
||||
.cxx/
|
||||
|
||||
# Local configuration file (sdk path, etc)
|
||||
local.properties
|
||||
@@ -64,4 +68,4 @@ fastlane/readme.md
|
||||
/app/release/output-metadata.json
|
||||
/app/release/
|
||||
/.kotlin/sessions/
|
||||
.hermes/
|
||||
dist/
|
||||
@@ -8,15 +8,16 @@ All assistant-specific files (`CLAUDE.md`, `.github/copilot-instructions.md`) po
|
||||
## Project Overview
|
||||
|
||||
Look4Sat is an open-source, fully offline Android satellite tracker and pass predictor. It tracks 9000+ active
|
||||
satellites using Celestrak/SatNOGS orbital data, calculates positions via SGP4/SDP4, and predicts passes relative to
|
||||
the user's location. Features include polar radar visualization, SSTV image decoding, and ground track mapping. No ads,
|
||||
no tracking, no network required after initial data download.
|
||||
satellites using TLE/OMM data from Celestrak/SatNOGS, calculates orbital positions via SGP4/SDP4 models, and displays
|
||||
passes relative to the user's location. Features include polar radar visualization, SSTV image decoding, satellite
|
||||
ground track mapping, and pass predictions up to 10 days ahead. No ads, no tracking, no network required after initial
|
||||
data download.
|
||||
|
||||
## Architecture & Design
|
||||
## Architecture
|
||||
|
||||
**MVI (Model-View-Intent)** with unidirectional data flow:
|
||||
- `State` data class (named `<Feature>State`) exposed via `StateFlow` from ViewModel
|
||||
- `Action` sealed interface (named `<Feature>Action`) dispatched to ViewModel's `onAction()`
|
||||
- `State` data class → exposed via `StateFlow` from ViewModel
|
||||
- `Action` sealed interface → user intents dispatched to ViewModel's `onAction()`
|
||||
- Jetpack Compose UI observes state and recomposes reactively
|
||||
|
||||
**Clean Architecture layers:**
|
||||
@@ -33,11 +34,9 @@ no tracking, no network required after initial data download.
|
||||
| `feature:satellites` | Satellite list, filtering, selection |
|
||||
| `feature:settings` | User preferences |
|
||||
|
||||
**Feature isolation:**
|
||||
- `feature:*` modules depend only on `core:domain` and `core:presentation`.
|
||||
- No feature-to-feature dependencies; cross-feature communication goes through core layers.
|
||||
- `feature:*` modules depend only on `core:domain` + `core:presentation`. Features never depend on each other.
|
||||
|
||||
## Build & Platform
|
||||
## Build & Run
|
||||
|
||||
```shell
|
||||
# Debug build
|
||||
@@ -51,55 +50,54 @@ no tracking, no network required after initial data download.
|
||||
```
|
||||
|
||||
- **Min SDK**: 24 | **Target SDK**: 36 | **JDK**: 17
|
||||
- **Gradle**: Version catalog in `gradle/libs.versions.toml` + convention plugins in `build-logic/`
|
||||
- **Gradle**: Uses version catalog (`gradle/libs.versions.toml`) + convention plugins in `build-logic/`
|
||||
|
||||
## Tech Stack
|
||||
## Key Libraries
|
||||
|
||||
- **Compose** (BOM 2026.05.01) + Material3 Adaptive
|
||||
- **Navigation3**: Type-safe navigation with `@Serializable` nav keys
|
||||
- **Room** (KSP code generation) for local satellite/orbital storage
|
||||
- **OkHttp** 5.x for data downloads
|
||||
- **Navigation3** (type-safe, uses `@Serializable` NavKeys)
|
||||
- **Room** (KSP code generation) for local satellite/TLE storage
|
||||
- **OkHttp** 5.x for TLE downloads
|
||||
- **OSMDroid** for map rendering
|
||||
- **Kotlin Serialization** for navigation args and parsing
|
||||
- **Kotlin Serialization** for navigation args and data parsing
|
||||
- **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
|
||||
|
||||
Look4Sat supports both TLE and OMM (Orbit Mean-Elements Message) CSV formats:
|
||||
**TLE vs. OMM/CSV format:**
|
||||
|
||||
- **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)
|
||||
Look4Sat supports both TLE and OMM (Orbit Mean-Elements Message) formats for backward compatibility:
|
||||
|
||||
## Engineering Heuristics (Lazy = Efficient)
|
||||
- **TLE format**: Traditional 3-line element format (deprecated). NORAD catalog numbers are 5-digit integers, which
|
||||
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.
|
||||
|
||||
- Treat "lazy" as efficient, not careless: the best code is the code never written.
|
||||
- First understand the task and trace the real flow end-to-end, then climb this ladder:
|
||||
1. Does this need to be built now? (YAGNI)
|
||||
2. Does it already exist in this codebase? Reuse helpers/patterns before rewriting.
|
||||
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?"
|
||||
**Current implementation:**
|
||||
- `DataParser.kt` handles both `parseTLEStream()` and `parseCSVStream()` seamlessly
|
||||
- TLE data is downloaded from configured sources and stored in Room database
|
||||
- When downloading satellite data, the app automatically detects format and parses accordingly
|
||||
- Both formats produce identical `OrbitalData` objects, ensuring transparent format switching
|
||||
|
||||
## Bug-Fix Policy
|
||||
**Migration path:**
|
||||
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.
|
||||
|
||||
- Fix root cause, not just the reported symptom.
|
||||
- If touching a shared function, inspect callers and prefer one shared fix over per-caller patches.
|
||||
- The smallest correct diff wins only after behavior is understood.
|
||||
## Code Style
|
||||
|
||||
- Prefer **short, focused functions** — single responsibility, easy to read.
|
||||
- **Exceptions**: Composable functions and math-heavy algorithms (SGP4/SDP4) may be longer.
|
||||
- Strict code style — no dead code, no unused imports, consistent formatting.
|
||||
|
||||
## Roadmap
|
||||
|
||||
@@ -107,9 +105,12 @@ Look4Sat supports both TLE and OMM (Orbit Mean-Elements Message) CSV formats:
|
||||
|
||||
## Gotchas
|
||||
|
||||
- Orbital math lives in `core:domain/predict/` — dense vector math (SGP4/SDP4). Tread carefully.
|
||||
- Orbital math lives in `core:domain/predict/` — it's 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.
|
||||
- `build-logic/convention/` contains shared Gradle configuration — edit there, not in individual modules.
|
||||
- `build-logic/convention/` contains all shared Gradle configuration — edit there, not in individual modules.
|
||||
- ProGuard is enabled for release builds — don't add reflection-based libs or any other dependencies without asking.
|
||||
|
||||
## Copilot Working Mode: Code-Only
|
||||
|
||||
|
||||
@@ -1,45 +1,44 @@
|
||||
# Look4Sat: Satellite tracker
|
||||
# Look4Sat-BA7OPF
|
||||
|
||||
[](https://github.com/rt-bishop/Look4Sat/actions/workflows/release.yml)
|
||||
[](https://github.com/atsunatsu/Look4Sat/releases)
|
||||
|
||||
[<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/)
|
||||
**BA7OPF 定制版** — 基于 [rt-bishop/Look4Sat](https://github.com/rt-bishop/Look4Sat) 的业余无线电卫星追踪器,增加了线性卫星频率计算器等功能。
|
||||
|
||||
### Radio satellite tracker and pass predictor for Android, inspired by Gpredict
|
||||
非官方修改版(fork),独立维护;首次发布 2026-09,持续修改中。
|
||||
|
||||
<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>
|
||||
## 本仓库特色功能
|
||||
|
||||
### Track satellite passes with ease!
|
||||
- **网格地图模式(Grid Mode)** — 地图页内置 VUCC 网格覆盖图:已通联网格绿色填充、漫游网格蓝色 45° 斜纹(疏朗半透明样式)、当前所在方格淡黄加粗框;点击已通联网格弹出该网格内通联明细弹窗;进入网格模式默认 VUCC 视图
|
||||
- **奖状边界模式(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.
|
||||
## 上游仓库
|
||||
|
||||
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.
|
||||
本仓库是 [rt-bishop/Look4Sat](https://github.com/rt-bishop/Look4Sat) 的分支,上游仓库的原始功能包括:
|
||||
|
||||
The application is built using Kotlin, Coroutines, Jetpack Compose and Navigation.\
|
||||
It is now and always will be completely ad-free and open-source.
|
||||
- 基于 Celestrak / SatNOGS 数据的 9000+ 活跃卫星追踪
|
||||
- SGP4/SDP4 轨道预测,10 天过境预报
|
||||
- 极坐标雷达图、地面轨迹图
|
||||
- SSTV 图像解码
|
||||
- 无广告、无跟踪、完全离线
|
||||
|
||||
## Main features:
|
||||
## 许可证
|
||||
|
||||
* 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.
|
||||
GNU General Public License v3.0。详见 [LICENSE](LICENSE)。
|
||||
|
||||
## Star History
|
||||
|
||||
<a href="https://star-history.dera.page/#rt-bishop/Look4Sat&type=timeline&legend=top-left">
|
||||
<a href="https://star-history.dera.page/#atsunatsu/Look4Sat&type=timeline&legend=top-left">
|
||||
<picture>
|
||||
<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=rt-bishop/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" />
|
||||
<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: light)" srcset="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=atsunatsu/Look4Sat&type=timeline&legend=top-left" />
|
||||
</picture>
|
||||
</a>
|
||||
@@ -0,0 +1,46 @@
|
||||
# Third-Party Notices
|
||||
|
||||
Look4Sat-BA7OPF (this fork) bundles or adapts the following third-party
|
||||
components:
|
||||
|
||||
## OrbitDeckiOS
|
||||
|
||||
- Source: https://github.com/prstoetzer/OrbitDeckiOS
|
||||
- Copyright (c) 2025 Paul Stoetzer, N8HM
|
||||
- License: MIT
|
||||
- Used for: the Grid Finder feature — the VUCC grid line / grid point
|
||||
geometry in `core/domain/.../utility/GridGeometry.kt` is ported from
|
||||
OrbitDeckiOS' GridGeometry.
|
||||
|
||||
### MIT License
|
||||
|
||||
```
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2025 Paul Stoetzer, N8HM
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
```
|
||||
|
||||
## Material Design Icons
|
||||
|
||||
- Source: https://materialdesignicons.com (Pictogrammers)
|
||||
- License: Apache-2.0
|
||||
- Used for: `core/presentation/src/main/res/drawable/ic_grid_finder.xml`
|
||||
(crosshairs-gps icon). Attribution is also noted in the icon file header.
|
||||
@@ -1,3 +1,41 @@
|
||||
import java.io.FileInputStream
|
||||
import java.util.Properties
|
||||
|
||||
plugins {
|
||||
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,6 +13,7 @@
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" />
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
||||
|
||||
<application
|
||||
android:name=".MainApplication"
|
||||
@@ -25,6 +26,7 @@
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:screenOrientation="portrait"
|
||||
android:theme="@style/Theme.Look4Sat.SplashScreen">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
@@ -42,7 +44,17 @@
|
||||
|
||||
<meta-data
|
||||
android:name="android.telephony.PROPERTY_SATELLITE_DATA_OPTIMIZED"
|
||||
android:value="com.rtbishop.look4sat" />
|
||||
android:value="cn.ba7opf.look4sat" />
|
||||
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
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>
|
||||
</manifest>
|
||||
@@ -27,7 +27,9 @@ import android.view.View
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
|
||||
import com.rtbishop.look4sat.core.presentation.MainTheme
|
||||
@@ -47,9 +49,12 @@ class MainActivity : ComponentActivity() {
|
||||
installSplashScreen()
|
||||
enableEdgeToEdge()
|
||||
super.onCreate(savedInstanceState)
|
||||
val settingsRepo = (applicationContext as IContainerProvider).getMainContainer().settingsRepo
|
||||
observeNightFilterState()
|
||||
setContent {
|
||||
MainTheme(isDarkTheme = true) { NavRoot() }
|
||||
val otherSettings by settingsRepo.otherSettings.collectAsStateWithLifecycle()
|
||||
// Light theme is opt-in: the app stays dark until the user turns it on.
|
||||
MainTheme(isDarkTheme = !otherSettings.stateOfLightTheme) { NavRoot() }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -17,10 +17,21 @@
|
||||
*/
|
||||
package com.rtbishop.look4sat
|
||||
|
||||
import android.app.Activity
|
||||
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.domain.repository.IContainerProvider
|
||||
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 java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
@@ -35,12 +46,42 @@ class MainApplication : Application(), IContainerProvider {
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
container = MainContainer(this)
|
||||
clearAmSatCacheOnAppBackground()
|
||||
// trigger automatic update every 48 hours
|
||||
container.appScope.launch { checkAutoUpdate() }
|
||||
// automatic LoTW grid sync on every app start (gated, see checkLoTWAutoSync)
|
||||
container.appScope.launch { checkLoTWAutoSync() }
|
||||
// repair logbooks split by the old name/band identity mismatch, independent of
|
||||
// whether a sync is due (cheap read + in-memory match on every start)
|
||||
container.appScope.launch { runCatching { container.qsoRepository.consolidateConfirmations() } }
|
||||
// load satellite data on every app start
|
||||
container.appScope.launch { container.satelliteRepo.initRepository() }
|
||||
}
|
||||
|
||||
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()) {
|
||||
if (container.settingsRepo.otherSettings.value.stateOfAutoUpdate) {
|
||||
val timeDelta = timeNow - container.settingsRepo.databaseState.value.updateTimestamp
|
||||
@@ -51,4 +92,42 @@ 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,6 +51,8 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
@@ -61,27 +63,35 @@ import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.lifecycle.ViewModelStoreOwner
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator
|
||||
import androidx.navigation3.runtime.entryProvider
|
||||
import androidx.navigation3.runtime.rememberNavBackStack
|
||||
import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator
|
||||
import androidx.navigation3.ui.NavDisplay
|
||||
import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
|
||||
import com.rtbishop.look4sat.core.presentation.R
|
||||
import com.rtbishop.look4sat.core.domain.repository.RadioTrackingState
|
||||
import com.rtbishop.look4sat.core.domain.repository.MutualPassData
|
||||
import com.rtbishop.look4sat.core.presentation.DeeplinkResolver
|
||||
import com.rtbishop.look4sat.core.presentation.ElevationThresholds
|
||||
import com.rtbishop.look4sat.core.presentation.GridFinderDestination
|
||||
import com.rtbishop.look4sat.core.presentation.LocalElevationThresholds
|
||||
import com.rtbishop.look4sat.core.presentation.LoTWUploadDestination
|
||||
import com.rtbishop.look4sat.core.presentation.RadarDestination
|
||||
import com.rtbishop.look4sat.core.presentation.Screen
|
||||
import com.rtbishop.look4sat.core.presentation.hasEnoughHeight
|
||||
import com.rtbishop.look4sat.core.presentation.hasEnoughWidth
|
||||
import com.rtbishop.look4sat.feature.gridfinder.GridFinderDestination
|
||||
import com.rtbishop.look4sat.feature.map.MapDestination
|
||||
import com.rtbishop.look4sat.feature.map.MapFilterViewModel
|
||||
import com.rtbishop.look4sat.feature.mutual.MutualScreen
|
||||
import com.rtbishop.look4sat.feature.mutual.MutualViewModel
|
||||
import com.rtbishop.look4sat.feature.passes.PassesDestination
|
||||
import com.rtbishop.look4sat.feature.radar.RadarDestination
|
||||
import com.rtbishop.look4sat.feature.satellites.SatellitesDestination
|
||||
import com.rtbishop.look4sat.feature.settings.SettingsDestination
|
||||
import com.rtbishop.look4sat.feature.settings.LoTWUploadDestination
|
||||
import com.rtbishop.look4sat.feature.status.SatStatusDestination
|
||||
|
||||
@Composable
|
||||
@@ -93,6 +103,8 @@ fun NavRoot(deeplink: String? = null) {
|
||||
}
|
||||
val navigateBack: () -> Unit = { rootBackStack.removeLastOrNull() }
|
||||
val navigateToRadar: () -> Unit = { rootBackStack.add(RadarDestination) }
|
||||
val navigateToGridFinder: () -> Unit = { rootBackStack.add(GridFinderDestination) }
|
||||
val navigateToLoTWStation: () -> Unit = { rootBackStack.add(LoTWUploadDestination) }
|
||||
// Incoming screen slides in from the right, outgoing drifts left at 1/3 speed (API35+ style)
|
||||
val pushTransition = slideInHorizontally(tween(300)) { it } togetherWith
|
||||
slideOutHorizontally(tween(300)) { -it / 3 }
|
||||
@@ -124,13 +136,37 @@ fun NavRoot(deeplink: String? = null) {
|
||||
rememberViewModelStoreNavEntryDecorator()
|
||||
),
|
||||
entryProvider = entryProvider {
|
||||
entry<Screen.Passes> { MainScreen(navigateToRadar = navigateToRadar) }
|
||||
entry<Screen.Passes> {
|
||||
MainScreen(
|
||||
navigateToRadar = navigateToRadar,
|
||||
navigateToGridFinder = navigateToGridFinder
|
||||
)
|
||||
}
|
||||
entry<RadarDestination> {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
color = MaterialTheme.colorScheme.background
|
||||
) {
|
||||
RadarDestination(navigateUp = navigateBack)
|
||||
RadarDestination(navigateUp = navigateBack, onOpenLoTWStation = navigateToLoTWStation)
|
||||
}
|
||||
}
|
||||
entry<GridFinderDestination> {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
color = MaterialTheme.colorScheme.background
|
||||
) {
|
||||
GridFinderDestination(
|
||||
navigateUp = navigateBack,
|
||||
onOpenLoTWStation = navigateToLoTWStation
|
||||
)
|
||||
}
|
||||
}
|
||||
entry<LoTWUploadDestination> {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
color = MaterialTheme.colorScheme.background
|
||||
) {
|
||||
LoTWUploadDestination(navigateUp = navigateBack)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -139,139 +175,202 @@ fun NavRoot(deeplink: String? = null) {
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MainScreen(navigateToRadar: () -> Unit = {}) {
|
||||
val context = LocalContext.current
|
||||
val container = (context.applicationContext as IContainerProvider).getMainContainer()
|
||||
val trackingState by container.radioTrackingService.state.collectAsStateWithLifecycle()
|
||||
|
||||
fun MainScreen(
|
||||
navigateToRadar: () -> Unit = {},
|
||||
navigateToGridFinder: () -> Unit = {}
|
||||
) {
|
||||
val backStack = rememberNavBackStack(Screen.Passes)
|
||||
val currentKey = backStack.lastOrNull()
|
||||
val navigateBack: () -> Unit = { backStack.removeLastOrNull() }
|
||||
val fadeTransition = fadeIn(animationSpec = tween(300)) togetherWith
|
||||
fadeOut(animationSpec = tween(300))
|
||||
val navItems =
|
||||
listOf(Screen.Satellites, Screen.Passes, Screen.Status, Screen.Map, Screen.Settings)
|
||||
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)
|
||||
|
||||
NavigationSuiteScaffold(
|
||||
navigationSuiteItems = {
|
||||
navItems.forEach { screen ->
|
||||
val isSelected = when (currentKey) {
|
||||
is Screen.Satellites -> screen is Screen.Satellites
|
||||
is Screen.Passes -> screen is Screen.Passes
|
||||
is Screen.Status -> screen is Screen.Status
|
||||
is Screen.Map -> screen is Screen.Map
|
||||
is Screen.Settings -> screen is Screen.Settings
|
||||
else -> false
|
||||
}
|
||||
item(
|
||||
icon = {
|
||||
Icon(
|
||||
painter = painterResource(screen.iconResId),
|
||||
contentDescription = 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 {
|
||||
NavDisplay(
|
||||
backStack = backStack,
|
||||
modifier = Modifier.weight(1f),
|
||||
onBack = navigateBack,
|
||||
transitionSpec = { fadeTransition },
|
||||
popTransitionSpec = { fadeTransition },
|
||||
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.Status> { SatStatusDestination() }
|
||||
entry<Screen.Map> { MapDestination() }
|
||||
entry<Screen.Settings> { SettingsDestination() }
|
||||
}
|
||||
)
|
||||
if (trackingState.isActive) {
|
||||
TrackingBanner(
|
||||
state = trackingState,
|
||||
onClick = {
|
||||
val pass = trackingState.currentPass
|
||||
if (pass != null) {
|
||||
container.satelliteRepo.selectPass(pass.catNum, pass.aosTime)
|
||||
navigateToRadar()
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@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"
|
||||
val context = LocalContext.current
|
||||
val container = (context.applicationContext as IContainerProvider).getMainContainer()
|
||||
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)
|
||||
)
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(MaterialTheme.colorScheme.primaryContainer)
|
||||
.clickable(onClick = onClick)
|
||||
.padding(horizontal = 12.dp, vertical = 6.dp)
|
||||
// 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
|
||||
// grid and run the query BEFORE navigating, so the page's first frame
|
||||
// already contains the results (time-range card at the top, pass curves
|
||||
// right below — no scroll flicker). Navigation happens once the query
|
||||
// finishes (see the collector below).
|
||||
val mutualState by mutualViewModel.uiState.collectAsStateWithLifecycle()
|
||||
val matchCalculating = mutualState.pendingNavigation && mutualState.isCalculating
|
||||
|
||||
LaunchedEffect(mutualViewModel) {
|
||||
mutualViewModel.uiState.collect { state ->
|
||||
if (state.pendingNavigation && !state.isCalculating) {
|
||||
mutualViewModel.consumePendingNavigation()
|
||||
// Push Mutual on top of the Map entry instead of replacing the
|
||||
// stack (bottom-nav style): the system back gesture then pops
|
||||
// back to the map page, which is the page the user came from.
|
||||
if (backStack.lastOrNull() !is Screen.Mutual) {
|
||||
backStack.add(Screen.Mutual)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
CompositionLocalProvider(
|
||||
LocalElevationThresholds provides ElevationThresholds(
|
||||
low = otherSettings.lowElevation,
|
||||
high = otherSettings.highElevation
|
||||
)
|
||||
) {
|
||||
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
|
||||
)
|
||||
NavigationSuiteScaffold(
|
||||
navigationSuiteItems = {
|
||||
navItems.forEach { 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 {
|
||||
NavDisplay(
|
||||
backStack = backStack,
|
||||
modifier = Modifier.weight(1f),
|
||||
onBack = navigateBack,
|
||||
transitionSpec = { fadeTransition },
|
||||
popTransitionSpec = { fadeTransition },
|
||||
predictivePopTransitionSpec = { fadeTransition },
|
||||
entryDecorators = listOf(
|
||||
// Required for saving Compose state per entry
|
||||
rememberSaveableStateHolderNavEntryDecorator(),
|
||||
// Required for ViewModel scoping per entry
|
||||
rememberViewModelStoreNavEntryDecorator()
|
||||
),
|
||||
entryProvider = entryProvider {
|
||||
entry<Screen.Satellites> {
|
||||
SatellitesDestination(navigateUp = navigateBack)
|
||||
}
|
||||
entry<Screen.Passes> {
|
||||
PassesDestination(
|
||||
navigateToRadar = { catNum, aosTime ->
|
||||
container.setMutualPassData(MutualPassData())
|
||||
container.satelliteRepo.selectPass(catNum, aosTime)
|
||||
navigateToRadar()
|
||||
}
|
||||
)
|
||||
}
|
||||
entry<Screen.Map> {
|
||||
MapDestination(
|
||||
mapFilterViewModel = mapFilterViewModel,
|
||||
matchCalculating = matchCalculating,
|
||||
onMatchGrid = { grid ->
|
||||
// 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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
<?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,6 +32,8 @@ internal class ApplicationPlugin : Plugin<Project> {
|
||||
implementation(project(":core:domain"))
|
||||
implementation(project(":core:presentation"))
|
||||
implementation(project(":feature:map"))
|
||||
implementation(project(":feature:gridfinder"))
|
||||
implementation(project(":feature:mutual"))
|
||||
implementation(project(":feature:passes"))
|
||||
implementation(project(":feature:radar"))
|
||||
implementation(project(":feature:satellites"))
|
||||
|
||||
+3
@@ -35,6 +35,9 @@ internal class CoreDataPlugin : Plugin<Project> {
|
||||
ksp(libs.androidx.room.compiler)
|
||||
implementation(libs.kotlin.coroutines)
|
||||
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,46 +5,6 @@ plugins {
|
||||
alias(libs.plugins.google.ksp) apply false
|
||||
alias(libs.plugins.kotlin.jvm) 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) {
|
||||
|
||||
@@ -1,808 +0,0 @@
|
||||
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.
@@ -19,7 +19,6 @@ package com.rtbishop.look4sat.core.data.database
|
||||
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Insert
|
||||
import androidx.room.MapColumn
|
||||
import androidx.room.OnConflictStrategy
|
||||
import androidx.room.Query
|
||||
import androidx.room.Transaction
|
||||
@@ -40,32 +39,28 @@ interface Look4SatDao {
|
||||
@Query("SELECT * FROM entries WHERE catnum IN (:selectedIds)")
|
||||
suspend fun getEntriesWithIds(selectedIds: List<Int>): List<SatEntry>
|
||||
|
||||
@Query("SELECT catnum, epoch FROM entries")
|
||||
suspend fun getEntriesEpochs(): Map<@MapColumn("catnum") Int, @MapColumn("epoch") Double>
|
||||
|
||||
@Query("SELECT catnum, name FROM entries")
|
||||
suspend fun getEntriesNames(): Map<@MapColumn("catnum") Int, @MapColumn("name") String>
|
||||
|
||||
@Query("UPDATE entries SET name = :name WHERE catnum = :catnum")
|
||||
suspend fun renameEntry(catnum: Int, name: String)
|
||||
|
||||
@Query("DELETE FROM entries WHERE catnum IN (:ids)")
|
||||
suspend fun deleteEntriesWithIds(ids: List<Int>)
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun insertEntries(entries: List<SatEntry>)
|
||||
|
||||
@Query("DELETE FROM entries")
|
||||
suspend fun deleteEntries()
|
||||
|
||||
@Query(
|
||||
"""
|
||||
SELECT DISTINCT catnum FROM radios WHERE isAlive = 1
|
||||
AND (downlinkMode IN (:modes) OR uplinkMode IN (:modes))
|
||||
"""
|
||||
)
|
||||
@Query("SELECT catnum FROM radios WHERE downlinkMode IN (:modes) AND isAlive = 1")
|
||||
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")
|
||||
suspend fun getRadiosTotal(): Int
|
||||
|
||||
|
||||
@@ -24,9 +24,7 @@ import androidx.sqlite.db.SupportSQLiteDatabase
|
||||
import com.rtbishop.look4sat.core.data.database.entity.SatEntry
|
||||
import com.rtbishop.look4sat.core.data.database.entity.SatRadio
|
||||
|
||||
const val DATABASE_NAME = "Look4SatDBv400"
|
||||
|
||||
@Database(entities = [SatEntry::class, SatRadio::class], version = 2, exportSchema = false)
|
||||
@Database(entities = [SatEntry::class, SatRadio::class], version = 3, exportSchema = false)
|
||||
abstract class Look4SatDb : RoomDatabase() {
|
||||
abstract fun look4SatDao(): Look4SatDao
|
||||
}
|
||||
@@ -39,3 +37,11 @@ val MIGRATION_1_2 = object : Migration(1, 2) {
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
/*
|
||||
* 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>)
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
/*
|
||||
* 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 ''")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
/*
|
||||
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*/
|
||||
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,6 +33,10 @@ data class SatRadio(
|
||||
val uplinkMode: String?,
|
||||
val isInverted: Boolean,
|
||||
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. */
|
||||
val isCustom: Boolean = false
|
||||
)
|
||||
+4
-3
@@ -330,12 +330,13 @@ object IcomCivProtocol {
|
||||
|
||||
/**
|
||||
* Parse frequency + mode from a CMD_READ_FREQ reply payload.
|
||||
* Payload layout after stripping command byte: [5 freq bytes] [mode byte] [filter byte]
|
||||
* Payload layout after stripping command byte: [5 freq bytes]
|
||||
* The IC-705 does not include a mode byte; if one is present it is parsed, otherwise mode is empty.
|
||||
*/
|
||||
fun parseFreqModePayload(payload: ByteArray): Pair<Long, String>? {
|
||||
if (payload.size < 6) return null
|
||||
if (payload.size < 5) return null
|
||||
val freqHz = decodeFrequencyBcd(payload.copyOfRange(0, 5))
|
||||
val mode = BYTE_TO_MODE[payload[5]] ?: return null
|
||||
val mode = if (payload.size > 5) BYTE_TO_MODE[payload[5]] ?: return null else ""
|
||||
return freqHz to mode
|
||||
}
|
||||
|
||||
|
||||
+4
-2
@@ -67,7 +67,7 @@ class NetworkReporter(
|
||||
.replace($$"$AZ", azimuth.toString())
|
||||
.replace($$"$EL", el.toString())
|
||||
.unescapeControlChars()
|
||||
write(rotatorSocket, command) { rotatorConnected = false }
|
||||
write(rotatorSocket, command) { resetRotatorConnection() }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -154,5 +154,7 @@ class NetworkReporter(
|
||||
}
|
||||
|
||||
private fun String.unescapeControlChars(): String =
|
||||
replace("\\r", "\r").replace("\\n", "\n").replace("\\t", "\t")
|
||||
replace("""\r""", 13.toChar().toString())
|
||||
.replace("""\n""", 10.toChar().toString())
|
||||
.replace("""\t""", 9.toChar().toString())
|
||||
}
|
||||
+71
-24
@@ -17,43 +17,60 @@
|
||||
*/
|
||||
package com.rtbishop.look4sat.core.data.injection
|
||||
|
||||
import android.Manifest
|
||||
import android.bluetooth.BluetoothManager
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import android.hardware.SensorManager
|
||||
import android.hardware.display.DisplayManager
|
||||
import android.location.LocationManager
|
||||
import androidx.core.content.pm.PackageInfoCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.room.Room
|
||||
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.Look4SatDb
|
||||
import com.rtbishop.look4sat.core.data.database.MIGRATION_1_2
|
||||
import com.rtbishop.look4sat.core.data.database.MIGRATION_2_3
|
||||
import com.rtbishop.look4sat.core.data.database.MIGRATION_QSO_1_2
|
||||
import com.rtbishop.look4sat.core.data.database.MIGRATION_QSO_2_3
|
||||
import com.rtbishop.look4sat.core.data.database.QsoDatabase
|
||||
import com.rtbishop.look4sat.core.data.framework.BluetoothReporter
|
||||
import com.rtbishop.look4sat.core.data.framework.Ft817Controller
|
||||
import com.rtbishop.look4sat.core.data.framework.Ic705Controller
|
||||
import com.rtbishop.look4sat.core.data.framework.NetworkReporter
|
||||
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.DatabaseRepo
|
||||
import com.rtbishop.look4sat.core.data.repository.QsoRepository
|
||||
import com.rtbishop.look4sat.core.data.repository.SatelliteRepo
|
||||
import com.rtbishop.look4sat.core.data.repository.SelectionRepo
|
||||
import com.rtbishop.look4sat.core.data.repository.SensorsRepo
|
||||
import com.rtbishop.look4sat.core.data.repository.SettingsRepo
|
||||
import com.rtbishop.look4sat.core.data.repository.UpdateRepository
|
||||
import com.rtbishop.look4sat.core.data.repository.LoTWRepository
|
||||
import com.rtbishop.look4sat.core.data.repository.LocationRepo
|
||||
import com.rtbishop.look4sat.core.data.repository.WavelogRepository
|
||||
import com.rtbishop.look4sat.core.data.source.LocalSource
|
||||
import com.rtbishop.look4sat.core.data.source.RemoteSource
|
||||
import com.rtbishop.look4sat.core.data.usecase.AddToCalendar
|
||||
import com.rtbishop.look4sat.core.data.usecase.AudioCapture
|
||||
import com.rtbishop.look4sat.core.data.usecase.SaveImage
|
||||
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.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.IRadioController
|
||||
import com.rtbishop.look4sat.core.domain.repository.IRadioTrackingService
|
||||
import com.rtbishop.look4sat.core.domain.repository.IReporter
|
||||
import com.rtbishop.look4sat.core.domain.repository.ILoTWRepository
|
||||
import com.rtbishop.look4sat.core.domain.repository.ILocationRepo
|
||||
import com.rtbishop.look4sat.core.domain.repository.ISatelliteRepo
|
||||
import com.rtbishop.look4sat.core.domain.repository.ISelectionRepo
|
||||
import com.rtbishop.look4sat.core.domain.repository.ISensorsRepo
|
||||
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
|
||||
import com.rtbishop.look4sat.core.domain.repository.IWavelogRepository
|
||||
import com.rtbishop.look4sat.core.domain.repository.MutualPassData
|
||||
import com.rtbishop.look4sat.core.domain.source.ILocalSource
|
||||
import com.rtbishop.look4sat.core.domain.source.IRemoteSource
|
||||
import com.rtbishop.look4sat.core.domain.usecase.IAddToCalendar
|
||||
@@ -65,7 +82,11 @@ import kotlinx.coroutines.CoroutineExceptionHandler
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import okhttp3.OkHttpClient
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
class MainContainer(private val context: Context) : IMainContainer {
|
||||
|
||||
@@ -77,11 +98,40 @@ class MainContainer(private val context: Context) : IMainContainer {
|
||||
override val selectionRepo = provideSelectionRepo()
|
||||
override val satelliteRepo = provideSatelliteRepo()
|
||||
override val databaseRepo = provideDatabaseRepo()
|
||||
override val amSatRepo by lazy { AmSatRepository(remoteSource) }
|
||||
override val amSatRepo by lazy { AmSatRepository(remoteSource, appScope) }
|
||||
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 {
|
||||
val manager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
|
||||
RadioTrackingService(appScope, manager, satelliteRepo, settingsRepo)
|
||||
}
|
||||
override val locationRepo: ILocationRepo by lazy {
|
||||
val manager = context.getSystemService(Context.LOCATION_SERVICE) as LocationManager
|
||||
LocationRepo(manager) {
|
||||
ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) ==
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
}
|
||||
}
|
||||
|
||||
private val _mutualPassData = MutableStateFlow(MutualPassData())
|
||||
override val mutualPassData: StateFlow<MutualPassData> = _mutualPassData.asStateFlow()
|
||||
private val _pendingLoTWStationGrid = MutableStateFlow<String?>(null)
|
||||
override val pendingLoTWStationGrid: StateFlow<String?> = _pendingLoTWStationGrid.asStateFlow()
|
||||
|
||||
override fun setMutualPassData(data: MutualPassData) {
|
||||
_mutualPassData.value = data
|
||||
}
|
||||
override fun setPendingLoTWStationGrid(grid: String?) {
|
||||
_pendingLoTWStationGrid.value = grid
|
||||
}
|
||||
|
||||
override fun provideAddToCalendar(): IAddToCalendar = AddToCalendar(context)
|
||||
|
||||
@@ -142,33 +192,32 @@ class MainContainer(private val context: Context) : IMainContainer {
|
||||
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 {
|
||||
val dbDispatcher = Dispatchers.Default
|
||||
val dataParser = DataParser(dbDispatcher)
|
||||
val remoteSource = provideRemoteSource()
|
||||
return DatabaseRepo(dbDispatcher, dataParser, localSource, remoteSource, settingsRepo)
|
||||
}
|
||||
|
||||
private fun provideLocalSource(): ILocalSource {
|
||||
val builder = Room.databaseBuilder(context, Look4SatDb::class.java, DATABASE_NAME)
|
||||
val database = builder.addMigrations(MIGRATION_1_2).fallbackToDestructiveMigration(false).build()
|
||||
val builder = Room.databaseBuilder(context, Look4SatDb::class.java, "Look4SatDBv400")
|
||||
val database = builder
|
||||
.addMigrations(MIGRATION_1_2, MIGRATION_2_3)
|
||||
.fallbackToDestructiveMigration(false)
|
||||
.build()
|
||||
return LocalSource(database.look4SatDao())
|
||||
}
|
||||
|
||||
private fun provideRemoteSource(): IRemoteSource {
|
||||
val version = context.packageManager.getPackageInfo(context.packageName, 0).versionName ?: "4.0.4"
|
||||
val userAgent = "Look4Sat/$version (+https://github.com/rt-bishop/Look4Sat)"
|
||||
// Data providers ask clients to identify themselves, so that they can reach out to the
|
||||
// developer instead of silently blocking every user of the app behind a misbehaving request
|
||||
val client = OkHttpClient.Builder().addInterceptor { chain ->
|
||||
chain.proceed(chain.request().newBuilder().header("User-Agent", userAgent).build())
|
||||
}.build()
|
||||
// amsat.org live pages are slow (7-10s+ from some networks); the
|
||||
// OkHttp defaults (10s read timeout) made every AMSAT Live update
|
||||
// time out, silently keeping stale FM/Linear lists forever.
|
||||
val client = OkHttpClient.Builder()
|
||||
.connectTimeout(20, TimeUnit.SECONDS)
|
||||
.readTimeout(30, TimeUnit.SECONDS)
|
||||
.writeTimeout(30, TimeUnit.SECONDS)
|
||||
.callTimeout(60, TimeUnit.SECONDS)
|
||||
.build()
|
||||
return RemoteSource(Dispatchers.IO, context.contentResolver, client)
|
||||
}
|
||||
|
||||
@@ -184,9 +233,7 @@ class MainContainer(private val context: Context) : IMainContainer {
|
||||
val manager = context.getSystemService(Context.LOCATION_SERVICE) as LocationManager
|
||||
val appPrefsFileName = "${context.packageName}_preferences"
|
||||
val appPreferences = context.getSharedPreferences(appPrefsFileName, Context.MODE_PRIVATE)
|
||||
val packageInfo = context.packageManager.getPackageInfo(context.packageName, 0)
|
||||
val appVersionName = packageInfo.versionName ?: "4.0.4"
|
||||
val appVersionCode = PackageInfoCompat.getLongVersionCode(packageInfo)
|
||||
return SettingsRepo(manager, appPreferences, appVersionName, appVersionCode)
|
||||
val appVersionName = context.packageManager.getPackageInfo(context.packageName, 0).versionName ?: "4.0.4"
|
||||
return SettingsRepo(manager, appPreferences, appVersionName)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
/*
|
||||
* 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 }
|
||||
@@ -0,0 +1,175 @@
|
||||
/*
|
||||
* 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
/*
|
||||
* 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"
|
||||
@@ -0,0 +1,79 @@
|
||||
/*
|
||||
* Look4Sat-BA7OPF. Amateur radio satellite tracker and pass predictor.
|
||||
* Copyright (C) 2026 BA7OPF.
|
||||
* Based on Look4Sat by Arty Bishop and contributors.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.rtbishop.look4sat.core.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" }
|
||||
}
|
||||
@@ -0,0 +1,468 @@
|
||||
/*
|
||||
* 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
|
||||
@@ -0,0 +1,301 @@
|
||||
/*
|
||||
* 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
|
||||
}
|
||||
}
|
||||
}
|
||||
+105
-8
@@ -1,3 +1,21 @@
|
||||
/*
|
||||
* Look4Sat-BA7OPF. Amateur radio satellite tracker and pass predictor.
|
||||
* Copyright (C) 2026 BA7OPF.
|
||||
* Based on Look4Sat by Arty Bishop and contributors.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.rtbishop.look4sat.core.data.repository
|
||||
|
||||
import com.rtbishop.look4sat.core.domain.model.AmSatReportSubmission
|
||||
@@ -9,7 +27,13 @@ import com.rtbishop.look4sat.core.domain.model.SatStatus
|
||||
import com.rtbishop.look4sat.core.domain.model.SatStatusPage
|
||||
import com.rtbishop.look4sat.core.domain.repository.IAmSatRepository
|
||||
import com.rtbishop.look4sat.core.domain.source.IRemoteSource
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Deferred
|
||||
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 org.json.JSONObject
|
||||
import java.text.SimpleDateFormat
|
||||
@@ -29,27 +53,85 @@ private data class ApiReport(
|
||||
)
|
||||
|
||||
/** AMSAT status repository using RemoteSource (Clean Architecture: data layer handles HTTP). */
|
||||
class AmSatRepository(private val remoteSource: IRemoteSource) : IAmSatRepository {
|
||||
class AmSatRepository(
|
||||
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 {
|
||||
timeZone = TimeZone.getTimeZone("UTC")
|
||||
}
|
||||
|
||||
override suspend fun fetchStatus(): SatStatusPage? = withContext(Dispatchers.IO) {
|
||||
override fun getCachedStatus(): SatStatusPage? = statusCache
|
||||
|
||||
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 catalogJson = remoteSource.getAmSatCatalog() ?: return@withContext null
|
||||
val catalogJson = remoteSource.getAmSatCatalog() ?: return null
|
||||
// 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.
|
||||
// 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@withContext null
|
||||
val reportsJson = remoteSource.getAmSatReports(hours = 72, limit = 500) ?: return null
|
||||
|
||||
val names = parseCatalog(catalogJson)
|
||||
val reports = parseReports(reportsJson)
|
||||
|
||||
if (names.isEmpty() && reports.isEmpty()) return@withContext null
|
||||
if (names.isEmpty() && reports.isEmpty()) return null
|
||||
|
||||
val statuses = buildStatuses(names, reports, nowSec)
|
||||
val reportMap = reports.associate { it.id to toSatReport(it) }
|
||||
SatStatusPage(System.currentTimeMillis(), statuses, reportMap)
|
||||
return SatStatusPage(System.currentTimeMillis(), statuses, reportMap).also { page ->
|
||||
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) {
|
||||
@@ -132,7 +214,7 @@ class AmSatRepository(private val remoteSource: IRemoteSource) : IAmSatRepositor
|
||||
}
|
||||
}
|
||||
|
||||
/** Build one SatStatus (5 days x 12 slots) per catalog satellite, slotting reports by age. */
|
||||
/** Build one SatStatus (3 days x 12 slots) per catalog satellite, slotting reports by age. */
|
||||
private fun buildStatuses(names: List<String>, reports: List<ApiReport>, nowSec: Long): List<SatStatus> {
|
||||
val byName = reports.groupBy { it.name }
|
||||
val utc = Calendar.getInstance(TimeZone.getTimeZone("UTC"))
|
||||
@@ -157,7 +239,22 @@ class AmSatRepository(private val remoteSource: IRemoteSource) : IAmSatRepositor
|
||||
}
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
+46
-80
@@ -23,6 +23,8 @@ import com.rtbishop.look4sat.core.domain.repository.IDatabaseRepo
|
||||
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
|
||||
import com.rtbishop.look4sat.core.domain.source.ILocalSource
|
||||
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 kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.async
|
||||
@@ -39,13 +41,14 @@ class DatabaseRepo(
|
||||
private val settingsRepo: ISettingsRepo
|
||||
) : IDatabaseRepo {
|
||||
|
||||
private val customSourceType = "Other"
|
||||
|
||||
override suspend fun updateTLEFromFile(uri: String): Int = withContext(dispatcher) {
|
||||
var importedCount = 0
|
||||
remoteSource.getFileStream(uri)?.let { stream ->
|
||||
val entries = mergeEntries(listOf(parseSatelliteStream(uri, unwrapIfZipped(uri, stream))))
|
||||
insertFresherEntries(entries)
|
||||
// report what the file contained: a valid file holding only stale data is not an error
|
||||
val entries = parseSatelliteStream(uri, unwrapIfZipped(uri, stream))
|
||||
localSource.insertEntries(entries)
|
||||
settingsRepo.setSatelliteTypeIds(customSourceType, entries.map { it.catnum })
|
||||
importedCount = entries.size
|
||||
}
|
||||
setUpdateSuccessful(System.currentTimeMillis())
|
||||
@@ -65,39 +68,55 @@ class DatabaseRepo(
|
||||
|
||||
override suspend fun updateFromRemote() = withContext(dispatcher) {
|
||||
val settings = settingsRepo.dataSourcesSettings.value
|
||||
fun normalizeUrl(url: String) = if (url.startsWith("http")) url else "https://$url"
|
||||
val tleUrls = settings.satelliteUrls.filterIndexed { i, url -> url.isNotBlank() && settings.isSatelliteEnabled(i) }
|
||||
val radioUrls = settings.transceiversUrls.filterIndexed { i, url -> url.isNotBlank() && settings.isTransceiverEnabled(i) }
|
||||
// Keep the raw URL as the status key (matches what the settings UI
|
||||
// displays) while requesting with the normalized URL.
|
||||
val tleUrls = settings.satelliteUrls
|
||||
.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
|
||||
val tleJobs = tleUrls.map { url -> async { url to remoteSource.getNetworkStream(normalizeUrl(url)) } }
|
||||
val radioJobs = radioUrls.map { url -> async { url to remoteSource.getNetworkStream(normalizeUrl(url)) } }
|
||||
val tleJobs = tleUrls.map { (raw, norm) -> async { raw to remoteSource.getNetworkStream(norm) } }
|
||||
val radioJobs = radioUrls.map { (raw, norm) -> async { raw to remoteSource.getNetworkStream(norm) } }
|
||||
val tleResults = tleJobs.awaitAll()
|
||||
val radioResults = radioJobs.awaitAll()
|
||||
// report the HTTP status code of every source (200, 404, ...)
|
||||
settingsRepo.updateDataSourcesStatus(
|
||||
(tleResults + radioResults).associate { (url, result) -> url to result.code }
|
||||
)
|
||||
// parse fetched data concurrently, merging satellites by freshness instead of source order
|
||||
val parsedPerSource = tleResults.map { (url, result) ->
|
||||
result.stream?.let { val nUrl = normalizeUrl(url); parseSatelliteStream(nUrl, unwrapIfZipped(nUrl, it)) }.orEmpty()
|
||||
}
|
||||
val importedRadios = radioResults.flatMap { (url, result) ->
|
||||
result.stream?.let { val nUrl = normalizeUrl(url); dataParser.parseJSONStream(unwrapIfZipped(nUrl, it)) }.orEmpty()
|
||||
// parse fetched data concurrently and associate known built-in URLs with existing type filters.
|
||||
val importedEntries = tleResults.flatMap { (rawUrl, result) ->
|
||||
val normUrl = normalizeUrl(rawUrl)
|
||||
val entries = result.stream?.let { parseSatelliteStream(normUrl, unwrapIfZipped(normUrl, it)) }.orEmpty()
|
||||
val type = builtinTypesByUrl[normUrl] ?: customSourceType
|
||||
importedTypeIds.getOrPut(type) { mutableListOf() }.addAll(entries.map { it.catnum })
|
||||
entries
|
||||
}.distinctBy { it.catnum }
|
||||
importedTypeIds.forEach { (type, ids) -> settingsRepo.setSatelliteTypeIds(type, ids.distinct()) }
|
||||
val importedRadios = radioResults.flatMap { (rawUrl, result) ->
|
||||
val normUrl = normalizeUrl(rawUrl)
|
||||
result.stream?.let { dataParser.parseJSONStream(unwrapIfZipped(normUrl, it)) }.orEmpty()
|
||||
}.filter { it.uuid.isNotBlank() }.distinctBy { it.uuid }
|
||||
// insert parsed data into the database
|
||||
insertFresherEntries(mergeEntries(parsedPerSource))
|
||||
// transceivers are a full snapshot: sources publish active entries only, so a retired
|
||||
// insert parsed data into the database.
|
||||
// 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.
|
||||
// Imported ones are kept: no source can refresh them, so nothing would bring them back
|
||||
// Manually imported ones (isCustom) are kept: no source can refresh them, so nothing
|
||||
// would bring them back if they were replaced here.
|
||||
if (importedRadios.isNotEmpty()) {
|
||||
localSource.deleteManagedRadios()
|
||||
localSource.insertRadios(importedRadios, isCustom = false)
|
||||
localSource.insertRadios(importedRadios)
|
||||
}
|
||||
// keep the previous timestamp when every source failed, so the next launch retries
|
||||
val hasFetchedData = parsedPerSource.any { entries -> entries.isNotEmpty() } || importedRadios.isNotEmpty()
|
||||
val previousTimestamp = settingsRepo.databaseState.value.updateTimestamp
|
||||
if (hasFetchedData) pruneStaleEntries()
|
||||
setUpdateSuccessful(if (hasFetchedData) System.currentTimeMillis() else previousTimestamp)
|
||||
if (importedEntries.isNotEmpty()) localSource.insertEntries(importedEntries)
|
||||
setUpdateSuccessful(System.currentTimeMillis())
|
||||
}
|
||||
|
||||
override suspend fun clearAllData() = withContext(dispatcher) {
|
||||
@@ -106,6 +125,9 @@ class DatabaseRepo(
|
||||
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> {
|
||||
val bufferedStream = stream.buffered()
|
||||
return when {
|
||||
@@ -133,62 +155,6 @@ class DatabaseRepo(
|
||||
line.count { it == ',' } >= 4
|
||||
}
|
||||
|
||||
/**
|
||||
* Merges the data of every source: orbital elements always come from the set with the newest
|
||||
* epoch, while the name comes from the first source that provides it. Source order is thus a
|
||||
* naming preference only, which also keeps names stable when sources leapfrog each other.
|
||||
*/
|
||||
private fun mergeEntries(sourceEntries: List<List<OrbitalData>>): List<OrbitalData> {
|
||||
val preferredNames = mutableMapOf<Int, String>()
|
||||
val freshestEntries = mutableMapOf<Int, OrbitalData>()
|
||||
sourceEntries.forEach { entries ->
|
||||
entries.forEach { entry ->
|
||||
val name = entry.name.trim()
|
||||
if (name.isNotBlank()) preferredNames.getOrPut(entry.catnum) { name }
|
||||
val current = freshestEntries[entry.catnum]
|
||||
if (current == null || entry.epochDaynum > current.epochDaynum) {
|
||||
freshestEntries[entry.catnum] = entry
|
||||
}
|
||||
}
|
||||
}
|
||||
return freshestEntries.values.map { entry ->
|
||||
val name = preferredNames[entry.catnum]
|
||||
if (name == null || name == entry.name) entry else entry.copy(name = name)
|
||||
}
|
||||
}
|
||||
|
||||
/** Stores the entries that are newer than the ones already saved, renaming the rest in place. */
|
||||
private suspend fun insertFresherEntries(entries: List<OrbitalData>) {
|
||||
val storedEpochs = localSource.getEntriesEpochs()
|
||||
val (fresherEntries, staleEntries) = entries.partition { entry ->
|
||||
val storedEpoch = storedEpochs[entry.catnum]
|
||||
storedEpoch == null || entry.epochDaynum > OrbitalData.epochToDaynum(storedEpoch)
|
||||
}
|
||||
localSource.insertEntries(fresherEntries)
|
||||
// a reordered source list has to rename satellites right away, even the ones holding
|
||||
// elements that are newer than the ones just parsed
|
||||
if (staleEntries.isNotEmpty()) {
|
||||
val storedNames = localSource.getEntriesNames()
|
||||
val renamedEntries = staleEntries.filter { entry -> entry.name != storedNames[entry.catnum] }
|
||||
if (renamedEntries.isNotEmpty()) {
|
||||
localSource.renameEntries(renamedEntries.associate { it.catnum to it.name })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Drops satellites that no enabled source has refreshed for a month: they either decayed or
|
||||
* disappeared from every catalog. Manually imported data ages out the same way, and every
|
||||
* source republishes active satellites well within that window.
|
||||
*/
|
||||
private suspend fun pruneStaleEntries() {
|
||||
val currentDaynum = OrbitalData.timeToDaynum(System.currentTimeMillis())
|
||||
val staleIds = localSource.getEntriesEpochs()
|
||||
.filterValues { epoch -> currentDaynum - OrbitalData.epochToDaynum(epoch) > 30.0 }
|
||||
.keys.toList()
|
||||
if (staleIds.isNotEmpty()) localSource.deleteEntriesWithIds(staleIds)
|
||||
}
|
||||
|
||||
private suspend fun setUpdateSuccessful(timestamp: Long) {
|
||||
settingsRepo.updateDatabaseState(
|
||||
DatabaseState(localSource.getRadiosTotal(), localSource.getEntriesTotal(), timestamp)
|
||||
|
||||
@@ -0,0 +1,524 @@
|
||||
/*
|
||||
* 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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
/*
|
||||
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.rtbishop.look4sat.core.data.repository
|
||||
|
||||
import android.location.Location
|
||||
import android.location.LocationListener
|
||||
import android.location.LocationManager
|
||||
import androidx.core.location.LocationManagerCompat
|
||||
import com.rtbishop.look4sat.core.domain.repository.ILocationRepo
|
||||
import com.rtbishop.look4sat.core.domain.repository.LocationFix
|
||||
import com.rtbishop.look4sat.core.domain.repository.LocationState
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
|
||||
private const val UPDATE_INTERVAL_MS = 1_000L
|
||||
private const val UPDATE_MIN_DISTANCE_M = 0f
|
||||
|
||||
/**
|
||||
* Live-location adapter for the field tools. Deliberately independent of
|
||||
* [SettingsRepo]'s station position: a walk around a grid corner must not
|
||||
* rewrite the station that every pass prediction is built on.
|
||||
*
|
||||
* Unlike [SettingsRepo.setStationPosition], this class always calls
|
||||
* [LocationManager.removeUpdates] in [stopUpdates], so the listener is only
|
||||
* alive while a screen is actually collecting fixes.
|
||||
*/
|
||||
class LocationRepo(
|
||||
private val locationManager: LocationManager,
|
||||
private val permissionGranted: () -> Boolean
|
||||
) : ILocationRepo, LocationListener {
|
||||
|
||||
private val _state = MutableStateFlow(initialState())
|
||||
override val state: StateFlow<LocationState> = _state.asStateFlow()
|
||||
|
||||
private var isRegistered = false
|
||||
|
||||
override fun hasPermission(): Boolean = permissionGranted()
|
||||
|
||||
override fun startUpdates() {
|
||||
if (isRegistered) return
|
||||
val request = describeProvider() ?: return
|
||||
if (!request.permissionGranted || !request.providerEnabled) return
|
||||
try {
|
||||
// A last-known fix is better than an empty screen while GPS warms up.
|
||||
locationManager.getLastKnownLocation(request.provider)?.let(::publish)
|
||||
locationManager.requestLocationUpdates(
|
||||
request.provider, UPDATE_INTERVAL_MS, UPDATE_MIN_DISTANCE_M, this
|
||||
)
|
||||
isRegistered = true
|
||||
} catch (exception: SecurityException) {
|
||||
println("LocationRepo: no permission - $exception")
|
||||
} catch (exception: IllegalArgumentException) {
|
||||
println("LocationRepo: provider unavailable - $exception")
|
||||
}
|
||||
}
|
||||
|
||||
override fun stopUpdates() {
|
||||
if (!isRegistered) return
|
||||
isRegistered = false
|
||||
try {
|
||||
locationManager.removeUpdates(this)
|
||||
} catch (exception: SecurityException) {
|
||||
println("LocationRepo: removing updates without permission - $exception")
|
||||
}
|
||||
}
|
||||
|
||||
override fun onLocationChanged(location: Location) = publish(location)
|
||||
|
||||
override fun onProviderDisabled(provider: String) = refreshWaitingState()
|
||||
|
||||
override fun onProviderEnabled(provider: String) = refreshWaitingState()
|
||||
|
||||
private fun publish(location: Location) {
|
||||
_state.value = LocationState.Fix(
|
||||
LocationFix(
|
||||
latitude = location.latitude,
|
||||
longitude = location.longitude,
|
||||
altitudeMeters = location.altitude.takeIf { location.hasAltitude() } ?: 0.0,
|
||||
// Providers that cannot report accuracy use -1; treat that as
|
||||
// useless rather than perfect.
|
||||
accuracyMeters = location.accuracy.takeIf { location.hasAccuracy() } ?: Float.MAX_VALUE,
|
||||
epochMs = location.time.takeIf { it > 0L } ?: System.currentTimeMillis()
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun initialState(): LocationState {
|
||||
val request = describeProvider()
|
||||
return LocationState.Waiting(
|
||||
permissionGranted = request?.permissionGranted ?: false,
|
||||
providerEnabled = request?.providerEnabled ?: false
|
||||
)
|
||||
}
|
||||
|
||||
private fun refreshWaitingState() {
|
||||
if (_state.value is LocationState.Fix) return
|
||||
_state.value = initialState()
|
||||
}
|
||||
|
||||
private fun describeProvider(): ProviderRequest? {
|
||||
if (!permissionGranted()) {
|
||||
return ProviderRequest(LocationManager.GPS_PROVIDER, false, false)
|
||||
}
|
||||
val enabled = LocationManagerCompat.isLocationEnabled(locationManager)
|
||||
val provider = when {
|
||||
locationManager.allProviders.contains(LocationManager.GPS_PROVIDER) -> LocationManager.GPS_PROVIDER
|
||||
locationManager.allProviders.contains(LocationManager.NETWORK_PROVIDER) -> LocationManager.NETWORK_PROVIDER
|
||||
else -> null
|
||||
} ?: return null
|
||||
return ProviderRequest(provider, true, enabled)
|
||||
}
|
||||
|
||||
private data class ProviderRequest(
|
||||
val provider: String,
|
||||
val permissionGranted: Boolean,
|
||||
val providerEnabled: Boolean
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,369 @@
|
||||
/*
|
||||
* 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
|
||||
+30
-9
@@ -35,10 +35,11 @@ import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.util.TimeZone
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
class SatelliteRepo(
|
||||
private val dispatcher: CoroutineDispatcher,
|
||||
@@ -64,22 +65,33 @@ class SatelliteRepo(
|
||||
|
||||
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) {
|
||||
combine(
|
||||
settingsRepo.selectedIds,
|
||||
settingsRepo.stationPosition
|
||||
) { selectedIds, _ -> selectedIds }
|
||||
settingsRepo.stationPosition,
|
||||
// Recalculate passes when the UTC display toggle changes, so an existing
|
||||
// AOS time window is reapplied in the newly selected timezone.
|
||||
settingsRepo.otherSettings.map { it.stateOfUtc }.distinctUntilChanged()
|
||||
) { selectedIds, _, _ -> selectedIds }
|
||||
.collect { selectedIds ->
|
||||
_satellites.update { localStorage.getEntriesWithIds(selectedIds) }
|
||||
val settings = settingsRepo.passesSettings.value
|
||||
val now = System.currentTimeMillis()
|
||||
// 历史过境: 回看窗口 hoursBefore 小时, 过境列表从更早时间开始计算.
|
||||
calculatePasses(
|
||||
time = System.currentTimeMillis(),
|
||||
hoursAhead = settings.hoursAhead,
|
||||
time = now - settings.hoursBefore * 60L * 60L * 1000L,
|
||||
hoursAhead = settings.hoursBefore + settings.hoursAhead,
|
||||
minElevation = settings.minElevation,
|
||||
aosStartMinute = settings.aosStartMinute,
|
||||
aosEndMinute = settings.aosEndMinute,
|
||||
invertAosTimeWindow = settings.invertAosTimeWindow,
|
||||
modes = settingsRepo.selectedSatModes.value
|
||||
modes = settings.selectedModes
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -164,7 +176,7 @@ class SatelliteRepo(
|
||||
}
|
||||
}
|
||||
newPasses.sortBy { it.aosTime }
|
||||
delay(1000.milliseconds) // Simulate loading time for better UX
|
||||
delay(1000) // Simulate loading time for better UX
|
||||
_passes.update { newPasses }
|
||||
}
|
||||
_isCalculating.value = false
|
||||
@@ -176,13 +188,22 @@ class SatelliteRepo(
|
||||
aosEndMinute: Int,
|
||||
invertAosTimeWindow: Boolean
|
||||
): Boolean {
|
||||
val offsetMillis = TimeZone.getDefault().getOffset(aosTime).toLong()
|
||||
// Follow the UTC display toggle: with UTC enabled the window is interpreted
|
||||
// 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 aosMinute = (localMillis / 60_000L).toInt()
|
||||
val inRange = if (aosStartMinute <= aosEndMinute) {
|
||||
aosMinute in aosStartMinute..aosEndMinute
|
||||
} else {
|
||||
aosMinute !in (aosEndMinute + 1)..<aosStartMinute
|
||||
aosMinute >= aosStartMinute || aosMinute <= aosEndMinute
|
||||
}
|
||||
return if (invertAosTimeWindow) !inRange else inRange
|
||||
}
|
||||
|
||||
+74
-19
@@ -38,17 +38,20 @@ class SelectionRepo(
|
||||
) : ISelectionRepo {
|
||||
|
||||
private val currentItems = MutableStateFlow<List<SatItem>>(emptyList())
|
||||
private val currentTypes = MutableStateFlow(settingsRepo.selectedTypes.value)
|
||||
private val currentQuery = MutableStateFlow("")
|
||||
|
||||
// Resolve sat IDs once when modes change, then filter items reactively.
|
||||
// Resolve type IDs once when types change, then filter items reactively.
|
||||
// The HashSet gives O(1) catnum lookups instead of O(n) with a List.
|
||||
// Directly observe settingsRepo.selectedSatModes to ensure real-time sync across screens.
|
||||
private val itemsWithModes = settingsRepo.selectedSatModes.flatMapLatest { list: List<String> ->
|
||||
val catnumSet: Set<Int>? = if (list.isEmpty()) {
|
||||
// The three virtual types ("AMSAT Live FM", "AMSAT Live Linear", "Live SSTV")
|
||||
// are resolved from hardcoded FM/Linear catnum sets (Sources) or live
|
||||
// SSTV transponder data instead of SharedPreferences.
|
||||
private val itemsWithTypes = currentTypes.flatMapLatest { types: List<String> ->
|
||||
val catnumSet: Set<Int>? = if (types.isEmpty()) {
|
||||
null // null = no filtering
|
||||
} else {
|
||||
val ids = localSource.getIdsWithModes(list)
|
||||
if (ids.isEmpty()) null else ids.toHashSet()
|
||||
val ids = resolveTypeIds(types)
|
||||
if (ids.isEmpty()) emptySet() else ids.toHashSet()
|
||||
}
|
||||
currentItems.map { items ->
|
||||
if (catnumSet == null) items else items.filter { it.catnum in catnumSet }
|
||||
@@ -56,18 +59,52 @@ class SelectionRepo(
|
||||
}
|
||||
|
||||
private val itemsWithQuery = currentQuery.flatMapLatest { query ->
|
||||
itemsWithModes.map { items ->
|
||||
filterByQuery(items, query).sortedWith(
|
||||
compareByDescending<SatItem> { it.isSelected }
|
||||
.thenBy { it.name }
|
||||
.thenBy { it.catnum }
|
||||
)
|
||||
}
|
||||
itemsWithTypes.map { items -> filterByQuery(items, query) }
|
||||
}
|
||||
|
||||
override fun getCurrentModes() = settingsRepo.selectedSatModes.value
|
||||
override fun getCurrentTypes() = currentTypes.value
|
||||
|
||||
override fun getModesList() = Sources.satelliteModes
|
||||
/**
|
||||
* Resolves a list of type names to satellite catnums. The three virtual
|
||||
* transponder/activity types are resolved from hardcoded FM/Linear lists
|
||||
* (Sources.amSatFmCatnums / amSatLinearCatnums) or local SSTV transponder
|
||||
* data; all other types come from the per-type ID lists persisted by
|
||||
* [ISettingsRepo].
|
||||
*/
|
||||
private suspend fun resolveTypeIds(types: List<String>): List<Int> {
|
||||
val idsSet = mutableSetOf<Int>()
|
||||
types.forEach { type ->
|
||||
when (type) {
|
||||
Sources.virtualTypeNames[0] -> idsSet.addAll(Sources.amSatFmCatnums)
|
||||
Sources.virtualTypeNames[1] -> idsSet.addAll(Sources.amSatLinearCatnums)
|
||||
// Live SSTV: mode=SSTV transponder records whose service class
|
||||
// is "Amateur", excluding launcher debris / rocket bodies
|
||||
// (names ending in "R/B" or "DEB" — e.g. Ariane 6 R/B) that
|
||||
// carry an amateur payload transponder but are not satellites.
|
||||
Sources.virtualTypeNames[2] -> {
|
||||
val sstvIds = localSource.getIdsWithModesAndAmateur(listOf("SSTV")).toSet()
|
||||
val inOrbitNames = currentItems.value.associate { it.catnum to it.name }
|
||||
val filtered = sstvIds.filter { catnum ->
|
||||
val name = inOrbitNames[catnum]?.uppercase().orEmpty()
|
||||
!name.endsWith(" R/B") && !name.endsWith(" DEB") &&
|
||||
!name.endsWith("R/B") && !name.endsWith("DEB")
|
||||
}
|
||||
idsSet.addAll(filtered)
|
||||
}
|
||||
else -> idsSet.addAll(settingsRepo.getSatelliteTypesIds(listOf(type)))
|
||||
}
|
||||
}
|
||||
return idsSet.toList()
|
||||
}
|
||||
|
||||
override fun getTypesList() = buildList {
|
||||
// 三个转发器/活动虚拟类型排在最前.
|
||||
addAll(Sources.virtualTypeNames)
|
||||
// 所有真实 TLE 源类型均可选(含 "All" = CelesTrak active);只排除
|
||||
// "Other"(空 URL 占位,无数据)。不要用 removeAt(0) —— 字母序第一个
|
||||
// 是 "All"/"ARISS" 这类真实源,删掉会让用户勾不到它们。
|
||||
addAll(Sources.satelliteDataUrls.keys.sorted().filterNot { it == "Other" })
|
||||
}
|
||||
|
||||
override suspend fun getEntriesFlow() = withContext(dispatcher) {
|
||||
val selectedIds = settingsRepo.selectedIds.value.toHashSet()
|
||||
@@ -77,8 +114,9 @@ class SelectionRepo(
|
||||
return@withContext itemsWithQuery
|
||||
}
|
||||
|
||||
override suspend fun setModes(modes: List<String>) {
|
||||
settingsRepo.setSelectedSatModes(modes)
|
||||
override suspend fun setTypes(types: List<String>) {
|
||||
currentTypes.value = types
|
||||
settingsRepo.setSelectedTypes(types)
|
||||
}
|
||||
|
||||
override suspend fun setQuery(query: String) {
|
||||
@@ -114,12 +152,29 @@ class SelectionRepo(
|
||||
/**
|
||||
* Filters items by query. Uses toIntOrNull() instead of exception-based flow,
|
||||
* 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> {
|
||||
if (query.isBlank()) return items
|
||||
val catnum = query.toIntOrNull()
|
||||
if (catnum != null) return items.filter { it.catnum == catnum }
|
||||
val lowerQuery = query.lowercase()
|
||||
return items.filter { it.name.lowercase().contains(lowerQuery) }
|
||||
val tokens = query.split(' ')
|
||||
.map { normalizeForSearch(it) }
|
||||
.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,6 +27,7 @@ import android.view.Display
|
||||
import android.view.Surface
|
||||
import com.rtbishop.look4sat.core.domain.predict.GeoPos
|
||||
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 kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
@@ -41,7 +42,12 @@ class SensorsRepo(
|
||||
) : ISensorsRepo, SensorEventListener {
|
||||
|
||||
private val _sensorData = MutableStateFlow(Pair(0f, 0f))
|
||||
private val sensor: Sensor? = sensorManager.getDefaultSensor(Sensor.TYPE_ROTATION_VECTOR)
|
||||
private val rotationSensor: 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 tempMatrix = FloatArray(9)
|
||||
private val orientationValues = FloatArray(3)
|
||||
@@ -50,6 +56,7 @@ class SensorsRepo(
|
||||
private var hasInitialReading = false
|
||||
|
||||
override val sensorData: StateFlow<Pair<Float, Float>> = _sensorData
|
||||
override val compassAccuracy: StateFlow<CompassAccuracy> = _compassAccuracy
|
||||
|
||||
override fun getMagDeclination(geoPos: GeoPos, time: Long): Float {
|
||||
return GeomagneticField(
|
||||
@@ -62,15 +69,41 @@ class SensorsRepo(
|
||||
|
||||
override fun enableSensor() {
|
||||
hasInitialReading = false
|
||||
sensor?.let { sensorManager.registerListener(this, it, SENSOR_RATE_US) }
|
||||
if (rotationSensor == null || magneticSensor == null) {
|
||||
_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 onAccuracyChanged(sensor: Sensor, accuracy: Int) = Unit
|
||||
override fun onAccuracyChanged(sensor: Sensor, accuracy: Int) {
|
||||
if (sensor.type == Sensor.TYPE_MAGNETIC_FIELD) {
|
||||
updateAccuracy(accuracy)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onSensorChanged(event: SensorEvent) {
|
||||
if (event.sensor.type == Sensor.TYPE_ROTATION_VECTOR) handleSensorEvent(event)
|
||||
when (event.sensor.type) {
|
||||
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 {
|
||||
|
||||
+493
-84
@@ -25,14 +25,14 @@ import androidx.core.location.LocationListenerCompat
|
||||
import androidx.core.location.LocationManagerCompat
|
||||
import com.rtbishop.look4sat.core.domain.model.DataSourcesSettings
|
||||
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.PassesSettings
|
||||
import com.rtbishop.look4sat.core.domain.model.RCSettings
|
||||
import com.rtbishop.look4sat.core.domain.model.RadioControlSettings
|
||||
import com.rtbishop.look4sat.core.domain.model.Constants
|
||||
import com.rtbishop.look4sat.core.domain.model.WavelogSettings
|
||||
import com.rtbishop.look4sat.core.domain.predict.GeoPos
|
||||
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.qthToPosition
|
||||
import com.rtbishop.look4sat.core.domain.utility.round
|
||||
@@ -41,12 +41,13 @@ import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import org.json.JSONObject
|
||||
import java.util.Locale
|
||||
import com.rtbishop.look4sat.core.domain.model.Constants
|
||||
import com.rtbishop.look4sat.core.domain.source.Sources
|
||||
|
||||
class SettingsRepo(
|
||||
private val locationManager: LocationManager,
|
||||
private val preferences: SharedPreferences,
|
||||
override val appVersionName: String,
|
||||
override val appVersionCode: Long,
|
||||
override val appVersionName: String
|
||||
) : ISettingsRepo, LocationListenerCompat {
|
||||
|
||||
private val keyBluetoothRotatorAddress = "bluetoothAddress"
|
||||
@@ -58,6 +59,7 @@ class SettingsRepo(
|
||||
private val keyBluetoothFrequencyFormat = "bluetoothFrequencyFormat"
|
||||
private val keyFilterShowDeepSpace = "filterShowDeepSpace"
|
||||
private val keyFilterHoursAhead = "filterHoursAhead"
|
||||
private val keyFilterHoursBefore = "filterHoursBefore"
|
||||
private val keyFilterMinElevation = "filterMinElevation"
|
||||
private val keyFilterAosStartMinute = "filterAosStartMinute"
|
||||
private val keyFilterAosEndMinute = "filterAosEndMinute"
|
||||
@@ -74,13 +76,18 @@ class SettingsRepo(
|
||||
private val keyFrequencyFormat = "frequencyFormat"
|
||||
private val keyFrequencyOffsetHz = "frequencyOffsetHz"
|
||||
private val keySelectedIds = "selectedIds"
|
||||
private val keySelectedSatModes = "selectedSatModes"
|
||||
private val keySelectedTypes = "selectedTypes"
|
||||
private val keySelectedModes = "selectedModes"
|
||||
private val keyStateOfAutoUpdate = "stateOfAutoUpdate"
|
||||
private val keyStateOfAutoLotwSync = "stateOfAutoLotwSync"
|
||||
private val keyStateOfSensors = "stateOfSensors"
|
||||
private val keyCompassOffsetDegrees = "compassOffsetDegrees"
|
||||
private val keyStateOfSweep = "stateOfSweep"
|
||||
private val keyStateOfUtc = "stateOfUtc"
|
||||
private val keyStateOfLightTheme = "stateOfLightTheme"
|
||||
private val keyStateOfNightMode = "stateOfNightMode"
|
||||
private val keyStateOfMapGrid = "stateOfMapGrid"
|
||||
private val keyStateOfMapFirstCall = "stateOfMapFirstCall"
|
||||
private val keyStationAltitude = "stationAltitude"
|
||||
private val keyStationLatitude = "stationLatitude"
|
||||
private val keyStationLongitude = "stationLongitude"
|
||||
@@ -88,38 +95,32 @@ class SettingsRepo(
|
||||
private val keyStationTimestamp = "stationTimestamp"
|
||||
private val keyUpdateTimestamp = "updateTimestamp"
|
||||
private val keyShouldSeeWarning = "shouldSeeWarning"
|
||||
private val keyShouldSeeWhatsNew = "shouldSeeWhatsNew"
|
||||
private val keyShouldSeeWhatsNew = "shouldSeeWhatsNew_v$appVersionName"
|
||||
private val keySstvMode = "sstvMode"
|
||||
private val keyLowElevation = "lowElevation"
|
||||
private val keyHighElevation = "highElevation"
|
||||
private val keyRadarCompassOffset = "radarCompassOffset"
|
||||
private val keyRadarCompassOffsetElev = "radarCompassOffsetElev"
|
||||
private val keySatellitesUrls = "satellitesUrls"
|
||||
private val keyMapSource = "mapSource"
|
||||
private val keyTiandituKey = "tiandituKey"
|
||||
private val keyUseCustomTle = "useCustomTle"
|
||||
private val keyUseCustomTransceivers = "useCustomTransceivers"
|
||||
private val keyTleUrl = "tleUrl"
|
||||
private val keyTransceiversUrl = "transceiversUrl"
|
||||
private val keySatelliteUrls = "satelliteUrls"
|
||||
private val keyTransceiversUrls = "transceiversUrls"
|
||||
private val keySatellitesUrlsEnabled = "satellitesUrlsEnabled"
|
||||
private val keyTransceiversUrlsEnabled = "transceiversUrlsEnabled"
|
||||
private val keySettingsVersion = "settingsVersion"
|
||||
private val keySatelliteEnabled = "satelliteEnabled"
|
||||
private val keyTransceiversEnabled = "transceiversEnabled"
|
||||
private val keySatnogsTleSourceMigration = "satnogsTleSourceMigration"
|
||||
private val keyAutoTleSourceMigration = "autoTleSourceMigration"
|
||||
private val separatorComma = ","
|
||||
private val separatorUrl = "\n"
|
||||
|
||||
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())
|
||||
}
|
||||
}
|
||||
}
|
||||
private val legacyCelestrakSatnogsUrl =
|
||||
"https://celestrak.org/NORAD/elements/gp.php?GROUP=satnogs&FORMAT=csv"
|
||||
|
||||
//region # Satellites selection settings
|
||||
private val _satelliteSelection = MutableStateFlow(getSelectedIds())
|
||||
private val _satelliteModeSelection = MutableStateFlow(getSelectedSatModes())
|
||||
private val _typesSelection = MutableStateFlow(getSelectedTypes())
|
||||
override val selectedIds: StateFlow<List<Int>> = _satelliteSelection
|
||||
override val selectedSatModes: StateFlow<List<String>> = _satelliteModeSelection
|
||||
override val selectedTypes: StateFlow<List<String>> = _typesSelection
|
||||
|
||||
override fun setSelectedIds(ids: List<Int>) {
|
||||
val selectionString = ids.joinToString(separatorComma)
|
||||
@@ -127,10 +128,10 @@ class SettingsRepo(
|
||||
_satelliteSelection.value = ids
|
||||
}
|
||||
|
||||
override fun setSelectedSatModes(modes: List<String>) {
|
||||
val modesString = modes.joinToString(separatorComma)
|
||||
preferences.edit { putString(keySelectedSatModes, modesString) }
|
||||
_satelliteModeSelection.value = modes
|
||||
override fun setSelectedTypes(types: List<String>) {
|
||||
val typesString = types.joinToString(separatorComma)
|
||||
preferences.edit { putString(keySelectedTypes, typesString) }
|
||||
_typesSelection.value = types
|
||||
}
|
||||
|
||||
private fun getSelectedIds(): List<Int> {
|
||||
@@ -139,41 +140,287 @@ class SettingsRepo(
|
||||
return selectionString.split(separatorComma).map { it.toInt() }
|
||||
}
|
||||
|
||||
private fun getSelectedSatModes(): List<String> {
|
||||
val modesString = preferences.getString(keySelectedSatModes, null)
|
||||
if (modesString.isNullOrEmpty()) return emptyList()
|
||||
return modesString.split(separatorComma).sorted()
|
||||
private fun getSelectedTypes(): List<String> {
|
||||
val typesString = preferences.getString(keySelectedTypes, "Amateur")
|
||||
if (typesString.isNullOrEmpty()) return emptyList()
|
||||
return typesString.split(separatorComma)
|
||||
}
|
||||
//endregion
|
||||
|
||||
//region # Passes filter settings
|
||||
//region # Wavelog worked-grids 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())
|
||||
override val passesSettings: StateFlow<PassesSettings> = _passesSettings
|
||||
|
||||
override fun setPassesSettings(settings: PassesSettings) = preferences.edit {
|
||||
putBoolean(keyFilterShowDeepSpace, settings.showDeepSpace)
|
||||
putInt(keyFilterHoursAhead, settings.hoursAhead)
|
||||
putInt(keyFilterHoursBefore, settings.hoursBefore)
|
||||
putLong(keyFilterMinElevation, settings.minElevation.toRawBits())
|
||||
putInt(keyFilterAosStartMinute, settings.aosStartMinute)
|
||||
putInt(keyFilterAosEndMinute, settings.aosEndMinute)
|
||||
putBoolean(keyFilterAosInvert, settings.invertAosTimeWindow)
|
||||
putString(keySelectedModes, settings.selectedModes.joinToString(separatorComma))
|
||||
_passesSettings.value = settings
|
||||
}
|
||||
|
||||
private fun getPassesSettings(): PassesSettings {
|
||||
val showDeepSpace = preferences.getBoolean(keyFilterShowDeepSpace, true)
|
||||
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 aosStartMinute = preferences.getInt(keyFilterAosStartMinute, 0).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 selectedModesString = preferences.getString(keySelectedModes, null)
|
||||
val selectedModes = selectedModesString?.split(separatorComma)?.sorted() ?: emptyList()
|
||||
return PassesSettings(
|
||||
showDeepSpace,
|
||||
hoursAhead,
|
||||
minElevation,
|
||||
aosStartMinute,
|
||||
aosEndMinute,
|
||||
invertAosTimeWindow
|
||||
invertAosTimeWindow,
|
||||
selectedModes,
|
||||
hoursBefore
|
||||
)
|
||||
}
|
||||
//endregion
|
||||
@@ -221,6 +468,34 @@ class SettingsRepo(
|
||||
return true
|
||||
}
|
||||
|
||||
override fun getCurrentGrid(): String? {
|
||||
val station = _stationPosition.value
|
||||
val now = System.currentTimeMillis()
|
||||
// Only trust a location fix that is both recent (≤ 24 h) and newer than the stored
|
||||
// station, so a stale fix can never override a position the operator just set.
|
||||
val fix = lastKnownGridFix()?.takeIf { now - it.second <= 24 * 3_600_000L }
|
||||
val chosen = if (fix != null && fix.second >= station.timestamp) fix.first else station.qthLocator
|
||||
return chosen.takeIf(String::isNotBlank)
|
||||
}
|
||||
|
||||
/** Last known GPS/NETWORK fix converted to a locator; read-only, null without permission. */
|
||||
private fun lastKnownGridFix(): Pair<String, Long>? {
|
||||
return try {
|
||||
val provider = when {
|
||||
LocationManagerCompat.hasProvider(locationManager, providerGps) -> providerGps
|
||||
LocationManagerCompat.hasProvider(locationManager, providerNet) -> providerNet
|
||||
else -> return null
|
||||
}
|
||||
val location = locationManager.getLastKnownLocation(provider) ?: return null
|
||||
val locator = positionToQth(location.latitude, location.longitude) ?: return null
|
||||
locator to location.time
|
||||
} catch (_: SecurityException) {
|
||||
null
|
||||
} catch (_: IllegalArgumentException) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun getStationPosition(): GeoPos {
|
||||
val latitude = (preferences.getString(keyStationLatitude, null) ?: "0.0").toDouble()
|
||||
val longitude = (preferences.getString(keyStationLongitude, null) ?: "0.0").toDouble()
|
||||
@@ -253,6 +528,27 @@ class SettingsRepo(
|
||||
private val _databaseState = MutableStateFlow(getDatabaseState())
|
||||
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 {
|
||||
putInt(keyNumberOfSatellites, state.numberOfSatellites)
|
||||
@@ -349,21 +645,30 @@ class SettingsRepo(
|
||||
|
||||
override fun updateOtherSettings(transform: (OtherSettings) -> OtherSettings) {
|
||||
_otherSettings.update { current ->
|
||||
val new = transform(current)
|
||||
val new = transform(current).let {
|
||||
it.copy(
|
||||
mapSource = MapSource.normalize(it.mapSource),
|
||||
tiandituKey = it.tiandituKey.trim()
|
||||
)
|
||||
}
|
||||
preferences.edit {
|
||||
putBoolean(keyStateOfAutoUpdate, new.stateOfAutoUpdate)
|
||||
putBoolean(keyStateOfAutoLotwSync, new.stateOfAutoLotwSync)
|
||||
putBoolean(keyStateOfSensors, new.stateOfSensors)
|
||||
putFloat(keyCompassOffsetDegrees, new.compassOffsetDegrees.coerceIn(-180f, 180f))
|
||||
putBoolean(keyStateOfSweep, new.stateOfSweep)
|
||||
putBoolean(keyStateOfUtc, new.stateOfUtc)
|
||||
putBoolean(keyStateOfLightTheme, new.stateOfLightTheme)
|
||||
putBoolean(keyStateOfNightMode, new.stateOfNightMode)
|
||||
putBoolean(keyStateOfMapGrid, new.stateOfMapGrid)
|
||||
putBoolean(keyStateOfMapFirstCall, new.stateOfMapFirstCall)
|
||||
putBoolean(keyShouldSeeWarning, new.shouldSeeWarning)
|
||||
putBoolean(keyShouldSeeWhatsNew, new.shouldSeeWhatsNew)
|
||||
putString(keySstvMode, new.sstvMode)
|
||||
putLong(keyLowElevation, new.lowElevation.toRawBits())
|
||||
putLong(keyHighElevation, new.highElevation.toRawBits())
|
||||
putFloat(keyRadarCompassOffset, new.radarCompassOffset)
|
||||
putFloat(keyRadarCompassOffsetElev, new.radarCompassOffsetElev)
|
||||
putString(keyMapSource, new.mapSource)
|
||||
putString(keyTiandituKey, new.tiandituKey)
|
||||
}
|
||||
new
|
||||
}
|
||||
@@ -371,18 +676,22 @@ class SettingsRepo(
|
||||
|
||||
private fun getOtherSettings(): OtherSettings = OtherSettings(
|
||||
stateOfAutoUpdate = preferences.getBoolean(keyStateOfAutoUpdate, true),
|
||||
stateOfAutoLotwSync = preferences.getBoolean(keyStateOfAutoLotwSync, true),
|
||||
stateOfSensors = preferences.getBoolean(keyStateOfSensors, true),
|
||||
compassOffsetDegrees = preferences.getFloat(keyCompassOffsetDegrees, 0f).coerceIn(-180f, 180f),
|
||||
stateOfSweep = preferences.getBoolean(keyStateOfSweep, true),
|
||||
stateOfUtc = preferences.getBoolean(keyStateOfUtc, false),
|
||||
stateOfLightTheme = preferences.getBoolean(keyStateOfLightTheme, false),
|
||||
stateOfNightMode = preferences.getBoolean(keyStateOfNightMode, false),
|
||||
stateOfMapGrid = preferences.getBoolean(keyStateOfMapGrid, false),
|
||||
stateOfMapFirstCall = preferences.getBoolean(keyStateOfMapFirstCall, false),
|
||||
shouldSeeWarning = preferences.getBoolean(keyShouldSeeWarning, true),
|
||||
shouldSeeWhatsNew = preferences.getBoolean(keyShouldSeeWhatsNew, true),
|
||||
sstvMode = preferences.getString(keySstvMode, null) ?: "Auto",
|
||||
lowElevation = Double.fromBits(preferences.getLong(keyLowElevation, 15.0.toRawBits())),
|
||||
highElevation = Double.fromBits(preferences.getLong(keyHighElevation, 45.0.toRawBits())),
|
||||
radarCompassOffset = preferences.getFloat(keyRadarCompassOffset, 0f),
|
||||
radarCompassOffsetElev = preferences.getFloat(keyRadarCompassOffsetElev, 0f)
|
||||
mapSource = MapSource.normalize(preferences.getString(keyMapSource, null).orEmpty()),
|
||||
tiandituKey = preferences.getString(keyTiandituKey, null).orEmpty().trim()
|
||||
)
|
||||
//endregion
|
||||
|
||||
@@ -399,56 +708,114 @@ class SettingsRepo(
|
||||
transceiversEnabled = alignFlags(settings.transceiversUrls, settings.transceiversEnabled)
|
||||
)
|
||||
preferences.edit {
|
||||
putString(keySatellitesUrls, normalized.satelliteUrls.joinToString(separatorUrl))
|
||||
putString(keySatelliteUrls, normalized.satelliteUrls.joinToString(separatorUrl))
|
||||
putString(keyTransceiversUrls, normalized.transceiversUrls.joinToString(separatorUrl))
|
||||
putString(keySatellitesUrlsEnabled, normalized.satelliteEnabled.joinToString(separatorComma))
|
||||
putString(keyTransceiversUrlsEnabled, normalized.transceiversEnabled.joinToString(separatorComma))
|
||||
putString(keySatelliteEnabled, normalized.satelliteEnabled.joinToString(separatorComma))
|
||||
putString(keyTransceiversEnabled, normalized.transceiversEnabled.joinToString(separatorComma))
|
||||
putBoolean(keySatnogsTleSourceMigration, true)
|
||||
putBoolean(keyAutoTleSourceMigration, true)
|
||||
}
|
||||
_dataSourcesSettings.value = normalized
|
||||
}
|
||||
|
||||
private fun getDataSourcesSettings(): DataSourcesSettings {
|
||||
val (satUrls, satEnabled) = parseSources(
|
||||
preferences.getString(keySatellitesUrls, null),
|
||||
preferences.getString(keySatellitesUrlsEnabled, null),
|
||||
Sources.satelliteDataUrls
|
||||
)
|
||||
val (txUrls, txEnabled) = parseSources(
|
||||
preferences.getString(keyTransceiversUrls, null),
|
||||
preferences.getString(keyTransceiversUrlsEnabled, null),
|
||||
Sources.transceiversDataUrls
|
||||
)
|
||||
return DataSourcesSettings(
|
||||
satelliteUrls = satUrls,
|
||||
transceiversUrls = txUrls,
|
||||
satelliteEnabled = satEnabled,
|
||||
transceiversEnabled = txEnabled
|
||||
)
|
||||
}
|
||||
|
||||
private fun parseSources(
|
||||
storedUrls: String?,
|
||||
storedEnabled: String?,
|
||||
defaults: List<String>
|
||||
): Pair<List<String>, List<Boolean>> {
|
||||
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
|
||||
private fun getDataSourcesSettings(): DataSourcesSettings = DataSourcesSettings(
|
||||
satelliteUrls = getDataSourceUrls(
|
||||
key = keySatelliteUrls,
|
||||
defaultUrls = Sources.satelliteDataUrls.values.filter { it.isNotBlank() },
|
||||
legacyEnabledKey = keyUseCustomTle,
|
||||
legacyUrlKey = keyTleUrl
|
||||
).migrateSatnogsTleSource().migrateAutoTleSources(),
|
||||
transceiversUrls = getDataSourceUrls(
|
||||
key = keyTransceiversUrls,
|
||||
defaultUrls = Sources.transceiversDataUrls.values.filter { it.isNotBlank() },
|
||||
legacyEnabledKey = keyUseCustomTransceivers,
|
||||
legacyUrlKey = keyTransceiversUrl
|
||||
),
|
||||
satelliteEnabled = readEnabledFlags(keySatelliteEnabled),
|
||||
transceiversEnabled = readEnabledFlags(keyTransceiversEnabled)
|
||||
)
|
||||
|
||||
/** Read the persisted per-source enabled flags (empty when never stored). */
|
||||
private fun readEnabledFlags(key: String): List<Boolean> {
|
||||
return preferences.getString(key, null)
|
||||
?.split(separatorComma)
|
||||
?.mapNotNull { it.trim() }
|
||||
?.filter { it == "true" || it == "false" }
|
||||
?.map { it == "true" }
|
||||
?: emptyList()
|
||||
}
|
||||
|
||||
/** Keep the flags positionally aligned with the URL list, defaulting to enabled. */
|
||||
private fun alignFlags(urls: List<String>, flags: List<Boolean>): List<Boolean> {
|
||||
if (flags.size >= urls.size) return flags.take(urls.size)
|
||||
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
|
||||
|
||||
//region # Data sources status
|
||||
@@ -497,16 +864,29 @@ class SettingsRepo(
|
||||
baudRate = preferences.getInt(keyRadioBaudRate, 4800),
|
||||
splitMode = preferences.getBoolean(keyRadioSplitMode, false)
|
||||
)
|
||||
//endregion
|
||||
|
||||
//region # Per-satellite calculator offset settings
|
||||
private val keySatelliteOffsets = "satelliteOffsets"
|
||||
private val keyLegacySatelliteOffsetPrefix = "offset_khz_"
|
||||
|
||||
override fun getSatelliteOffset(catnum: Int): String {
|
||||
val json = preferences.getString(keySatelliteOffsets, "{}") ?: "{}"
|
||||
return try {
|
||||
val stored = try {
|
||||
JSONObject(json).optString(catnum.toString(), "")
|
||||
} 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) {
|
||||
@@ -516,10 +896,39 @@ class SettingsRepo(
|
||||
if (offset.isEmpty()) obj.remove(catnum.toString()) else obj.put(catnum.toString(), offset)
|
||||
obj.toString()
|
||||
} catch (_: Exception) {
|
||||
"""{"$catnum": "$offset"}"""
|
||||
if (offset.isEmpty()) "{}" else """{"$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
|
||||
private val keyAmSatCallsign = "amSatCallsign"
|
||||
|
||||
+124
@@ -0,0 +1,124 @@
|
||||
/*
|
||||
* 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
@@ -0,0 +1,97 @@
|
||||
/*
|
||||
* 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -43,21 +43,13 @@ class LocalSource(private val look4SatDao: Look4SatDao) : ILocalSource {
|
||||
return selectedOrbitalObjects
|
||||
}
|
||||
|
||||
override suspend fun getEntriesEpochs() = look4SatDao.getEntriesEpochs()
|
||||
|
||||
override suspend fun getEntriesNames() = look4SatDao.getEntriesNames()
|
||||
|
||||
override suspend fun renameEntries(names: Map<Int, String>) =
|
||||
names.forEach { (catnum, name) -> look4SatDao.renameEntry(catnum, name) }
|
||||
|
||||
override suspend fun deleteEntriesWithIds(ids: List<Int>) =
|
||||
ids.chunked(999).forEach { idsPart -> look4SatDao.deleteEntriesWithIds(idsPart) }
|
||||
|
||||
override suspend fun insertEntries(entries: List<OrbitalData>) = look4SatDao.insertEntries(entries.toEntity())
|
||||
|
||||
override suspend fun deleteEntries() = look4SatDao.deleteEntries()
|
||||
|
||||
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(
|
||||
this.name, this.epoch, this.meanmo, this.eccn, this.incl,
|
||||
@@ -84,23 +76,27 @@ class LocalSource(private val look4SatDao: Look4SatDao) : ILocalSource {
|
||||
}
|
||||
|
||||
override suspend fun insertRadios(radios: List<SatRadio>, isCustom: Boolean) {
|
||||
look4SatDao.insertRadios(radios.map { radio -> radio.toFramework(isCustom) })
|
||||
look4SatDao.insertRadios(radios.map { it.copy(isCustom = isCustom) }.toFrameworkRadios())
|
||||
}
|
||||
|
||||
override suspend fun deleteRadios() = look4SatDao.deleteRadios()
|
||||
|
||||
override suspend fun deleteManagedRadios() = look4SatDao.deleteManagedRadios()
|
||||
|
||||
private fun DomainRadio.toFramework(isCustom: Boolean = false) = FrameworkRadio(
|
||||
override suspend fun deleteRadios() = look4SatDao.deleteRadios()
|
||||
|
||||
private fun DomainRadio.toFramework() = FrameworkRadio(
|
||||
this.uuid, this.info, this.isAlive, this.downlinkLow, this.downlinkHigh, this.downlinkMode,
|
||||
this.uplinkLow, this.uplinkHigh, this.uplinkMode, this.isInverted, this.catnum, isCustom
|
||||
this.uplinkLow, this.uplinkHigh, this.uplinkMode, this.isInverted, this.catnum,
|
||||
this.service, this.isCustom
|
||||
)
|
||||
|
||||
private fun FrameworkRadio.toDomain() = DomainRadio(
|
||||
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() }
|
||||
|
||||
//endregion
|
||||
|
||||
@@ -54,8 +54,8 @@ class RemoteSource(
|
||||
response.close()
|
||||
return@withContext NetworkResult(code, null)
|
||||
}
|
||||
// Return the body stream directly as the caller is responsible for closing it
|
||||
// That returns the connection to OkHttp's pool
|
||||
// Return the body stream directly as the caller is responsible for closing it.
|
||||
// Closing the stream returns the connection to OkHttp's pool.
|
||||
val body = response.body
|
||||
if (body == null) {
|
||||
response.close()
|
||||
|
||||
@@ -29,7 +29,7 @@ class AddToCalendar(private val context: Context) : IAddToCalendar {
|
||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
setData(CalendarContract.Events.CONTENT_URI)
|
||||
putExtra(CalendarContract.Events.TITLE, name)
|
||||
putExtra(CalendarContract.Events.DESCRIPTION, "Look4Sat Pass")
|
||||
putExtra(CalendarContract.Events.DESCRIPTION, "Look4Sat-BA7OPF Pass")
|
||||
putExtra(CalendarContract.EXTRA_EVENT_BEGIN_TIME, aosTime)
|
||||
putExtra(CalendarContract.EXTRA_EVENT_END_TIME, losTime)
|
||||
}
|
||||
|
||||
+18
@@ -1,3 +1,21 @@
|
||||
/*
|
||||
* Look4Sat-BA7OPF. Amateur radio satellite tracker and pass predictor.
|
||||
* Copyright (C) 2026 BA7OPF.
|
||||
* Based on Look4Sat by Arty Bishop and contributors.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.rtbishop.look4sat.core.data.framework
|
||||
|
||||
import org.junit.Assert.assertArrayEquals
|
||||
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
/*
|
||||
* Look4Sat-BA7OPF. Amateur radio satellite tracker and pass predictor.
|
||||
* Copyright (C) 2026 BA7OPF.
|
||||
* Based on Look4Sat by Arty Bishop and contributors.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.rtbishop.look4sat.core.data.framework
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
|
||||
class IcomCivProtocolTest {
|
||||
|
||||
private fun bytes(vararg values: Int) = ByteArray(values.size) { values[it].toByte() }
|
||||
|
||||
@Test
|
||||
fun parseFreqModePayload_ic705ReadFreqReply() {
|
||||
// Reply captured from an IC-705 to CMD 0x03: frequency only, no mode byte
|
||||
val reply = bytes(0xFE, 0xFE, 0xE0, 0xA4, 0x03, 0x60, 0x74, 0x95, 0x45, 0x01, 0xFD)
|
||||
val response = IcomCivProtocol.parseResponse(reply, IcomCivProtocol.CMD_READ_FREQ)
|
||||
assertNotNull(response)
|
||||
assertEquals(145957460L to "", IcomCivProtocol.parseFreqModePayload(response!!.payload))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parseFreqModePayload_withModeByte() {
|
||||
assertEquals(
|
||||
435611000L to "USB",
|
||||
IcomCivProtocol.parseFreqModePayload(bytes(0x00, 0x10, 0x61, 0x35, 0x04, 0x01, 0x01))
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parseFreqModePayload_tooShort() {
|
||||
assertNull(IcomCivProtocol.parseFreqModePayload(bytes(0x60, 0x74, 0x95, 0x45)))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
/*
|
||||
* 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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
/*
|
||||
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.rtbishop.look4sat.core.data.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
@@ -0,0 +1,189 @@
|
||||
/*
|
||||
* 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))
|
||||
+95
-172
@@ -19,6 +19,7 @@ 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.GridQso
|
||||
import com.rtbishop.look4sat.core.domain.model.OtherSettings
|
||||
import com.rtbishop.look4sat.core.domain.model.PassesSettings
|
||||
import com.rtbishop.look4sat.core.domain.model.RCSettings
|
||||
@@ -32,6 +33,7 @@ import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
|
||||
import com.rtbishop.look4sat.core.domain.source.ILocalSource
|
||||
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 kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
@@ -42,7 +44,6 @@ import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import java.io.InputStream
|
||||
import java.time.LocalDate
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class DatabaseRepoTest {
|
||||
@@ -50,10 +51,6 @@ class DatabaseRepoTest {
|
||||
private val dispatcher = StandardTestDispatcher()
|
||||
private val dataParser = DataParser(dispatcher)
|
||||
|
||||
// fixtures carry a current epoch: stale entries are pruned on every remote update
|
||||
private val todayEpoch = LocalDate.now().let { "%02d%03d".format(it.year % 100, it.dayOfYear) }
|
||||
private val fresherEpoch = "$todayEpoch.71955234".toDouble()
|
||||
|
||||
@Test
|
||||
fun `manual satellite import parses csv stream from content uri`() = runTest(dispatcher) {
|
||||
val uri = "content://look4sat/import/satellites"
|
||||
@@ -68,6 +65,7 @@ class DatabaseRepoTest {
|
||||
|
||||
assertEquals(1, localSource.insertedEntries.size)
|
||||
assertEquals(25544, localSource.insertedEntries.first().catnum)
|
||||
assertEquals(listOf(25544), settingsRepo.satelliteTypeIdsByType["Other"])
|
||||
assertTrue(settingsRepo.databaseState.value.numberOfSatellites > 0)
|
||||
}
|
||||
|
||||
@@ -105,20 +103,19 @@ class DatabaseRepoTest {
|
||||
repository.updateFromRemote()
|
||||
|
||||
assertTrue(localSource.insertedEntries.any { it.catnum == 25544 })
|
||||
assertEquals(listOf(25544), settingsRepo.satelliteTypeIdsByType["Other"])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `remote update takes the freshest elements and the preferred name`() = runTest(dispatcher) {
|
||||
val primaryUrl = "https://example.com/primary.txt"
|
||||
val secondaryUrl = "https://example.com/secondary.txt"
|
||||
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[primaryUrl] = { validTleStream() }
|
||||
networkStreams[secondaryUrl] = { fresherTleStream() }
|
||||
networkStreams[satnogsUrl] = { jamxTleStream() }
|
||||
}
|
||||
val settingsRepo = FakeSettingsRepo(
|
||||
dataSources = DataSourcesSettings(
|
||||
satelliteUrls = listOf(primaryUrl, secondaryUrl),
|
||||
satelliteUrls = listOf(satnogsUrl),
|
||||
transceiversUrls = emptyList()
|
||||
)
|
||||
)
|
||||
@@ -126,167 +123,59 @@ class DatabaseRepoTest {
|
||||
|
||||
repository.updateFromRemote()
|
||||
|
||||
val entry = localSource.insertedEntries.single { it.catnum == 25544 }
|
||||
assertEquals("ISS (ZARYA)", entry.name)
|
||||
assertEquals(fresherEpoch, entry.epoch, 1e-8)
|
||||
assertTrue(localSource.insertedEntries.any { it.name == "JAMX-0825b" && it.catnum == 98248 })
|
||||
assertEquals(listOf(98248), settingsRepo.satelliteTypeIdsByType["SatNOGS"])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `manual satellite import does not overwrite fresher data`() = runTest(dispatcher) {
|
||||
val freshUri = "content://look4sat/import/fresh"
|
||||
val staleUri = "content://look4sat/import/stale"
|
||||
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 {
|
||||
fileStreams[freshUri] = { fresherTleStream() }
|
||||
fileStreams[staleUri] = { validTleStream() }
|
||||
}
|
||||
val settingsRepo = FakeSettingsRepo()
|
||||
val repository = DatabaseRepo(dispatcher, dataParser, localSource, remoteSource, settingsRepo)
|
||||
|
||||
assertEquals(1, repository.updateTLEFromFile(freshUri))
|
||||
assertEquals(1, repository.updateTLEFromFile(staleUri))
|
||||
assertEquals(fresherEpoch, localSource.insertedEntries.single().epoch, 1e-8)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `remote update 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
|
||||
fun `remote update renames satellites even when their elements are not newer`() = runTest(dispatcher) {
|
||||
val preferredUrl = "https://example.com/preferred.txt"
|
||||
val freshUrl = "https://example.com/fresh.txt"
|
||||
val localSource = FakeLocalSource()
|
||||
val remoteSource = FakeRemoteSource().apply {
|
||||
networkStreams[freshUrl] = { fresherTleStream() }
|
||||
networkStreams[allUrl] = { validCsvStream() }
|
||||
}
|
||||
val settingsRepo = FakeSettingsRepo(
|
||||
dataSources = DataSourcesSettings(
|
||||
satelliteUrls = listOf(freshUrl),
|
||||
satelliteUrls = listOf(allUrl),
|
||||
transceiversUrls = emptyList()
|
||||
)
|
||||
)
|
||||
val repository = DatabaseRepo(dispatcher, dataParser, localSource, remoteSource, settingsRepo)
|
||||
repository.updateFromRemote()
|
||||
assertEquals("ISS", localSource.insertedEntries.single().name)
|
||||
|
||||
// the user moves a source with older elements, but a better name, to the top of the list
|
||||
remoteSource.networkStreams[preferredUrl] = { validTleStream() }
|
||||
settingsRepo.dataSourcesSettings.value = DataSourcesSettings(
|
||||
satelliteUrls = listOf(preferredUrl, freshUrl),
|
||||
transceiversUrls = emptyList()
|
||||
)
|
||||
repository.updateFromRemote()
|
||||
|
||||
val entry = localSource.insertedEntries.single()
|
||||
assertEquals("ISS (ZARYA)", entry.name)
|
||||
assertEquals(fresherEpoch, entry.epoch, 1e-8)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `failed remote update records the attempt without claiming success`() = runTest(dispatcher) {
|
||||
val failingUrl = "https://example.com/offline.txt"
|
||||
val localSource = FakeLocalSource()
|
||||
val remoteSource = FakeRemoteSource()
|
||||
val settingsRepo = FakeSettingsRepo(
|
||||
dataSources = DataSourcesSettings(
|
||||
satelliteUrls = listOf(failingUrl),
|
||||
transceiversUrls = emptyList()
|
||||
)
|
||||
)
|
||||
settingsRepo.databaseState.value = DatabaseState(0, 0, 1_000L)
|
||||
val repository = DatabaseRepo(dispatcher, dataParser, localSource, remoteSource, settingsRepo)
|
||||
repository.updateFromRemote()
|
||||
|
||||
// the update timestamp stays put so the data is not presented as fresh, while the recorded
|
||||
// attempt keeps the next app launch from hammering a source that is already failing
|
||||
assertEquals(1_000L, settingsRepo.databaseState.value.updateTimestamp)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `remote update keeps the transceivers imported from a file`() = runTest(dispatcher) {
|
||||
val radioUri = "content://imported.json"
|
||||
val radioUrl = "https://example.com/transmitters.json"
|
||||
val localSource = FakeLocalSource()
|
||||
val remoteSource = FakeRemoteSource().apply {
|
||||
fileStreams[radioUri] = { customRadioStream() }
|
||||
networkStreams[radioUrl] = { singleRadioStream() }
|
||||
}
|
||||
val settingsRepo = FakeSettingsRepo(
|
||||
dataSources = DataSourcesSettings(
|
||||
satelliteUrls = emptyList(),
|
||||
transceiversUrls = listOf(radioUrl)
|
||||
)
|
||||
)
|
||||
val repository = DatabaseRepo(dispatcher, dataParser, localSource, remoteSource, settingsRepo)
|
||||
repository.updateTransceiversFromFile(radioUri)
|
||||
repository.updateFromRemote()
|
||||
|
||||
// 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())
|
||||
assertEquals(listOf(25544), settingsRepo.satelliteTypeIdsByType["All"])
|
||||
}
|
||||
|
||||
private fun validCsvStream(): InputStream = """
|
||||
OBJECT_NAME,OBJECT_ID,EPOCH,MEAN_MOTION,ECCENTRICITY,INCLINATION,RA_OF_ASC_NODE,ARG_OF_PERICENTER,MEAN_ANOMALY,EPHEMERIS_TYPE,CLASSIFICATION_TYPE,NORAD_CAT_ID,ELEMENT_SET_NO,REV_AT_EPOCH,BSTAR,MEAN_MOTION_DOT,MEAN_MOTION_DDOT
|
||||
ISS (ZARYA),1998-067A,${LocalDate.now()}T12:28:09.322176,15.48582035,.0004694,51.6447,309.4881,203.6966,299.8876,0,U,25544,999,31220,.31985E-4,.1288E-4,0
|
||||
ISS (ZARYA),1998-067A,2021-11-16T12:28:09.322176,15.48582035,.0004694,51.6447,309.4881,203.6966,299.8876,0,U,25544,999,31220,.31985E-4,.1288E-4,0
|
||||
""".trimIndent().byteInputStream()
|
||||
|
||||
private fun validTleStream(): InputStream = """
|
||||
ISS (ZARYA)
|
||||
1 25544U 98067A $todayEpoch.51955234 .00001288 00000+0 31985-4 0 9990
|
||||
1 25544U 98067A 21320.51955234 .00001288 00000+0 31985-4 0 9990
|
||||
2 25544 51.6447 309.4881 0004694 203.6966 299.8876 15.48582035312205
|
||||
""".trimIndent().byteInputStream()
|
||||
|
||||
private fun fresherTleStream(): InputStream = """
|
||||
ISS
|
||||
1 25544U 98067A $todayEpoch.71955234 .00001288 00000+0 31985-4 0 9990
|
||||
2 25544 51.6447 309.4881 0004694 203.6966 299.8876 15.48582035312205
|
||||
""".trimIndent().byteInputStream()
|
||||
|
||||
private fun twoRadiosStream(): InputStream = """
|
||||
[$radioAlive,{"uuid":"uuid-retired","description":"Retired","alive":true,"downlink_low":437800000,
|
||||
"mode":"FM","invert":false,"norad_cat_id":25544}]
|
||||
""".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}]
|
||||
private fun jamxTleStream(): InputStream = """
|
||||
JAMX-0825b
|
||||
1 98248U 26237.16675926 .00015724 00000-0 97477-3 0 00013
|
||||
2 98248 097.5373 310.9694 0011309 278.1232 340.7230 15.09766181000012
|
||||
""".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 {
|
||||
val fileStreams: MutableMap<String, () -> InputStream> = mutableMapOf()
|
||||
val networkStreams: MutableMap<String, () -> InputStream> = mutableMapOf()
|
||||
|
||||
override suspend fun getFileStream(uri: String): InputStream? = fileStreams[uri]?.invoke()
|
||||
|
||||
override suspend fun getNetworkStream(url: String): NetworkResult {
|
||||
val stream = networkStreams[url]?.invoke()
|
||||
return if (stream != null) NetworkResult(200, stream) else NetworkResult(404, null)
|
||||
}
|
||||
override suspend fun getNetworkStream(url: String): NetworkResult =
|
||||
networkStreams[url]?.let { NetworkResult(200, it()) } ?: NetworkResult(404, null)
|
||||
|
||||
override suspend fun getAmSatCatalog(): String? = null
|
||||
|
||||
@@ -297,30 +186,15 @@ private class FakeRemoteSource : IRemoteSource {
|
||||
|
||||
private class FakeLocalSource : ILocalSource {
|
||||
val insertedEntries = mutableListOf<OrbitalData>()
|
||||
val insertedRadios = mutableListOf<SatRadio>()
|
||||
private val customRadios = mutableListOf<String>()
|
||||
private val insertedRadios = mutableListOf<SatRadio>()
|
||||
|
||||
override suspend fun getEntriesTotal(): Int = insertedEntries.size
|
||||
|
||||
override suspend fun getEntriesList(): List<SatItem> = emptyList()
|
||||
override suspend fun getEntriesList(): List<SatItem> =
|
||||
insertedEntries.map { SatItem(it.catnum, it.name) }
|
||||
|
||||
override suspend fun getEntriesWithIds(ids: List<Int>): List<OrbitalObject> = emptyList()
|
||||
|
||||
override suspend fun getEntriesEpochs(): Map<Int, Double> =
|
||||
insertedEntries.associate { entry -> entry.catnum to entry.epoch }
|
||||
|
||||
override suspend fun getEntriesNames(): Map<Int, String> =
|
||||
insertedEntries.associate { entry -> entry.catnum to entry.name }
|
||||
|
||||
override suspend fun renameEntries(names: Map<Int, String>) = names.forEach { (catnum, name) ->
|
||||
val index = insertedEntries.indexOfFirst { entry -> entry.catnum == catnum }
|
||||
if (index >= 0) insertedEntries[index] = insertedEntries[index].copy(name = name)
|
||||
}
|
||||
|
||||
override suspend fun deleteEntriesWithIds(ids: List<Int>) {
|
||||
insertedEntries.removeAll { entry -> entry.catnum in ids }
|
||||
}
|
||||
|
||||
override suspend fun insertEntries(entries: List<OrbitalData>) {
|
||||
insertedEntries += entries
|
||||
}
|
||||
@@ -330,39 +204,36 @@ private class FakeLocalSource : ILocalSource {
|
||||
}
|
||||
|
||||
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 getRadiosWithId(id: Int): List<SatRadio> = emptyList()
|
||||
|
||||
override suspend fun insertRadios(radios: List<SatRadio>, isCustom: Boolean) {
|
||||
insertedRadios.removeAll { stored -> radios.any { radio -> radio.uuid == stored.uuid } }
|
||||
insertedRadios += radios
|
||||
if (isCustom) customRadios += radios.map { radio -> radio.uuid }
|
||||
else customRadios -= radios.map { radio -> radio.uuid }.toSet()
|
||||
insertedRadios += radios.map { it.copy(isCustom = isCustom) }
|
||||
}
|
||||
|
||||
override suspend fun deleteManagedRadios() {
|
||||
insertedRadios.removeAll { !it.isCustom }
|
||||
}
|
||||
|
||||
override suspend fun deleteRadios() {
|
||||
insertedRadios.clear()
|
||||
customRadios.clear()
|
||||
}
|
||||
|
||||
override suspend fun deleteManagedRadios() {
|
||||
insertedRadios.removeAll { radio -> radio.uuid !in customRadios }
|
||||
}
|
||||
}
|
||||
|
||||
private class FakeSettingsRepo(dataSources: DataSourcesSettings = defaultDataSourcesSettings()) : ISettingsRepo {
|
||||
|
||||
override val appVersionName: String = "test"
|
||||
override val appVersionCode: Long = 1L
|
||||
|
||||
override val selectedIds: StateFlow<List<Int>> = MutableStateFlow(emptyList())
|
||||
|
||||
override val selectedSatModes: StateFlow<List<String>> = MutableStateFlow(emptyList())
|
||||
override val selectedTypes: StateFlow<List<String>> = MutableStateFlow(emptyList())
|
||||
|
||||
override val passesSettings: StateFlow<PassesSettings> = MutableStateFlow(
|
||||
PassesSettings(hoursAhead = 24, minElevation = 0.0)
|
||||
PassesSettings(hoursAhead = 24, minElevation = 0.0, selectedModes = emptyList())
|
||||
)
|
||||
|
||||
override val stationPosition: StateFlow<GeoPos> = MutableStateFlow(GeoPos(0.0, 0.0))
|
||||
@@ -374,20 +245,23 @@ private class FakeSettingsRepo(dataSources: DataSourcesSettings = defaultDataSou
|
||||
)
|
||||
|
||||
override val otherSettings: StateFlow<OtherSettings> = MutableStateFlow(
|
||||
OtherSettings(false, false, false, false, false, false, false, false)
|
||||
OtherSettings(
|
||||
false, false, false, false, false, false, false,
|
||||
shouldSeeWarning = false, shouldSeeWhatsNew = false
|
||||
)
|
||||
)
|
||||
|
||||
override val dataSourcesSettings: MutableStateFlow<DataSourcesSettings> = MutableStateFlow(dataSources)
|
||||
|
||||
override val dataSourcesStatus: MutableStateFlow<Map<String, Int>> = MutableStateFlow(emptyMap())
|
||||
|
||||
override val radioControlSettings: StateFlow<RadioControlSettings> = MutableStateFlow(
|
||||
RadioControlSettings(false, RadioControlSettings.MODEL_YAESU_FT817, "", "", "", "", 9600)
|
||||
)
|
||||
|
||||
val satelliteTypeIdsByType = mutableMapOf<String, List<Int>>()
|
||||
|
||||
override fun setSelectedIds(ids: List<Int>) = Unit
|
||||
|
||||
override fun setSelectedSatModes(modes: List<String>) = Unit
|
||||
override fun setSelectedTypes(types: List<String>) = Unit
|
||||
|
||||
override fun setPassesSettings(settings: PassesSettings) = Unit
|
||||
|
||||
@@ -397,6 +271,13 @@ private class FakeSettingsRepo(dataSources: DataSourcesSettings = defaultDataSou
|
||||
|
||||
override fun setStationPosition(locator: String): Boolean = true
|
||||
|
||||
override fun getCurrentGrid(): String? = null
|
||||
|
||||
override fun getSatelliteTypesIds(types: List<String>): List<Int> = emptyList()
|
||||
|
||||
override fun setSatelliteTypeIds(type: String, ids: List<Int>) {
|
||||
satelliteTypeIdsByType[type] = ids
|
||||
}
|
||||
|
||||
override fun updateDatabaseState(state: DatabaseState) {
|
||||
databaseState.value = state
|
||||
@@ -410,19 +291,61 @@ private class FakeSettingsRepo(dataSources: DataSourcesSettings = defaultDataSou
|
||||
dataSourcesSettings.value = settings
|
||||
}
|
||||
|
||||
override val dataSourcesStatus: StateFlow<Map<String, Int>> = MutableStateFlow(emptyMap())
|
||||
|
||||
override fun updateDataSourcesStatus(status: Map<String, Int>) {
|
||||
dataSourcesStatus.value = status
|
||||
(dataSourcesStatus as? MutableStateFlow)?.value = status
|
||||
}
|
||||
|
||||
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 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 {
|
||||
|
||||
+427
@@ -0,0 +1,427 @@
|
||||
/*
|
||||
* 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
@@ -0,0 +1,397 @@
|
||||
/*
|
||||
* 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) } }
|
||||
}
|
||||
}
|
||||
}
|
||||
+366
@@ -0,0 +1,366 @@
|
||||
/*
|
||||
* 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 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 `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 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
@@ -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.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.
@@ -0,0 +1,154 @@
|
||||
/*
|
||||
* 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)
|
||||
@@ -0,0 +1,114 @@
|
||||
/*
|
||||
* 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
/*
|
||||
* 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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
/*
|
||||
* 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()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
/*
|
||||
* 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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
/*
|
||||
* 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 }
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
/*
|
||||
* 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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
/*
|
||||
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.rtbishop.look4sat.core.domain.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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
/*
|
||||
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* 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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
/*
|
||||
* 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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
/*
|
||||
* 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()
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
/*
|
||||
* 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
@@ -0,0 +1,48 @@
|
||||
/*
|
||||
* 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
@@ -0,0 +1,148 @@
|
||||
/*
|
||||
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.rtbishop.look4sat.core.domain.logbook
|
||||
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* Satellite names of the ARRL LoTW catalogue and the tracker names that map onto them.
|
||||
*
|
||||
* ARRL's config.tq6 knows every satellite by exactly ONE name ("SO-50", "ARISS", "PO-101",
|
||||
* "BO-102"), while the TLE sources the tracker uses carry their own catalogue names: SatNOGS
|
||||
* lists SO-50 as "SAUDISAT 1C", the ISS as "ISS (ZARYA)", PO-101 as "DIWATA 2B" and AO-123 as
|
||||
* "OBJECT AY". Without this mapping a signed QSO on those satellites is rejected as an unknown
|
||||
* satellite.
|
||||
*
|
||||
* Keys are tracker names normalised by [normalize] (upper case, A-Z0-9 only); values are the
|
||||
* exact config.tq6 satellite names. Generated 2026-09-27 from:
|
||||
* - SatNOGS DB satellites (`name` + `names` alias list) joined to config.tq6 by exact alias,
|
||||
* - live SatNOGS 3LE names and Celestrak amateur names joined to the same NORAD id,
|
||||
* - designator tokens of the config.tq6 satellite descriptions (e.g. CAS-7B -> BO-102),
|
||||
* - the ISS family, which SatNOGS does not list under its ARRL name "ARISS".
|
||||
* Placeholder names ("OBJECT AY" and friends) are deliberately NOT mapped: they are recycled
|
||||
* between objects and would sign the wrong satellite. LoTWConfigTest asserts that every target
|
||||
* here is a real config.tq6 name.
|
||||
*/
|
||||
object LoTWSatelliteAliases {
|
||||
|
||||
val table: Map<String, String> = mapOf(
|
||||
"AMSATOSCAR7" to "AO-7", "AO123" to "AO-123", "AO16" to "AO-16",
|
||||
"AO27" to "AO-27", "AO7" to "AO-7", "AO73" to "AO-73",
|
||||
"AO85" to "AO-85", "AO91" to "AO-91", "AO92" to "AO-92",
|
||||
"ARISSAT1" to "KEDR", "ASRTU1" to "AO-123", "ASRTUFRIENDSHIP" to "AO-123",
|
||||
"BJ2CR" to "AO-123", "BREEZEKMRB" to "RS-44", "BRICSAT2" to "NO-103",
|
||||
"BRICSATP" to "NO-83", "BY701" to "BY70-1", "CAMSAT" to "FO-118",
|
||||
"CAS10" to "HO-119", "CAS2F" to "XW-2F", "CAS2T" to "CAS-2T",
|
||||
"CAS3A" to "XW-2A", "CAS3B" to "XW-2B", "CAS3C" to "XW-2C",
|
||||
"CAS3CBJ1SD" to "XW-2C", "CAS3D" to "XW-2D", "CAS3E" to "XW-2E",
|
||||
"CAS3F" to "XW-2F", "CAS4A" to "CAS-4A", "CAS4B" to "CAS-4B",
|
||||
"CAS5A" to "FO-118", "CAS6" to "TO-108", "CAS7B" to "BO-102",
|
||||
"CAS9" to "HO-113", "CZ11RB" to "CAS-2T", "DELFIC3" to "DO-64",
|
||||
"DIWATA2" to "PO-101", "DIWATA2B" to "PO-101", "DO64" to "DO-64",
|
||||
"DOSAAF85" to "RS-44", "DRUZHBAATURK" to "AO-123", "EO79" to "EO-79",
|
||||
"EO88" to "EO-88", "ESHAIL2" to "QO-100", "EUROPEANOSCAR79" to "EO-79",
|
||||
"EYESAT1" to "AO-27", "EYESATA" to "AO-27", "FENGTAIOSCAR118" to "FO-118",
|
||||
"FO118" to "FO-118", "FO29" to "FO-29", "FOX1" to "AO-85",
|
||||
"FOX1A" to "AO-85", "FOX1B" to "AO-91", "FOX1D" to "AO-92",
|
||||
"FRESCO" to "LO-87", "FUNCUBE1" to "AO-73", "FUNCUBE2" to "UKUBE1",
|
||||
"FUNCUBE3" to "EO-79", "FUNCUBE5" to "EO-88", "GREENCUBE" to "IO-117",
|
||||
"HADESD" to "SO-121", "HADESICM" to "SO-125", "HADESR" to "SO-124",
|
||||
"HAIL2" to "QO-100", "HAMSAT" to "VO-52", "HO113" to "HO-113",
|
||||
"HO119" to "HO-119", "HOPE4" to "HO-119", "HYDRA1" to "SO-121",
|
||||
"INSPIRESAT7" to "INSPR7", "IO117" to "IO-117", "IO86" to "IO-86",
|
||||
"ISS" to "ARISS", "ISSZARYA" to "ARISS", "JAS2" to "FO-29",
|
||||
"JINNIUZUO1" to "TAURUS", "JO97" to "JO-97", "JORDANOSCAR97" to "JO-97",
|
||||
"JY1SAT" to "JO-97", "LAPANA2" to "IO-86", "LILACSAT1" to "LO-90",
|
||||
"LILACSAT2" to "CAS-3H", "LITUANICASAT1" to "LO-78", "LO19" to "LO-19",
|
||||
"LO87" to "LO-87", "LUSAT" to "LO-19", "LUSEX" to "LO-87",
|
||||
"MAYA3" to "MAYA-3", "MAYA4" to "MAYA-4", "MESAT1" to "MO-122",
|
||||
"MESAT1OSCAR" to "MO-122", "MESAT1OSCAR122" to "MO-122", "MIRSAT1" to "MO-112",
|
||||
"MO122" to "MO-122", "MTCUBE2" to "IO-117", "NA1SS" to "ARISS",
|
||||
"NAYIF1" to "EO-88", "NEWSAT1" to "LO-87", "NO103" to "NO-103",
|
||||
"NO104" to "NO-104", "NO44" to "NO-44", "NO83" to "NO-83",
|
||||
"NO84" to "NO-84", "NUSAT1" to "LO-87", "ORARI" to "IO-86",
|
||||
"OSCAR16PACSAT" to "AO-16", "OSCAR19LUSAT" to "LO-19", "OSCAR64" to "DO-64",
|
||||
"OSCAR7" to "AO-7", "PACSAT" to "AO-16", "PARKINSONSAT" to "NO-84",
|
||||
"PCSAT" to "NO-44", "PHILLIPINESOSCAR101" to "PO-101", "PO101" to "PO-101",
|
||||
"PSAT" to "NO-84", "PSAT2" to "NO-104", "QB50P1" to "EO-79",
|
||||
"QO100" to "QO-100", "RADFXSAT" to "AO-91", "RADIOROSTO" to "RS-15",
|
||||
"RS0ISS" to "ARISS", "RS14" to "AO-21", "RS15" to "RS-15",
|
||||
"RS44" to "RS-44", "RS64S" to "AO-123", "SAUDISAT1C" to "SO-50",
|
||||
"SO121" to "SO-121", "SO124" to "SO-124", "SO125" to "SO-125",
|
||||
"SO50" to "SO-50", "SOLUTUSNANOSATTELITE" to "SONATE", "SONATE2" to "SONATE",
|
||||
"TAURUS1" to "TAURUS", "TEVEL21" to "TEV2-1", "TEVEL22" to "TEV2-2",
|
||||
"TEVEL23" to "TEV2-3", "TEVEL24" to "TEV2-4", "TEVEL25" to "TEV2-5",
|
||||
"TEVEL26" to "TEV2-6", "TEVEL27" to "TEV2-7", "TEVEL28" to "TEV2-8",
|
||||
"TEVEL29" to "TEV2-9", "USAT1" to "LO-87", "USNAP1" to "NO-103",
|
||||
"VO52" to "VO-52", "W3ADO" to "NO-44", "XW2A" to "XW-2A",
|
||||
"XW2B" to "XW-2B", "XW2C" to "XW-2C", "XW2D" to "XW-2D",
|
||||
"XW2E" to "XW-2E", "XW2F" to "XW-2F", "XW3" to "HO-113",
|
||||
"XW3CAS9" to "HO-113", "XW4" to "HO-119", "YB0X" to "IO-86",
|
||||
"ZARYA" to "ARISS",
|
||||
|
||||
)
|
||||
|
||||
/**
|
||||
* Tracker name -> ARRL name. An exact table hit wins; otherwise the longest alias key
|
||||
* contained in the name matches, so a name carrying an extra suffix ("SAUDISAT 1C
|
||||
* (SO-50)") still resolves. Keys shorter than 5 characters never match loosely.
|
||||
*/
|
||||
fun lookup(name: String): String? {
|
||||
val normalized = normalize(name)
|
||||
table[normalized]?.let { return it }
|
||||
return table.entries
|
||||
.filter { it.key.length >= 5 && normalized.contains(it.key) }
|
||||
.maxByOrNull { it.key.length }
|
||||
?.value
|
||||
}
|
||||
|
||||
/** Upper case, everything that is not A-Z0-9 removed ("ISS (ZARYA)" -> "ISSZARYA"). */
|
||||
fun normalize(name: String): String = name.uppercase(Locale.US).filter { it.isLetterOrDigit() }
|
||||
|
||||
/**
|
||||
* Resolve any tracker name against the ARRL catalogue: exact official name, then an alias
|
||||
* from [table], then an official name contained in the tracker name. Returns the official
|
||||
* name, or null when the tracker name is not an ARRL satellite at all.
|
||||
*/
|
||||
fun resolve(name: String, officialNames: Collection<String>): String? {
|
||||
val trimmed = name.trim()
|
||||
if (trimmed.isEmpty()) return null
|
||||
val normalized = trimmed.uppercase(Locale.US)
|
||||
officialNames.firstOrNull { it.equals(trimmed, true) }?.let { return it }
|
||||
lookup(trimmed)?.let { alias -> officialNames.firstOrNull { it.equals(alias, true) }?.let { return it } }
|
||||
return officialNames.firstOrNull { it.length > 1 && normalized.contains(it) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Catalogue candidates for a partly typed name. Alias keys are matched too, so the
|
||||
* operator's own spelling ("diwata", "saudisat 1c", "ao9") reaches the ARRL name even
|
||||
* when the two share no characters.
|
||||
*/
|
||||
fun suggestions(query: String, officialNames: Collection<String>, limit: Int = 6): List<String> {
|
||||
val normalized = normalize(query)
|
||||
if (normalized.isEmpty()) return emptyList()
|
||||
val fromAliases = table.entries
|
||||
.filter { it.key.contains(normalized) || normalized.contains(it.key) }
|
||||
.map { it.value }
|
||||
val fromNames = officialNames.filter { normalize(it).contains(normalized) }
|
||||
return (fromAliases + fromNames + listOfNotNull(lookup(query)))
|
||||
.distinct()
|
||||
.filter { candidate -> officialNames.any { it.equals(candidate, true) } }
|
||||
.take(limit)
|
||||
}
|
||||
}
|
||||
+71
@@ -0,0 +1,71 @@
|
||||
/*
|
||||
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.rtbishop.look4sat.core.domain.logbook
|
||||
|
||||
import com.rtbishop.look4sat.core.domain.repository.LoTWProblem
|
||||
|
||||
/**
|
||||
* Wording for the reasons a QSO cannot be signed for LoTW.
|
||||
*
|
||||
* The logbook used to report every one of these as "invalid call/date", which sent the operator
|
||||
* looking at the callsign and date even when the real cause was a missing frequency (no BAND) or
|
||||
* a satellite name ARRL does not know.
|
||||
*/
|
||||
fun LoTWProblem.label(): String = when (this) {
|
||||
LoTWProblem.BAND -> "missing frequency/band"
|
||||
LoTWProblem.SATELLITE -> "satellite name not in ARRL's list"
|
||||
LoTWProblem.MODE -> "mode not accepted by LoTW"
|
||||
LoTWProblem.QSO_DATE -> "date outside the certificate"
|
||||
LoTWProblem.QSO_FUTURE -> "record time is in the future"
|
||||
LoTWProblem.CALLSIGN_MISMATCH -> "MY callsign does not match the certificate"
|
||||
LoTWProblem.INVALID_CONTACT -> "invalid callsign/record"
|
||||
LoTWProblem.STATION_GRID -> "station grid missing"
|
||||
LoTWProblem.STATION_REGION -> "station region missing"
|
||||
LoTWProblem.STATION_ZONE -> "station zones missing"
|
||||
LoTWProblem.STATION_IOTA -> "station IOTA missing"
|
||||
LoTWProblem.TOO_MANY_CONTACTS -> "too many contacts in one batch"
|
||||
LoTWProblem.EMPTY_SELECTION -> "nothing selected"
|
||||
else -> name.lowercase().replace('_', ' ')
|
||||
}
|
||||
|
||||
/**
|
||||
* One-line explanation of why records were left out of an upload, e.g.
|
||||
* "3 QSO(s) can't be uploaded — 2× missing frequency/band, 1× satellite name not in ARRL's list (SAUDISAT 1C)".
|
||||
* Returns an empty string when nothing was skipped.
|
||||
*/
|
||||
fun unavailableUploadSummary(
|
||||
total: Int,
|
||||
reasons: Map<LoTWProblem, Int> = emptyMap(),
|
||||
details: Map<LoTWProblem, String> = emptyMap(),
|
||||
duplicates: Int = 0,
|
||||
incomplete: Int = 0
|
||||
): String {
|
||||
if (total <= 0) return ""
|
||||
val parts = reasons.entries
|
||||
.sortedByDescending { it.value }
|
||||
.map { (problem, count) ->
|
||||
val detail = details[problem]?.takeIf { it.isNotBlank() }?.let { " ($it)" }.orEmpty()
|
||||
"$count× ${problem.label()}$detail"
|
||||
}
|
||||
.toMutableList()
|
||||
if (duplicates > 0) parts += "$duplicates× duplicate of another record in the batch"
|
||||
if (incomplete > 0) parts += "$incomplete× record not completed"
|
||||
val subject = if (total == 1) "1 QSO can't be uploaded" else "$total QSO(s) can't be uploaded"
|
||||
val detail = parts.joinToString(", ").ifBlank { "check the logbook" }
|
||||
return "$subject — $detail"
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
/*
|
||||
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*/
|
||||
package com.rtbishop.look4sat.core.domain.logbook
|
||||
|
||||
/**
|
||||
* The records a resubmit actually re-signs: whatever the operator checked, complete rows only.
|
||||
*
|
||||
* Unlike a normal upload this deliberately keeps already-uploaded and confirmed records —
|
||||
* the point is to send them again under a corrected station location, and LoTW treats an
|
||||
* identical contact (call/band/mode/time/satellite) as an update of the existing record.
|
||||
*/
|
||||
fun resubmitCandidates(records: List<QsoRecord>, selectedIds: Set<Long>): List<QsoRecord> =
|
||||
records.filter { it.id in selectedIds && it.status == QsoStatus.COMPLETE }
|
||||
+239
@@ -0,0 +1,239 @@
|
||||
/*
|
||||
* 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 -> ""
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
/*
|
||||
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*/
|
||||
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,3 +1,21 @@
|
||||
/*
|
||||
* Look4Sat-BA7OPF. Amateur radio satellite tracker and pass predictor.
|
||||
* Copyright (C) 2026 BA7OPF.
|
||||
* Based on Look4Sat by Arty Bishop and contributors.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.rtbishop.look4sat.core.domain.model
|
||||
|
||||
/** One public AMSAT satellite status report submission. */
|
||||
|
||||
@@ -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.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"
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
/*
|
||||
* 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()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
/*
|
||||
* 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
|
||||
)
|
||||
@@ -0,0 +1,31 @@
|
||||
/*
|
||||
* 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,19 +20,23 @@ package com.rtbishop.look4sat.core.domain.model
|
||||
import kotlinx.serialization.SerialName
|
||||
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
|
||||
data class SatRadio(
|
||||
@SerialName("uuid") val uuid: String,
|
||||
@SerialName("description") val info: String = "",
|
||||
@SerialName("alive") val isAlive: Boolean = true,
|
||||
@SerialName("downlink_low") val downlinkLow: Long? = null,
|
||||
@SerialName("downlink_high") val downlinkHigh: Long? = null,
|
||||
@SerialName("mode") val downlinkMode: String? = null,
|
||||
@SerialName("uplink_low") val uplinkLow: Long? = null,
|
||||
@SerialName("uplink_high") val uplinkHigh: Long? = null,
|
||||
@SerialName("uplink_mode") val uplinkMode: String? = null,
|
||||
@SerialName("invert") val isInverted: Boolean = false,
|
||||
@SerialName("norad_cat_id") val catnum: Int? = null
|
||||
@SerialName("description") val info: String,
|
||||
@SerialName("alive") val isAlive: Boolean,
|
||||
@SerialName("downlink_low") val downlinkLow: Long?,
|
||||
@SerialName("downlink_high") val downlinkHigh: Long?,
|
||||
@SerialName("mode") val downlinkMode: String?,
|
||||
@SerialName("uplink_low") val uplinkLow: Long?,
|
||||
@SerialName("uplink_high") val uplinkHigh: Long?,
|
||||
@SerialName("uplink_mode") val uplinkMode: String?,
|
||||
@SerialName("invert") val isInverted: Boolean,
|
||||
@SerialName("norad_cat_id") val catnum: Int?,
|
||||
/** 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,3 +1,21 @@
|
||||
/*
|
||||
* Look4Sat-BA7OPF. Amateur radio satellite tracker and pass predictor.
|
||||
* Copyright (C) 2026 BA7OPF.
|
||||
* Based on Look4Sat by Arty Bishop and contributors.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.rtbishop.look4sat.core.domain.model
|
||||
|
||||
/** One satellite status report (AMSAT site tooltip data) */
|
||||
@@ -20,13 +38,14 @@ data class SatSlot(
|
||||
/** One satellite day (12 two-hour slots) */
|
||||
data class SatDay(
|
||||
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, 5 days of state */
|
||||
/** One satellite, 3 days of state */
|
||||
data class SatStatus(
|
||||
val name: String, // "AO-123_[FM]"
|
||||
val days: List<SatDay> // 5 天(新→旧)
|
||||
val days: List<SatDay> // 3 days (newest first)
|
||||
)
|
||||
|
||||
/** Overall page parse result */
|
||||
|
||||
@@ -29,7 +29,10 @@ data class PassesSettings(
|
||||
val minElevation: Double,
|
||||
val aosStartMinute: Int = 0,
|
||||
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(
|
||||
@@ -58,21 +61,37 @@ data class OtherSettings(
|
||||
val stateOfUtc: Boolean,
|
||||
val stateOfLightTheme: Boolean,
|
||||
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 shouldSeeWhatsNew: Boolean,
|
||||
val sstvMode: String = "Auto",
|
||||
val lowElevation: Double = 15.0,
|
||||
val highElevation: Double = 45.0,
|
||||
val radarCompassOffset: Float = 0f,
|
||||
val radarCompassOffsetElev: Float = 0f
|
||||
/** Independent auto-sync toggle for LoTW confirmed grids (separate from the
|
||||
* ephemeris auto-update; only effective after at least one manual sync). */
|
||||
val stateOfAutoLotwSync: Boolean = true,
|
||||
/** 指南针手动航向偏置(度, -180..180): 雷达方位 = 传感器方位 + 磁偏角 + 该偏置. */
|
||||
val compassOffsetDegrees: Float = 0f,
|
||||
val mapSource: String = MapSource.OSM,
|
||||
val tiandituKey: String = ""
|
||||
)
|
||||
|
||||
/**
|
||||
* Data source URLs (TLE / transceivers) and their per-source enabled flags.
|
||||
* [satelliteEnabled] and [transceiversEnabled] must be positionally aligned with the
|
||||
* corresponding URL list: the flag at index [i] applies to the URL at index [i].
|
||||
* Missing flags are treated as enabled (see [isSatelliteEnabled]/[isTransceiverEnabled]).
|
||||
*/
|
||||
object MapSource {
|
||||
const val OSM = "osm"
|
||||
const val TIANDITU_VECTOR = "tianditu_vector"
|
||||
const val TIANDITU_IMAGE = "tianditu_image"
|
||||
private const val LEGACY_TIANDITU = "tianditu"
|
||||
|
||||
fun normalize(source: String): String = when (source) {
|
||||
OSM, TIANDITU_VECTOR, TIANDITU_IMAGE -> source
|
||||
LEGACY_TIANDITU -> TIANDITU_VECTOR
|
||||
else -> OSM
|
||||
}
|
||||
}
|
||||
data class DataSourcesSettings(
|
||||
val satelliteUrls: List<String>,
|
||||
val transceiversUrls: List<String>,
|
||||
@@ -83,6 +102,20 @@ data class DataSourcesSettings(
|
||||
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(
|
||||
val enabled: Boolean,
|
||||
val radioModel: String,
|
||||
|
||||
+1
-1
@@ -432,7 +432,7 @@ object CelestialComputer {
|
||||
daynum = sunrise + 0.001
|
||||
sunPos = getSunPosition(observer, daynumToMillis(daynum))
|
||||
guard = 0
|
||||
while (sunPos.elevation > -threshold && guard++ < 500) {
|
||||
while (sunPos.elevation >= -threshold && guard++ < 500) {
|
||||
daynum += 0.008
|
||||
sunPos = getSunPosition(observer, daynumToMillis(daynum))
|
||||
}
|
||||
|
||||
+17
-29
@@ -37,42 +37,30 @@ data class OrbitalData(
|
||||
val xno: Double = meanmo * TWO_PI / MIN_PER_DAY
|
||||
val orbitalPeriod: Double = MIN_PER_DAY / meanmo
|
||||
val isDeepSpace: Boolean = orbitalPeriod >= 225.0 // NearEarth (period < 225 min) or DeepSpace (period >= 225 min)
|
||||
|
||||
/** Absolute age of this element set, used to pick the freshest data among several sources. */
|
||||
val epochDaynum: Double = epochToDaynum(epoch)
|
||||
|
||||
fun getObject(): OrbitalObject = if (isDeepSpace) DeepSpaceObject(this) else NearEarthObject(this)
|
||||
|
||||
/** Check if satellite has likely decayed by the given time. */
|
||||
fun hasDecayed(currentTimeMillis: Long): Boolean {
|
||||
if (ndot == 0.0) return false
|
||||
return CelestialComputer.hasDecayed(meanmo, ndot, epochDaynum, timeToDaynum(currentTimeMillis))
|
||||
val currentDaynum = (currentTimeMillis - 315446400000L) / 86400000.0
|
||||
val epochDaynum = epochToDaynum(epoch)
|
||||
return CelestialComputer.hasDecayed(meanmo, ndot, epochDaynum, currentDaynum)
|
||||
}
|
||||
|
||||
companion object {
|
||||
/** Days since 31 Dec 1979, the reference point every daynum in the app is based on. */
|
||||
fun timeToDaynum(timeMillis: Long): Double = (timeMillis - 315446400000L) / 86400000.0
|
||||
private fun epochToDaynum(epoch: Double): Double {
|
||||
var year = kotlin.math.floor(epoch * 1E-3)
|
||||
val day = (epoch * 1E-3 - year) * 1000.0
|
||||
year = if (year < 57) year + 2000 else year + 1900
|
||||
// daynum = days since 31 Dec 1979, Julian date of 31Dec79 = 2444238.5
|
||||
val jan1Jd = julianDateOfYear(year)
|
||||
return jan1Jd + day - 2444238.5
|
||||
}
|
||||
|
||||
/**
|
||||
* Days since 31 Dec 1979 for a TLE style YYDDD.ffffffff epoch. Raw epochs are not
|
||||
* comparable across decades (a 1999 epoch reads as 99xxx, a 2026 one as 26xxx), so
|
||||
* freshness checks must always go through this conversion.
|
||||
*/
|
||||
fun epochToDaynum(epoch: Double): Double {
|
||||
var year = kotlin.math.floor(epoch * 1E-3)
|
||||
val day = (epoch * 1E-3 - year) * 1000.0
|
||||
year = if (year < 57) year + 2000 else year + 1900
|
||||
// daynum = days since 31 Dec 1979, Julian date of 31Dec79 = 2444238.5
|
||||
val jan1Jd = julianDateOfYear(year)
|
||||
return jan1Jd + day - 2444238.5
|
||||
}
|
||||
|
||||
private fun julianDateOfYear(theYear: Double): Double {
|
||||
val aYear = theYear - 1
|
||||
val a = kotlin.math.floor(aYear / 100).toLong()
|
||||
val b = 2 - a + a / 4
|
||||
val i = kotlin.math.floor(365.25 * aYear).toLong()
|
||||
return i + (30.6001 * 14).toLong() + 1720994.5 + b
|
||||
}
|
||||
private fun julianDateOfYear(theYear: Double): Double {
|
||||
val aYear = theYear - 1
|
||||
val a = kotlin.math.floor(aYear / 100).toLong()
|
||||
val b = 2 - a + a / 4
|
||||
val i = kotlin.math.floor(365.25 * aYear).toLong()
|
||||
return i + (30.6001 * 14).toLong() + 1720994.5 + b
|
||||
}
|
||||
}
|
||||
+29
-2
@@ -1,3 +1,21 @@
|
||||
/*
|
||||
* Look4Sat-BA7OPF. Amateur radio satellite tracker and pass predictor.
|
||||
* Copyright (C) 2026 BA7OPF.
|
||||
* Based on Look4Sat by Arty Bishop and contributors.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.rtbishop.look4sat.core.domain.repository
|
||||
|
||||
import com.rtbishop.look4sat.core.domain.model.AmSatReportSubmission
|
||||
@@ -6,8 +24,17 @@ import com.rtbishop.look4sat.core.domain.model.SatStatusPage
|
||||
|
||||
/** AMSAT satellite status data source */
|
||||
interface IAmSatRepository {
|
||||
/** Fetch and parse the AMSAT status page; null on failure */
|
||||
suspend fun fetchStatus(): SatStatusPage?
|
||||
/** Cached AMSAT status page for the current foreground session, if any. */
|
||||
fun getCachedStatus(): 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. */
|
||||
suspend fun submitReport(submission: AmSatReportSubmission): AmSatReportSubmitResult
|
||||
|
||||
+72
@@ -0,0 +1,72 @@
|
||||
/*
|
||||
* 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
@@ -0,0 +1,169 @@
|
||||
/*
|
||||
* 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
@@ -0,0 +1,65 @@
|
||||
/*
|
||||
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.rtbishop.look4sat.core.domain.repository
|
||||
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
/**
|
||||
* A single position fix from the device's location provider, with the
|
||||
* horizontal accuracy reported by the platform.
|
||||
*/
|
||||
data class LocationFix(
|
||||
val latitude: Double,
|
||||
val longitude: Double,
|
||||
val altitudeMeters: Double = 0.0,
|
||||
val accuracyMeters: Float,
|
||||
val epochMs: Long
|
||||
) {
|
||||
/** ARRL VUCC work needs a fix good to the 20 ft (6.1 m) rule. */
|
||||
fun isPreciseEnough(limitMeters: Float): Boolean = accuracyMeters in 0f..limitMeters
|
||||
}
|
||||
|
||||
/** What the live-location source is currently able to give. */
|
||||
sealed interface LocationState {
|
||||
/** No fix yet - either permission is missing or the provider is warming up. */
|
||||
data class Waiting(val permissionGranted: Boolean, val providerEnabled: Boolean) : LocationState
|
||||
|
||||
data class Fix(val location: LocationFix) : LocationState
|
||||
}
|
||||
|
||||
/**
|
||||
* Live device position for field tools (currently the Grid Finder). Kept
|
||||
* separate from [ISettingsRepo.stationPosition] on purpose: collecting fixes
|
||||
* here must never move the user's station, which is what drives every pass
|
||||
* prediction in the app.
|
||||
*
|
||||
* Callers register with [startUpdates] while their screen is alive and MUST
|
||||
* call [stopUpdates] when it goes away.
|
||||
*/
|
||||
interface ILocationRepo {
|
||||
val state: StateFlow<LocationState>
|
||||
|
||||
/** Whether the app currently holds the fine-location permission. */
|
||||
fun hasPermission(): Boolean
|
||||
|
||||
/** Begins delivering fixes; no-op when permission is missing. */
|
||||
fun startUpdates()
|
||||
|
||||
/** Stops delivering fixes and releases the platform listener. */
|
||||
fun stopUpdates()
|
||||
}
|
||||
+33
-2
@@ -16,12 +16,14 @@
|
||||
* 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.IQsoRepository
|
||||
import com.rtbishop.look4sat.core.domain.model.SatRadio
|
||||
import com.rtbishop.look4sat.core.domain.usecase.IAddToCalendar
|
||||
import com.rtbishop.look4sat.core.domain.usecase.IAudioCapture
|
||||
import com.rtbishop.look4sat.core.domain.usecase.ISaveImage
|
||||
import com.rtbishop.look4sat.core.domain.usecase.IShowToast
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
interface IMainContainer {
|
||||
val appScope: CoroutineScope
|
||||
@@ -30,7 +32,18 @@ interface IMainContainer {
|
||||
val satelliteRepo: ISatelliteRepo
|
||||
val databaseRepo: IDatabaseRepo
|
||||
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 locationRepo: ILocationRepo
|
||||
val mutualPassData: StateFlow<MutualPassData>
|
||||
/** Grid Finder → LoTW station page prefill, consumed once by that page. */
|
||||
val pendingLoTWStationGrid: StateFlow<String?>
|
||||
fun setMutualPassData(data: MutualPassData)
|
||||
fun setPendingLoTWStationGrid(grid: String?)
|
||||
fun provideAddToCalendar(): IAddToCalendar
|
||||
fun provideShowToast(): IShowToast
|
||||
fun provideBluetoothReporter(): IReporter
|
||||
@@ -40,9 +53,27 @@ interface IMainContainer {
|
||||
fun provideRxRadioController(): IRadioController
|
||||
fun provideAudioCapture(): IAudioCapture
|
||||
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 {
|
||||
fun getMainContainer(): IMainContainer
|
||||
}
|
||||
+15
@@ -65,4 +65,19 @@ interface ISatelliteRepo {
|
||||
|
||||
/** Fetch radio transceivers for a satellite by its catalog number. */
|
||||
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
|
||||
|
||||
interface ISelectionRepo {
|
||||
fun getCurrentModes(): List<String>
|
||||
fun getModesList(): List<String>
|
||||
fun getCurrentTypes(): List<String>
|
||||
fun getTypesList(): List<String>
|
||||
suspend fun getEntriesFlow(): Flow<List<SatItem>>
|
||||
suspend fun setModes(modes: List<String>)
|
||||
suspend fun setTypes(types: List<String>)
|
||||
suspend fun setQuery(query: String)
|
||||
suspend fun setSelection(selectAll: Boolean)
|
||||
suspend fun setSelection(ids: List<Int>, isTicked: Boolean)
|
||||
|
||||
+11
@@ -20,8 +20,19 @@ package com.rtbishop.look4sat.core.domain.repository
|
||||
import com.rtbishop.look4sat.core.domain.predict.GeoPos
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
/** 指南针校准精度等级, 由磁场传感器 accuracy 事件映射而来. */
|
||||
enum class CompassAccuracy {
|
||||
UNAVAILABLE,
|
||||
UNRELIABLE,
|
||||
LOW,
|
||||
MEDIUM,
|
||||
HIGH
|
||||
}
|
||||
|
||||
interface ISensorsRepo {
|
||||
val sensorData: StateFlow<Pair<Float, Float>>
|
||||
/** 指南针当前校准精度 (磁场传感器 accuracy), 用于校准对话框进度. */
|
||||
val compassAccuracy: StateFlow<CompassAccuracy>
|
||||
fun getMagDeclination(geoPos: GeoPos, time: Long = System.currentTimeMillis()): Float
|
||||
fun enableSensor()
|
||||
fun disableSensor()
|
||||
|
||||
+46
-3
@@ -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.RCSettings
|
||||
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 kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
interface ISettingsRepo {
|
||||
|
||||
val appVersionName: String
|
||||
val appVersionCode: Long
|
||||
|
||||
//region # Satellites selection settings
|
||||
val selectedIds: StateFlow<List<Int>>
|
||||
val selectedSatModes: StateFlow<List<String>>
|
||||
val selectedTypes: StateFlow<List<String>>
|
||||
fun setSelectedIds(ids: List<Int>)
|
||||
fun setSelectedSatModes(modes: List<String>)
|
||||
fun setSelectedTypes(types: List<String>)
|
||||
//endregion
|
||||
|
||||
//region # Passes filter settings
|
||||
@@ -48,10 +48,18 @@ interface ISettingsRepo {
|
||||
fun setStationPosition(latitude: Double, longitude: Double, altitude: Double): Boolean
|
||||
fun setStationPosition(): Boolean
|
||||
fun setStationPosition(locator: String): Boolean
|
||||
/**
|
||||
* Freshest known position grid for the roaming check, read-only: the newer of a fresh
|
||||
* (≤ 24 h) system location fix and the stored station position. Null only when neither
|
||||
* yields a usable locator. Never writes the station position.
|
||||
*/
|
||||
fun getCurrentGrid(): String?
|
||||
//endregion
|
||||
|
||||
//region # Database update settings
|
||||
val databaseState: StateFlow<DatabaseState>
|
||||
fun getSatelliteTypesIds(types: List<String>): List<Int>
|
||||
fun setSatelliteTypeIds(type: String, ids: List<Int>)
|
||||
fun updateDatabaseState(state: DatabaseState)
|
||||
//endregion
|
||||
|
||||
@@ -87,8 +95,43 @@ interface ISettingsRepo {
|
||||
fun setSatelliteOffset(catnum: Int, offset: String)
|
||||
//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
|
||||
fun getAmSatCallsign(): String
|
||||
fun setAmSatCallsign(callsign: String)
|
||||
//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
@@ -0,0 +1,30 @@
|
||||
/*
|
||||
* 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
@@ -0,0 +1,28 @@
|
||||
/*
|
||||
* 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
@@ -0,0 +1,169 @@
|
||||
/*
|
||||
* 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
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
/*
|
||||
* 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 }
|
||||
Loaded 100 of 288 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user