Compare commits
204
Commits
v4.5.4
...
feat/ios-kmp
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9ca122a734 | ||
|
|
904d1ffbea | ||
|
|
9deb517874 | ||
|
|
fa91e89024 | ||
|
|
1a3c94f1e0 | ||
|
|
abb73ffef7 | ||
|
|
cc4f156c83 | ||
|
|
3810e76ea2 | ||
|
|
5ed76dba31 | ||
|
|
78a6f270bf | ||
|
|
8445c17033 | ||
|
|
e315c87f05 | ||
|
|
e27d692e8f | ||
|
|
6c67aa2718 | ||
|
|
cb3ebe7870 | ||
|
|
e2263668ce | ||
|
|
91263674a9 | ||
|
|
9576607fc6 | ||
|
|
00b7b25cc1 | ||
|
|
a3e3932f73 | ||
|
|
3d3db784a3 | ||
|
|
08f9106749 | ||
|
|
6db10b5b72 | ||
|
|
cd70e3654c | ||
|
|
a25f0fe2cf | ||
|
|
b1dce9c518 | ||
|
|
13c5fc9584 | ||
|
|
e8e67b74cd | ||
|
|
758dc6d567 | ||
|
|
4567f46867 | ||
|
|
fe6d0af8b7 | ||
|
|
3a8086eb05 | ||
|
|
e8ad51fd1b | ||
|
|
6859c825d7 | ||
|
|
271488a43e | ||
|
|
321cd8f2fa | ||
|
|
0208a577c4 | ||
|
|
eb66a77cae | ||
|
|
0a67f74369 | ||
|
|
e0900778f0 | ||
|
|
2ff8643988 | ||
|
|
b19c78441c | ||
|
|
262ae45432 | ||
|
|
7ac54f0a37 | ||
|
|
ce68f48765 | ||
|
|
38f939bd49 | ||
|
|
bdc6db5aff | ||
|
|
92499b1cf1 | ||
|
|
828fd0fb6f | ||
|
|
07df22d287 | ||
|
|
ac45ed0efb | ||
|
|
96bbb022e8 | ||
|
|
b6753a4fa6 | ||
|
|
0889a3bd88 | ||
|
|
10c415fabd | ||
|
|
984a139a81 | ||
|
|
4cb03111bc | ||
|
|
23f47d9122 | ||
|
|
5a45aab2b1 | ||
|
|
9367878702 | ||
|
|
8fbc639a82 | ||
|
|
fa73328936 | ||
|
|
50a644f417 | ||
|
|
7a2bbb8701 | ||
|
|
018a3afd2b | ||
|
|
3612e662e7 | ||
|
|
79215e7623 | ||
|
|
8f646d76f9 | ||
|
|
ea125d7db4 | ||
|
|
fdb44af9ff | ||
|
|
f6db55b35c | ||
|
|
1b8f8c46f6 | ||
|
|
9798107d37 | ||
|
|
e3d7238721 | ||
|
|
40ba3fecdf | ||
|
|
57d6f9d7ed | ||
|
|
a654735337 | ||
|
|
a42a5f1f0d | ||
|
|
c547a126ee | ||
|
|
16c746f552 | ||
|
|
8f56ea05ec | ||
|
|
b749733289 | ||
|
|
b4cfb16159 | ||
|
|
7319cf8f5b | ||
|
|
baf2a7022d | ||
|
|
b95a86c97f | ||
|
|
ed1fe66892 | ||
|
|
1e24673632 | ||
|
|
ed0f5678b3 | ||
|
|
aac1fa0da5 | ||
|
|
c7bb253981 | ||
|
|
af96fe1cf0 | ||
|
|
27d41eb2ee | ||
|
|
2bac6655f3 | ||
|
|
2da7127fd3 | ||
|
|
fed9fe188e | ||
|
|
19ca5205fb | ||
|
|
a7a6d70d40 | ||
|
|
d5230b3bc6 | ||
|
|
7d7abeb31e | ||
|
|
828f720955 | ||
|
|
5f1f90067f | ||
|
|
f4e188261d | ||
|
|
e1233dceaa | ||
|
|
09e728fa1b | ||
|
|
c1895506e0 | ||
|
|
066fafbb81 | ||
|
|
aedb3fee19 | ||
|
|
77314e824e | ||
|
|
74902cddce | ||
|
|
8324903104 | ||
|
|
a757474b06 | ||
|
|
9cfa238e5c | ||
|
|
29ca4c29bb | ||
|
|
82c8112575 | ||
|
|
20e5b2f617 | ||
|
|
aa10d4170c | ||
|
|
aac3aee4bb | ||
|
|
d749c5ab48 | ||
|
|
082e0c5bff | ||
|
|
e69d6dee4a | ||
|
|
cf93ca9f71 | ||
|
|
7cadded6ca | ||
|
|
6fc2f560b7 | ||
|
|
8adf861ed7 | ||
|
|
f4f6ec7db5 | ||
|
|
5d89190348 | ||
|
|
f9fd7a2cfa | ||
|
|
21f14848da | ||
|
|
d1668f1c27 | ||
|
|
d6b61eaa4b | ||
|
|
26c714fc24 | ||
|
|
2e1d8b9c00 | ||
|
|
7cdc2952dc | ||
|
|
f42d1f6c57 | ||
|
|
3e25e1aa2f | ||
|
|
e0405b541f | ||
|
|
c374058e1d | ||
|
|
ac5cbbc49a | ||
|
|
bd51044690 | ||
|
|
cf49dcfa01 | ||
|
|
1982c2d3dc | ||
|
|
5f297f9233 | ||
|
|
37300fb2a6 | ||
|
|
fc8096bdd5 | ||
|
|
d93e3b4028 | ||
|
|
9b0d543f12 | ||
|
|
57762613a8 | ||
|
|
13ef70810c | ||
|
|
6a62f951ad | ||
|
|
0d3e02c596 | ||
|
|
7c22bf6ac1 | ||
|
|
b8113fc757 | ||
|
|
86ab1b26b7 | ||
|
|
de6c964136 | ||
|
|
e477851adf | ||
|
|
d2d4a6fe86 | ||
|
|
2f4e3f5802 | ||
|
|
24eebdef74 | ||
|
|
3f5b48f270 | ||
|
|
060fa2dfcd | ||
|
|
c42e1d7b4e | ||
|
|
09ebf1f39a | ||
|
|
8b5960282f | ||
|
|
ec40f29f28 | ||
|
|
5dd7a35a23 | ||
|
|
4b835bac5c | ||
|
|
10eb84690e | ||
|
|
bf25292bf8 | ||
|
|
284183836e | ||
|
|
de85aa1e8b | ||
|
|
fb014a1183 | ||
|
|
78049a29d9 | ||
|
|
8fbfcb3712 | ||
|
|
3818c58eff | ||
|
|
4c62b2dfb2 | ||
|
|
d8e1ee3266 | ||
|
|
90bb584813 | ||
|
|
f789a15338 | ||
|
|
6221516893 | ||
|
|
c9cab8457d | ||
|
|
19926c58ac | ||
|
|
955871c27b | ||
|
|
bfcef27b90 | ||
|
|
449d1a9eec | ||
|
|
fe97339ece | ||
|
|
4d5a413fa8 | ||
|
|
25078b3121 | ||
|
|
96a4d15424 | ||
|
|
bf4cd5c07c | ||
|
|
84d11f289f | ||
|
|
f4a22d9779 | ||
|
|
0e767f4250 | ||
|
|
2c26272e0b | ||
|
|
fc1bec1915 | ||
|
|
7dd256f65d | ||
|
|
4d7952d293 | ||
|
|
8a2b0a9aa3 | ||
|
|
602b1553a2 | ||
|
|
bd0881fb4b | ||
|
|
a9dd3e6e55 | ||
|
|
905995ab44 | ||
|
|
f9806d7f7f | ||
|
|
2d3adfc6a6 |
No files matched your search
@@ -4,3 +4,7 @@ updates:
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
- package-ecosystem: "bundler"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "never"
|
||||
@@ -0,0 +1,58 @@
|
||||
# First step of the Kotlin Multiplatform port: core:domain becomes shareable code that compiles
|
||||
# and runs on iOS as well as Android. This workflow is the evidence for that claim - the same
|
||||
# orbital math (SGP4/SDP4), models and repository contracts are compiled by the Kotlin/Native
|
||||
# compiler for iOS and their unit tests run on an iOS simulator. It deliberately does not touch
|
||||
# the Android build rules: the second job only proves the existing app still builds.
|
||||
name: ios-kmp
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- feat/ios-kmp
|
||||
- ios-kmp
|
||||
|
||||
jobs:
|
||||
ios:
|
||||
name: iOS shared module
|
||||
runs-on: macos-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: '21'
|
||||
- uses: gradle/actions/setup-gradle@v4
|
||||
- name: Compile shared module for iOS
|
||||
run: ./gradlew :core:domain:compileKotlinIosSimulatorArm64 --console=plain
|
||||
- name: Unit tests on the iOS simulator
|
||||
run: ./gradlew :core:domain:iosSimulatorArm64Test --console=plain
|
||||
- name: Upload iOS test reports
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ios-domain-test-report
|
||||
path: core/domain/build/reports/tests/
|
||||
|
||||
android:
|
||||
name: Android regression
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: '21'
|
||||
- uses: gradle/actions/setup-gradle@v4
|
||||
- name: JVM unit tests (domain and data)
|
||||
run: ./gradlew :core:domain:jvmTest :core:data:testDebugUnitTest --console=plain
|
||||
- name: Assemble debug APK
|
||||
run: ./gradlew :app:assembleDebug --console=plain
|
||||
- name: Upload test reports
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: android-reports
|
||||
path: |
|
||||
core/domain/build/reports/tests/
|
||||
core/data/build/reports/tests/
|
||||
@@ -40,10 +40,14 @@ captures/
|
||||
|
||||
# Keystore files
|
||||
*.jks
|
||||
*.keystore
|
||||
/*.properties
|
||||
/keystore.properties
|
||||
/app/keystore.jks
|
||||
|
||||
# Hermes agent workspace (plans, local notes)
|
||||
.hermes/
|
||||
|
||||
# External native build folder generated in Android Studio 2.2 and later
|
||||
.externalNativeBuild
|
||||
|
||||
@@ -66,3 +70,6 @@ fastlane/readme.md
|
||||
/app/release/output-metadata.json
|
||||
/app/release/
|
||||
/.kotlin/sessions/
|
||||
# Kotlin build caches in any module, not only the root - build-logic produces one too.
|
||||
.kotlin/
|
||||
.hermes/
|
||||
@@ -8,16 +8,15 @@ 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 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.
|
||||
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.
|
||||
|
||||
## Architecture
|
||||
## Architecture & Design
|
||||
|
||||
**MVI (Model-View-Intent)** with unidirectional data flow:
|
||||
- `State` data class → exposed via `StateFlow` from ViewModel
|
||||
- `Action` sealed interface → user intents dispatched to ViewModel's `onAction()`
|
||||
- `State` data class (named `<Feature>State`) exposed via `StateFlow` from ViewModel
|
||||
- `Action` sealed interface (named `<Feature>Action`) dispatched to ViewModel's `onAction()`
|
||||
- Jetpack Compose UI observes state and recomposes reactively
|
||||
|
||||
**Clean Architecture layers:**
|
||||
@@ -26,7 +25,7 @@ data download.
|
||||
|----------------------|---------------------------------------------------------------------|
|
||||
| `app` | Entry point. Aggregates all modules |
|
||||
| `core:data` | Android library. Room DB, OkHttp networking, repo implementations |
|
||||
| `core:domain` | Pure Kotlin (JVM). Orbital math (SGP4/SDP4), models, repo contracts |
|
||||
| `core:domain` | Multiplatform: JVM + iOS. Orbital math (SGP4/SDP4), models, contracts |
|
||||
| `core:presentation` | Android library. Compose theme, shared UI components, NavKeys |
|
||||
| `feature:map` | OSMDroid map with ground tracks |
|
||||
| `feature:passes` | Pass predictions and upcoming events |
|
||||
@@ -34,9 +33,11 @@ data download.
|
||||
| `feature:satellites` | Satellite list, filtering, selection |
|
||||
| `feature:settings` | User preferences |
|
||||
|
||||
- `feature:*` modules depend only on `core:domain` + `core:presentation`. Features never depend on each other.
|
||||
**Feature isolation:**
|
||||
- `feature:*` modules depend only on `core:domain` and `core:presentation`.
|
||||
- No feature-to-feature dependencies; cross-feature communication goes through core layers.
|
||||
|
||||
## Build & Run
|
||||
## Build & Platform
|
||||
|
||||
```shell
|
||||
# Debug build
|
||||
@@ -49,68 +50,90 @@ data download.
|
||||
./gradlew test
|
||||
```
|
||||
|
||||
- **Min SDK**: 24 | **Target SDK**: 36 | **JDK**: 17
|
||||
- **Gradle**: Uses version catalog (`gradle/libs.versions.toml`) + convention plugins in `build-logic/`
|
||||
- **Min SDK**: 24 | **Target SDK**: 36 | **JDK**: 21 (`jdkVersion` in the version catalog)
|
||||
- **Gradle**: Version catalog in `gradle/libs.versions.toml` + convention plugins in `build-logic/`
|
||||
|
||||
## Key Libraries
|
||||
## Tech Stack
|
||||
|
||||
- **Compose** (BOM 2026.05.01) + Material3 Adaptive
|
||||
- **Navigation3** (type-safe, uses `@Serializable` NavKeys)
|
||||
- **Room** (KSP code generation) for local satellite/TLE storage
|
||||
- **OkHttp** 5.x for TLE downloads
|
||||
- **Navigation3**: Type-safe navigation with `@Serializable` nav keys
|
||||
- **Room** (KSP code generation) for local satellite/orbital storage
|
||||
- **OkHttp** 5.x for data downloads
|
||||
- **OSMDroid** for map rendering
|
||||
- **Kotlin Serialization** for navigation args and data parsing
|
||||
- **Kotlin Serialization** for navigation args and parsing
|
||||
- **Coroutines** + `StateFlow` for async/reactive patterns
|
||||
|
||||
## 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).
|
||||
- **Localization**: 7 languages (en, es, ru, si, tr, uk, zh)
|
||||
|
||||
## Data Formats & Migration
|
||||
|
||||
**TLE vs. OMM/CSV format:**
|
||||
Look4Sat supports both TLE and OMM (Orbit Mean-Elements Message) CSV formats:
|
||||
|
||||
Look4Sat supports both TLE and OMM (Orbit Mean-Elements Message) formats for backward compatibility:
|
||||
- **TLE format**: Legacy 3-line element format limited by 5-digit NORAD IDs
|
||||
- **OMM/CSV format**: Successor format with ISO 8601 timestamps and larger NORAD ID support
|
||||
- New 5-digit NORAD IDs are exhausted; TLE is officially deprecated and OMM/CSV is the clear default
|
||||
- `DataParser.kt` supports both via `parseTLE()` and `parseCSV()`, each taking the file text
|
||||
- Downloads auto-detect format; both produce identical `OrbitalData` objects
|
||||
- Existing code already supports transparent source transition without feature changes
|
||||
- Refresh orbital data weekly for accurate pass prediction (orbital decay)
|
||||
|
||||
- **TLE format**: Traditional 3-line element format (deprecated). NORAD catalog numbers are 5-digit integers, which
|
||||
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.
|
||||
## Engineering Heuristics (Lazy = Efficient)
|
||||
|
||||
**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
|
||||
- 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?"
|
||||
|
||||
**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.
|
||||
## Bug-Fix Policy
|
||||
|
||||
## 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.
|
||||
- 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.
|
||||
|
||||
## Roadmap
|
||||
|
||||
- **KMP migration**: `core:domain` is to become a fully shareable KMM module. Keep it pure Kotlin/JVM.
|
||||
- **KMP migration**: `core:domain` is now a Kotlin Multiplatform module (jvm + iosArm64/iosSimulatorArm64),
|
||||
so the orbital math, models and repository contracts are compiled once and shared with the iOS app; Android
|
||||
modules consume its jvm target. `commonMain` must stay free of JVM-only APIs (no `java.*`, `org.json`,
|
||||
`String.format`, `Locale`, `InputStream`) - `formatString` in `utility/CommonFormat.kt` covers printf.
|
||||
- **iOS app**: next step - an iOS shell that consumes the `Look4SatCore` framework plus the `expect`/`actual`
|
||||
platform pieces (map, location, sensors, notifications).
|
||||
|
||||
## Gotchas
|
||||
|
||||
- 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.
|
||||
- Orbital math lives in `core:domain/predict/` — dense vector math (SGP4/SDP4). Tread carefully.
|
||||
- `core:domain` is compiled for iOS too: anything added to its `commonMain` must exist in Kotlin/Native.
|
||||
`.github/workflows/ios-kmp.yml` compiles it for iOS and runs the shared tests on an iOS simulator.
|
||||
- Kotlin/JVM-only declarations still *resolve* in `commonMain` and only fail when the iOS target compiles:
|
||||
`@Synchronized` and `@Volatile` (the `kotlin.jvm` ones) are errors in common code since Kotlin 2.1, as are
|
||||
`toUpperCase`/`toLowerCase`/`capitalize` and `BigDecimal`. Use `kotlin.concurrent.Volatile`, and
|
||||
`utility/SynchronizedOn.kt` (a platform actual) when a monitor is needed. `check-multiplatform.sh` in the
|
||||
working copy's parent directory flags the rest.
|
||||
- Source sets: `commonTest` runs on both jvm and iOS, so no JUnit4, no `javaClass.classLoader` and no bare
|
||||
`assert()` there - a build without `-ea` skips those silently, and `-ea` is a JVM flag. Use `kotlin.test`.
|
||||
JVM-only tests (classpath resources, `Locale.setDefault`) belong in `jvmTest`; platform code in
|
||||
`jvmMain`/`iosMain`.
|
||||
- `formatString` has to match `java.lang.String.format` exactly, and that rounds the *shortest decimal
|
||||
representation* of a double half-up: `"%.3f"` of 0.5005 is `"0.501"`, even though the stored double is
|
||||
0.50049999999999994493. `CommonFormatOracleTest` (jvmTest) compares against real `String.format` over
|
||||
sampled doubles; `CommonFormatRoundingTest` (commonTest) pins literals so iOS checks the same digits.
|
||||
- SSTV decoding in `feature:radar` is experimental; image quality depends on signal strength during satellite pass.
|
||||
- `build-logic/convention/` contains all shared Gradle configuration — edit there, not in individual modules.
|
||||
- ProGuard is enabled for release builds — don't add reflection-based libs or any other dependencies without asking.
|
||||
- `build-logic/convention/` contains shared Gradle configuration — edit there, not in individual modules.
|
||||
- AMSAT status colours are ARGB literals in `core:data` (`AmSatRepository.statusColorOf`) and duplicated in
|
||||
`core:presentation/MainTheme.kt`, so the data layer currently decides how the UI looks. Known debt, left as
|
||||
upstream shipped it: the fix is a status enum in `core:domain` with the colour mapping in `core:presentation`.
|
||||
Anything needing themeable, dark-mode-aware or colour-blind-safe status colours has to do that first.
|
||||
|
||||
## Copilot Working Mode: Code-Only
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
GNU GENERAL PUBLIC LICENSE
|
||||
Version 3, 29 June 2007
|
||||
GNU AFFERO GENERAL PUBLIC LICENSE
|
||||
Version 3, 19 November 2007
|
||||
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
@@ -7,17 +7,15 @@
|
||||
|
||||
Preamble
|
||||
|
||||
The GNU General Public License is a free, copyleft license for
|
||||
software and other kinds of works.
|
||||
The GNU Affero General Public License is a free, copyleft license for
|
||||
software and other kinds of works, specifically designed to ensure
|
||||
cooperation with the community in the case of network server software.
|
||||
|
||||
The licenses for most software and other practical works are designed
|
||||
to take away your freedom to share and change the works. By contrast,
|
||||
the GNU General Public License is intended to guarantee your freedom to
|
||||
our General Public Licenses are intended to guarantee your freedom to
|
||||
share and change all versions of a program--to make sure it remains free
|
||||
software for all its users. We, the Free Software Foundation, use the
|
||||
GNU General Public License for most of our software; it applies also to
|
||||
any other work released this way by its authors. You can apply it to
|
||||
your programs, too.
|
||||
software for all its users.
|
||||
|
||||
When we speak of free software, we are referring to freedom, not
|
||||
price. Our General Public Licenses are designed to make sure that you
|
||||
@@ -26,44 +24,34 @@ them if you wish), that you receive source code or can get it if you
|
||||
want it, that you can change the software or use pieces of it in new
|
||||
free programs, and that you know you can do these things.
|
||||
|
||||
To protect your rights, we need to prevent others from denying you
|
||||
these rights or asking you to surrender the rights. Therefore, you have
|
||||
certain responsibilities if you distribute copies of the software, or if
|
||||
you modify it: responsibilities to respect the freedom of others.
|
||||
Developers that use our General Public Licenses protect your rights
|
||||
with two steps: (1) assert copyright on the software, and (2) offer
|
||||
you this License which gives you legal permission to copy, distribute
|
||||
and/or modify the software.
|
||||
|
||||
For example, if you distribute copies of such a program, whether
|
||||
gratis or for a fee, you must pass on to the recipients the same
|
||||
freedoms that you received. You must make sure that they, too, receive
|
||||
or can get the source code. And you must show them these terms so they
|
||||
know their rights.
|
||||
A secondary benefit of defending all users' freedom is that
|
||||
improvements made in alternate versions of the program, if they
|
||||
receive widespread use, become available for other developers to
|
||||
incorporate. Many developers of free software are heartened and
|
||||
encouraged by the resulting cooperation. However, in the case of
|
||||
software used on network servers, this result may fail to come about.
|
||||
The GNU General Public License permits making a modified version and
|
||||
letting the public access it on a server without ever releasing its
|
||||
source code to the public.
|
||||
|
||||
Developers that use the GNU GPL protect your rights with two steps:
|
||||
(1) assert copyright on the software, and (2) offer you this License
|
||||
giving you legal permission to copy, distribute and/or modify it.
|
||||
The GNU Affero General Public License is designed specifically to
|
||||
ensure that, in such cases, the modified source code becomes available
|
||||
to the community. It requires the operator of a network server to
|
||||
provide the source code of the modified version running there to the
|
||||
users of that server. Therefore, public use of a modified version, on
|
||||
a publicly accessible server, gives the public access to the source
|
||||
code of the modified version.
|
||||
|
||||
For the developers' and authors' protection, the GPL clearly explains
|
||||
that there is no warranty for this free software. For both users' and
|
||||
authors' sake, the GPL requires that modified versions be marked as
|
||||
changed, so that their problems will not be attributed erroneously to
|
||||
authors of previous versions.
|
||||
|
||||
Some devices are designed to deny users access to install or run
|
||||
modified versions of the software inside them, although the manufacturer
|
||||
can do so. This is fundamentally incompatible with the aim of
|
||||
protecting users' freedom to change the software. The systematic
|
||||
pattern of such abuse occurs in the area of products for individuals to
|
||||
use, which is precisely where it is most unacceptable. Therefore, we
|
||||
have designed this version of the GPL to prohibit the practice for those
|
||||
products. If such problems arise substantially in other domains, we
|
||||
stand ready to extend this provision to those domains in future versions
|
||||
of the GPL, as needed to protect the freedom of users.
|
||||
|
||||
Finally, every program is threatened constantly by software patents.
|
||||
States should not allow patents to restrict development and use of
|
||||
software on general-purpose computers, but in those that do, we wish to
|
||||
avoid the special danger that patents applied to a free program could
|
||||
make it effectively proprietary. To prevent this, the GPL assures that
|
||||
patents cannot be used to render the program non-free.
|
||||
An older license, called the Affero General Public License and
|
||||
published by Affero, was designed to accomplish similar goals. This is
|
||||
a different license, not a version of the Affero GPL, but Affero has
|
||||
released a new version of the Affero GPL which permits relicensing under
|
||||
this license.
|
||||
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow.
|
||||
@@ -72,7 +60,7 @@ modification follow.
|
||||
|
||||
0. Definitions.
|
||||
|
||||
"This License" refers to version 3 of the GNU General Public License.
|
||||
"This License" refers to version 3 of the GNU Affero General Public License.
|
||||
|
||||
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||
works, such as semiconductor masks.
|
||||
@@ -549,35 +537,45 @@ to collect a royalty for further conveying from those to whom you convey
|
||||
the Program, the only way you could satisfy both those terms and this
|
||||
License would be to refrain entirely from conveying the Program.
|
||||
|
||||
13. Use with the GNU Affero General Public License.
|
||||
13. Remote Network Interaction; Use with the GNU General Public License.
|
||||
|
||||
Notwithstanding any other provision of this License, if you modify the
|
||||
Program, your modified version must prominently offer all users
|
||||
interacting with it remotely through a computer network (if your version
|
||||
supports such interaction) an opportunity to receive the Corresponding
|
||||
Source of your version by providing access to the Corresponding Source
|
||||
from a network server at no charge, through some standard or customary
|
||||
means of facilitating copying of software. This Corresponding Source
|
||||
shall include the Corresponding Source for any work covered by version 3
|
||||
of the GNU General Public License that is incorporated pursuant to the
|
||||
following paragraph.
|
||||
|
||||
Notwithstanding any other provision of this License, you have
|
||||
permission to link or combine any covered work with a work licensed
|
||||
under version 3 of the GNU Affero General Public License into a single
|
||||
under version 3 of the GNU General Public License into a single
|
||||
combined work, and to convey the resulting work. The terms of this
|
||||
License will continue to apply to the part which is the covered work,
|
||||
but the special requirements of the GNU Affero General Public License,
|
||||
section 13, concerning interaction through a network will apply to the
|
||||
combination as such.
|
||||
but the work with which it is combined will remain governed by version
|
||||
3 of the GNU General Public License.
|
||||
|
||||
14. Revised Versions of this License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions of
|
||||
the GNU General Public License from time to time. Such new versions will
|
||||
be similar in spirit to the present version, but may differ in detail to
|
||||
the GNU Affero General Public License from time to time. Such new versions
|
||||
will be similar in spirit to the present version, but may differ in detail to
|
||||
address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the
|
||||
Program specifies that a certain numbered version of the GNU General
|
||||
Program specifies that a certain numbered version of the GNU Affero General
|
||||
Public License "or any later version" applies to it, you have the
|
||||
option of following the terms and conditions either of that numbered
|
||||
version or of any later version published by the Free Software
|
||||
Foundation. If the Program does not specify a version number of the
|
||||
GNU General Public License, you may choose any version ever published
|
||||
GNU Affero General Public License, you may choose any version ever published
|
||||
by the Free Software Foundation.
|
||||
|
||||
If the Program specifies that a proxy can decide which future
|
||||
versions of the GNU General Public License can be used, that proxy's
|
||||
versions of the GNU Affero General Public License can be used, that proxy's
|
||||
public statement of acceptance of a version permanently authorizes you
|
||||
to choose that version for the Program.
|
||||
|
||||
@@ -635,40 +633,29 @@ the "copyright" line and a pointer to where the full notice is found.
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
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
|
||||
it under the terms of the GNU Affero 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.
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If the program does terminal interaction, make it output a short
|
||||
notice like this when it starts in an interactive mode:
|
||||
|
||||
<program> Copyright (C) <year> <name of author>
|
||||
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
||||
This is free software, and you are welcome to redistribute it
|
||||
under certain conditions; type `show c' for details.
|
||||
|
||||
The hypothetical commands `show w' and `show c' should show the appropriate
|
||||
parts of the General Public License. Of course, your program's commands
|
||||
might be different; for a GUI interface, you would use an "about box".
|
||||
If your software can interact with users remotely through a computer
|
||||
network, you should also make sure that it provides a way for users to
|
||||
get its source. For example, if your program is a web application, its
|
||||
interface could display a "Source" link that leads users to an archive
|
||||
of the code. There are many ways you could offer source, and different
|
||||
solutions will be better for different programs; see section 13 for the
|
||||
specific requirements.
|
||||
|
||||
You should also get your employer (if you work as a programmer) or school,
|
||||
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||
For more information on this, and how to apply and follow the GNU GPL, see
|
||||
For more information on this, and how to apply and follow the GNU AGPL, see
|
||||
<https://www.gnu.org/licenses/>.
|
||||
|
||||
The GNU General Public License does not permit incorporating your program
|
||||
into proprietary programs. If your program is a subroutine library, you
|
||||
may consider it more useful to permit linking proprietary applications with
|
||||
the library. If this is what you want to do, use the GNU Lesser General
|
||||
Public License instead of this License. But first, please read
|
||||
<https://www.gnu.org/licenses/why-not-lgpl.html>.
|
||||
@@ -0,0 +1,24 @@
|
||||
LOOK4SAT (MCKERO6423 FORK) — LICENSING NOTICE
|
||||
|
||||
This repository combines two separately-licensed components:
|
||||
|
||||
1. Look4Sat application code (all modules except the DeepCW model)
|
||||
— Copyright (C) 2019-2026 Arty Bishop (rt-bishop) and contributors
|
||||
— Licensed under the GNU General Public License v3.0 (GPL-3.0)
|
||||
|
||||
2. The DeepCW neural decoding model in feature/cw/src/main/assets/deepcw/
|
||||
— Copyright (C) e04 (https://github.com/e04/deepcw-engine)
|
||||
— Licensed under the GNU Affero General Public License v3.0 only
|
||||
(AGPL-3.0-only)
|
||||
|
||||
Because this combined work incorporates an AGPL-3.0 component, it is
|
||||
distributed under the GNU Affero General Public License v3.0. GPL-3.0
|
||||
Section 13 permits this combination; AGPL-3.0 Section 13 applies to the
|
||||
combined work as a whole.
|
||||
|
||||
See feature/cw/licenses/NOTICE.md for model provenance, attribution, and
|
||||
the applied int8 quantization. The original GPL-3.0 text for the Look4Sat
|
||||
application code is preserved at feature/cw/licenses/Look4Sat-GPL-3.0.txt.
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
|
||||
@@ -34,12 +34,27 @@ It is now and always will be completely ad-free and open-source.
|
||||
* 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.
|
||||
|
||||
## License
|
||||
|
||||
The Look4Sat application code is licensed under the GNU General Public License v3.0.
|
||||
|
||||
The CW decoder in `feature/cw` bundles the [DeepCW](https://github.com/e04/deepcw-engine)
|
||||
neural decoding model, licensed under the GNU Affero General Public License v3.0 only
|
||||
(AGPL-3.0-only). Because the combined work incorporates an AGPL-3.0 component, the
|
||||
combined work is distributed under the
|
||||
[GNU Affero General Public License v3.0](LICENSE) — GPL-3.0 Section 13 permits the
|
||||
combination, and AGPL-3.0 Section 13 applies to the combined work as a whole.
|
||||
Model provenance and attribution are documented in
|
||||
[`feature/cw/licenses/NOTICE.md`](feature/cw/licenses/NOTICE.md); the original GPL-3.0
|
||||
text is preserved at `feature/cw/licenses/Look4Sat-GPL-3.0.txt`. The CW model runs
|
||||
locally on-device and does not provide services over a network.
|
||||
|
||||
## Star History
|
||||
|
||||
<a href="https://www.star-history.com/?repos=rt-bishop%2FLook4Sat&type=timeline&legend=top-left">
|
||||
<a href="https://star-history.dera.page/#rt-bishop/Look4Sat&type=timeline&legend=top-left">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=rt-bishop/Look4Sat&type=timeline&theme=dark&legend=top-left" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=rt-bishop/Look4Sat&type=timeline&legend=top-left" />
|
||||
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=rt-bishop/Look4Sat&type=timeline&legend=top-left" />
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://star-history.dera.page/svg?repos=rt-bishop/Look4Sat&type=timeline&theme=dark&legend=top-left" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://star-history.dera.page/svg?repos=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" />
|
||||
</picture>
|
||||
</a>
|
||||
+11
-3
@@ -11,11 +11,12 @@ val keystoreProperties = Properties().apply {
|
||||
}
|
||||
|
||||
android {
|
||||
// CW 解码 native 库仅 armeabi-v7a(照搬 Morse Expert 1.15): 全 ABI 打包会在
|
||||
// arm64 设备 loadLibrary 失败, 强制 32 位兼容(用户设备为 32 位软件)
|
||||
defaultConfig {
|
||||
// ONNX Runtime 的 AAR 自带 4 个架构共 115MB 原生库(arm64 28M / armv7 20M /
|
||||
// x86 33M / x86_64 34M)。x86 系列只有模拟器用得到, 全打包会让 APK 从 8MB
|
||||
// 涨到 135MB。仅保留真机需要的两个 ABI。
|
||||
ndk {
|
||||
abiFilters += listOf("armeabi-v7a")
|
||||
abiFilters += listOf("arm64-v8a", "armeabi-v7a")
|
||||
}
|
||||
}
|
||||
androidResources {
|
||||
@@ -23,6 +24,10 @@ android {
|
||||
localeFilters += listOf(
|
||||
"en", "zh", "tr", "in", "id", "es", "ru", "si", "uk"
|
||||
)
|
||||
// DeepCW 模型必须以未压缩形式打包: ONNX Runtime 通过 mmap 直接读取
|
||||
// assets, 压缩后无法映射会导致 createSession 失败。noCompress 只在
|
||||
// 打包 APK 的 app 模块生效, 在 feature 库模块声明无效。
|
||||
noCompress += "onnx"
|
||||
}
|
||||
signingConfigs {
|
||||
if (keystoreProperties["storeFile"] != null) {
|
||||
@@ -37,6 +42,9 @@ android {
|
||||
buildTypes {
|
||||
release {
|
||||
signingConfig = signingConfigs.findByName("release")
|
||||
// ONNX Runtime 走 JNI, R8 混淆会重命名 ai.onnxruntime.* 类导致 native
|
||||
// 崩溃。convention 插件已开启 isMinifyEnabled, 必须补 keep 规则。
|
||||
proguardFiles("proguard-rules.pro")
|
||||
}
|
||||
}
|
||||
}
|
||||
Vendored
+13
@@ -0,0 +1,13 @@
|
||||
# ProGuard / R8 rules for the Look4Sat application module.
|
||||
#
|
||||
# NOTE: release builds enable minification (isMinifyEnabled=true in the
|
||||
# convention plugin), so anything whose classes are resolved reflectively or
|
||||
# through JNI by name MUST be kept here.
|
||||
|
||||
# ONNX Runtime (ai.onnxruntime): the Java binding is backed by JNI. Native code
|
||||
# resolves Java methods/classes by their original names; R8 renaming or
|
||||
# stripping them causes a hard crash at runtime with no Java stack trace.
|
||||
# This rule is required by the ONNX Runtime docs for minified Android builds.
|
||||
# https://onnxruntime.ai/docs/get-started/with-java.html
|
||||
-keep class ai.onnxruntime.** { *; }
|
||||
-dontwarn ai.onnxruntime.**
|
||||
@@ -15,7 +15,22 @@
|
||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<!--
|
||||
dataSync rather than location. The location type is refused outright unless a location
|
||||
runtime permission has already been granted - startForeground throws SecurityException -
|
||||
and this service reads the station position the operator typed into settings, so demanding
|
||||
location access to beacon a fixed QTH is both wrong and a way to fail silently for anyone
|
||||
who declined it. The dataSync six-hour cap that prompted the earlier switch applies only
|
||||
when targetSdk is 35 or higher, which this project does not declare.
|
||||
-->
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
||||
<!--
|
||||
Doze suspends network access and ignores wake locks even for a foreground service, so a
|
||||
coroutine delay wakes up on time and then cannot reach the network. An exact alarm with
|
||||
setExactAndAllowWhileIdle is the only scheduling that survives Doze, and at a five-minute
|
||||
floor the system's one-alarm-per-nine-minutes throttle is not a problem.
|
||||
-->
|
||||
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<application
|
||||
android:name=".MainApplication"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.rtbishop.look4sat.app
|
||||
|
||||
import android.app.AlarmManager
|
||||
import android.app.Notification
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
@@ -11,7 +12,10 @@ import android.content.SharedPreferences
|
||||
import android.widget.Toast
|
||||
import android.content.pm.ServiceInfo
|
||||
import android.os.Build
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.os.IBinder
|
||||
import com.rtbishop.look4sat.BuildConfig
|
||||
import com.rtbishop.look4sat.MainApplication
|
||||
import com.rtbishop.look4sat.core.presentation.R
|
||||
import com.rtbishop.look4sat.core.data.aprs.AprsConfig
|
||||
@@ -24,8 +28,8 @@ import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* APRS 前台服务:系统通知保活,切换页面不断线。
|
||||
* START_STICKY:被系统杀掉后自动重启(APRSdroid 同款策略)。
|
||||
* APRS foreground service: kept alive by a system notification; keeps working across pages.
|
||||
* START_STICKY: auto-restarted after being killed by the system (same strategy as APRSdroid).
|
||||
*/
|
||||
class AprsForegroundService : Service() {
|
||||
|
||||
@@ -35,10 +39,25 @@ class AprsForegroundService : Service() {
|
||||
const val ACTION_REPORT_NOW = AprsStore.ACTION_REPORT_NOW
|
||||
const val CHANNEL_ID = "aprs_service"
|
||||
const val NOTIF_ID = 101
|
||||
|
||||
/** Alarm-driven tick, kept separate so a manual report stays distinguishable. */
|
||||
const val ACTION_ALARM_TICK = "com.rtbishop.look4sat.APRS_ALARM_TICK"
|
||||
|
||||
private const val ALARM_REQUEST = 4101
|
||||
}
|
||||
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
private var reporter: AprsReporter? = null
|
||||
|
||||
/**
|
||||
* Handler on the main looper, for anything that must not run on the reporter's IO thread.
|
||||
*
|
||||
* onReport is invoked from AprsReporter's Dispatchers.IO scope, and Toast construction there
|
||||
* throws because that thread has no Looper - an exception the surrounding runCatching then
|
||||
* swallowed, so every report notice was silently discarded. The messages existed and no
|
||||
* operator ever saw one.
|
||||
*/
|
||||
private val mainHandler = Handler(Looper.getMainLooper())
|
||||
private var lastState: AprsState = AprsState.Idle
|
||||
|
||||
override fun onBind(intent: Intent?): IBinder? = null
|
||||
@@ -51,9 +70,15 @@ class AprsForegroundService : Service() {
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
when (intent?.action) {
|
||||
ACTION_STOP -> stopReporting()
|
||||
ACTION_ALARM_TICK -> {
|
||||
// Woken by the exact alarm. Reporting once and then booking the next tick, rather
|
||||
// than using a repeating alarm, means a changed interval takes effect at once.
|
||||
if (reporter == null) startReporting() else reporter?.reportNow()
|
||||
scheduleNextTick()
|
||||
}
|
||||
ACTION_REPORT_NOW -> {
|
||||
if (reporter == null) {
|
||||
// 服务没在跑:先启动(未配置会 Toast 提示)
|
||||
// Service not running: start it first (Toast hint when not configured)
|
||||
startReporting()
|
||||
}
|
||||
reporter?.reportNow()
|
||||
@@ -64,6 +89,16 @@ class AprsForegroundService : Service() {
|
||||
}
|
||||
|
||||
private fun startReporting() {
|
||||
// onStartCommand reaches here for every ACTION_START and for the null
|
||||
// intent that START_STICKY delivers on restart. Without this guard each
|
||||
// call built a fresh AprsReporter and overwrote the field, leaving the
|
||||
// previous one running with its own scope and timer: the server then
|
||||
// received one duplicate position report per leaked instance per cycle,
|
||||
// and ACTION_STOP could only ever stop the newest one.
|
||||
reporter?.let { existing ->
|
||||
if (existing.isRunning) return
|
||||
existing.stop()
|
||||
}
|
||||
val cfg = AprsStore.loadConfig(this)
|
||||
if (!cfg.enabled || cfg.callsign.isBlank()) {
|
||||
runCatching {
|
||||
@@ -75,18 +110,33 @@ class AprsForegroundService : Service() {
|
||||
startForegroundWithNotification(cfg)
|
||||
val rep = AprsReporter(
|
||||
configProvider = { AprsStore.loadConfig(this) },
|
||||
// The real version, so the login line cannot drift from the build again.
|
||||
appVersion = BuildConfig.VERSION_NAME,
|
||||
positionProvider = { stationPosition() },
|
||||
onState = { lastState = it },
|
||||
onReport = { report ->
|
||||
AprsStore.saveLastReport(this, report.ok, report.detail)
|
||||
// Derived from this report rather than read from lastState: onState fires AFTER
|
||||
// onReport, so the notification was being rebuilt from the previous cycle's
|
||||
// verdict. For an alarm-driven report at 03:00 the notification is the only
|
||||
// surface that survives, and it was showing the wrong one.
|
||||
lastState = if (report.ok) AprsState.Connected else AprsState.Error
|
||||
updateNotification(cfg)
|
||||
// 上报结果必达:成功 Toast 短显,失败 Toast 长显+原因
|
||||
val msg = if (report.ok) {
|
||||
getString(R.string.aprs_toast_ok)
|
||||
} else {
|
||||
getString(R.string.aprs_toast_fail, report.detail)
|
||||
// Report result always surfaces: success = short Toast, failure = long Toast + reason
|
||||
// An unverified login needs its own message: the write succeeded, so a bare
|
||||
// failure notice would send the operator looking at their network when the
|
||||
// problem is the passcode - and APRS-IS is dropping every packet meanwhile.
|
||||
// No receive-only notice. APRS-IS lets an unverified station connect and then
|
||||
// discards its packets, but this app only reports its own position - there is no
|
||||
// receiving side to it - so telling the operator they are "in receive-only mode"
|
||||
// named a state that does not exist here. Without a passcode the packet does not
|
||||
// arrive, and that is what the failure notice says.
|
||||
val msg = when {
|
||||
report.ok -> getString(R.string.aprs_toast_ok)
|
||||
!report.verified -> getString(R.string.aprs_toast_unverified)
|
||||
else -> getString(R.string.aprs_toast_fail, report.detail)
|
||||
}
|
||||
runCatching {
|
||||
mainHandler.post {
|
||||
Toast.makeText(this, msg,
|
||||
if (report.ok) Toast.LENGTH_SHORT else Toast.LENGTH_LONG).show()
|
||||
}
|
||||
@@ -94,9 +144,12 @@ class AprsForegroundService : Service() {
|
||||
)
|
||||
reporter = rep
|
||||
rep.start()
|
||||
// The reporter beacons once on start; the alarm carries every one after that.
|
||||
scheduleNextTick()
|
||||
}
|
||||
|
||||
private fun stopReporting() {
|
||||
cancelTicks()
|
||||
reporter?.stop()
|
||||
reporter = null
|
||||
stopForeground(STOP_FOREGROUND_REMOVE)
|
||||
@@ -107,12 +160,17 @@ class AprsForegroundService : Service() {
|
||||
try {
|
||||
val notif = buildNotification(cfg)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
// Must match the manifest attribute exactly: AOSP checks the passed type is a
|
||||
// subset of the declared one and throws otherwise, which the catch below turns
|
||||
// into a silent stopSelf(). Declaring location instead would additionally require
|
||||
// a granted location permission before this call, and the settings card asks only
|
||||
// for notifications - so that combination fails silently too.
|
||||
startForeground(NOTIF_ID, notif, ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC)
|
||||
} else {
|
||||
startForeground(NOTIF_ID, notif)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
// 厂商 ROM / 旧系统兼容兜底:启动前台失败只停服务,不崩进程
|
||||
// Vendor ROM / old-system safety net: foreground-start failure only stops the service, never crashes the app
|
||||
stopSelf()
|
||||
}
|
||||
}
|
||||
@@ -146,9 +204,9 @@ class AprsForegroundService : Service() {
|
||||
nm.notify(NOTIF_ID, buildNotification(cfg))
|
||||
}
|
||||
|
||||
/** 上报位置:优先用设置里的站位(用户拍板);站位无效时实时 GPS 兜底 */
|
||||
/** Report position: station QTH from settings first (per user); live GPS as fallback when invalid */
|
||||
private fun stationPosition(): Pair<Double, Double>? {
|
||||
// ① 站位(设置页设置的位置)
|
||||
// 1. Station QTH (position set in settings)
|
||||
val station = runCatching {
|
||||
val container = (application as MainApplication).getMainContainer()
|
||||
container.settingsRepo.stationPosition.value
|
||||
@@ -156,7 +214,7 @@ class AprsForegroundService : Service() {
|
||||
if (station != null && (station.latitude != 0.0 || station.longitude != 0.0)) {
|
||||
return Pair(station.latitude, station.longitude)
|
||||
}
|
||||
// ② 兜底:实时 GPS 最后位置
|
||||
// 2. Fallback: last live GPS position
|
||||
return runCatching {
|
||||
val lm = getSystemService(Context.LOCATION_SERVICE) as android.location.LocationManager
|
||||
val providers = listOf(
|
||||
@@ -187,4 +245,43 @@ class AprsForegroundService : Service() {
|
||||
reporter = null
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
/**
|
||||
* Book the next beacon with an exact alarm.
|
||||
*
|
||||
* A coroutine delay was used before, which Doze defeats: the timer fires but network access is
|
||||
* suspended and wake locks are ignored, even inside a foreground service. Only
|
||||
* setExactAndAllowWhileIdle survives that, and the five-minute floor keeps this well clear of
|
||||
* the system's throttle on how often such an alarm may repeat.
|
||||
*/
|
||||
private fun scheduleNextTick() {
|
||||
val cfg = AprsStore.loadConfig(this)
|
||||
if (!cfg.enabled) return
|
||||
val alarms = getSystemService(Context.ALARM_SERVICE) as AlarmManager
|
||||
val minutes = cfg.intervalMin.coerceAtLeast(AprsReporter.MIN_INTERVAL_MIN)
|
||||
val at = System.currentTimeMillis() + minutes * 60_000L
|
||||
runCatching {
|
||||
val exact = Build.VERSION.SDK_INT < Build.VERSION_CODES.S || alarms.canScheduleExactAlarms()
|
||||
if (exact) {
|
||||
alarms.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, at, tickIntent())
|
||||
} else {
|
||||
// The operator revoked exact alarms. An inexact one still beacons, just whenever
|
||||
// the system decides, which beats not beaconing at all.
|
||||
alarms.set(AlarmManager.RTC_WAKEUP, at, tickIntent())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun cancelTicks() {
|
||||
val alarms = getSystemService(Context.ALARM_SERVICE) as AlarmManager
|
||||
runCatching { alarms.cancel(tickIntent()) }
|
||||
}
|
||||
|
||||
private fun tickIntent(): PendingIntent = PendingIntent.getService(
|
||||
this,
|
||||
ALARM_REQUEST,
|
||||
Intent(this, AprsForegroundService::class.java).setAction(ACTION_ALARM_TICK),
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||
)
|
||||
|
||||
}
|
||||
@@ -49,7 +49,7 @@ class MainActivity : ComponentActivity() {
|
||||
super.onCreate(savedInstanceState)
|
||||
observeNightFilterState()
|
||||
setContent {
|
||||
MainTheme(isDarkTheme = true) { MainScreen() }
|
||||
MainTheme(isDarkTheme = true) { NavRoot() }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@ class MainApplication : Application(), IContainerProvider {
|
||||
|
||||
private lateinit var container: IMainContainer
|
||||
|
||||
/** 全局崩溃捕获:堆栈写入 files/crash_log.txt,重启后可查看(用户要求错误报告) */
|
||||
/** Global crash capture: stack written to files/crash_log.txt, viewable after restart (user asked for error reports) */
|
||||
private fun installCrashHandler() {
|
||||
val defaultHandler = Thread.getDefaultUncaughtExceptionHandler()
|
||||
Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
|
||||
|
||||
@@ -20,20 +20,18 @@ package com.rtbishop.look4sat
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.animateContentSize
|
||||
import androidx.compose.animation.core.LinearEasing
|
||||
import androidx.compose.animation.core.RepeatMode
|
||||
import androidx.compose.animation.core.Spring
|
||||
import androidx.compose.animation.core.animateFloat
|
||||
import androidx.compose.animation.core.infiniteRepeatable
|
||||
import androidx.compose.animation.core.rememberInfiniteTransition
|
||||
import androidx.compose.animation.core.spring
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.expandVertically
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.scaleIn
|
||||
import androidx.compose.animation.scaleOut
|
||||
import androidx.compose.animation.shrinkVertically
|
||||
import androidx.compose.animation.core.LinearEasing
|
||||
import androidx.compose.animation.core.RepeatMode
|
||||
import androidx.compose.animation.core.animateFloat
|
||||
import androidx.compose.animation.core.infiniteRepeatable
|
||||
import androidx.compose.animation.core.rememberInfiniteTransition
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.slideInHorizontally
|
||||
import androidx.compose.animation.slideOutHorizontally
|
||||
import androidx.compose.animation.togetherWith
|
||||
@@ -51,7 +49,7 @@ import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteDefaults
|
||||
import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteScaffold
|
||||
@@ -67,6 +65,7 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.TransformOrigin
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
@@ -83,6 +82,7 @@ import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator
|
||||
import androidx.navigation3.ui.NavDisplay
|
||||
import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
|
||||
import com.rtbishop.look4sat.core.domain.repository.MutualPassData
|
||||
import com.rtbishop.look4sat.core.domain.navigation.MenuLayout
|
||||
import com.rtbishop.look4sat.core.presentation.DeeplinkResolver
|
||||
import com.rtbishop.look4sat.core.presentation.ElevationThresholds
|
||||
import com.rtbishop.look4sat.core.presentation.LocalElevationThresholds
|
||||
@@ -97,41 +97,45 @@ 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.radar.WavelogLogScreen
|
||||
import com.rtbishop.look4sat.feature.status.SatStatusScreen
|
||||
import com.rtbishop.look4sat.feature.roaming.RoamingScreen
|
||||
import com.rtbishop.look4sat.feature.satellites.SatellitesDestination
|
||||
import com.rtbishop.look4sat.feature.settings.SettingsDestination
|
||||
import com.rtbishop.look4sat.feature.status.SatStatusDestination
|
||||
|
||||
@Composable
|
||||
fun NavRoot(deeplink: String? = null) {
|
||||
val rootBackStack = rememberNavBackStack(Screen.Passes)
|
||||
val deeplinkResolver = DeeplinkResolver()
|
||||
LaunchedEffect(deeplink) {
|
||||
deeplink?.let {
|
||||
val destination = deeplinkResolver.resolve(it) // rootBackStack.clear()
|
||||
rootBackStack.add(destination)
|
||||
}
|
||||
deeplink?.let { rootBackStack.add(deeplinkResolver.resolve(it)) }
|
||||
}
|
||||
val navigateBack: () -> Unit = { rootBackStack.removeLastOrNull() }
|
||||
val slideInTransition = slideInHorizontally(initialOffsetX = { it }) togetherWith scaleOut(targetScale = 0.9f)
|
||||
val slideOutTransition = scaleIn(initialScale = 0.9f) togetherWith slideOutHorizontally(targetOffsetX = { it })
|
||||
val navigateToRadar: () -> Unit = { rootBackStack.add(RadarDestination) }
|
||||
// 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 }
|
||||
// Reverse: outgoing slides out to the right, incoming drifts in from the left
|
||||
val popTransition = slideInHorizontally(tween(300)) { -it / 3 } togetherWith
|
||||
slideOutHorizontally(tween(300)) { it }
|
||||
NavDisplay(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
backStack = rootBackStack,
|
||||
onBack = navigateBack,
|
||||
transitionSpec = { slideInTransition },
|
||||
popTransitionSpec = { slideOutTransition },
|
||||
predictivePopTransitionSpec = { slideOutTransition },
|
||||
transitionSpec = { pushTransition },
|
||||
popTransitionSpec = { popTransition },
|
||||
predictivePopTransitionSpec = { popTransition },
|
||||
entryDecorators = listOf(
|
||||
rememberSaveableStateHolderNavEntryDecorator(), // Required for saving Compose state per entry
|
||||
rememberViewModelStoreNavEntryDecorator() // Required for ViewModel scoping per entry
|
||||
rememberSaveableStateHolderNavEntryDecorator(),
|
||||
rememberViewModelStoreNavEntryDecorator()
|
||||
),
|
||||
entryProvider = entryProvider {
|
||||
entry<Screen.Passes> { MainScreen(navigateToRadar = { rootBackStack.add(RadarDestination) }) }
|
||||
entry<Screen.Passes> { MainScreen() }
|
||||
entry<RadarDestination> {
|
||||
Scaffold { innerPadding ->
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
color = MaterialTheme.colorScheme.background
|
||||
) {
|
||||
RadarDestination(navigateUp = navigateBack)
|
||||
innerPadding.calculateTopPadding()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -139,7 +143,7 @@ fun NavRoot(deeplink: String? = null) {
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun MainScreen(navigateToRadar: () -> Unit = {}) {
|
||||
fun MainScreen() {
|
||||
val backStack = rememberNavBackStack(Screen.Passes)
|
||||
val currentKey = backStack.lastOrNull()
|
||||
val navigateBack: () -> Unit = { backStack.removeLastOrNull() }
|
||||
@@ -148,30 +152,30 @@ fun MainScreen(navigateToRadar: () -> Unit = {}) {
|
||||
val container = (context.applicationContext as IContainerProvider).getMainContainer()
|
||||
val trackingState by container.radioTrackingService.state.collectAsStateWithLifecycle()
|
||||
val otherSettings by container.settingsRepo.otherSettings.collectAsStateWithLifecycle()
|
||||
// UI 设置: 按 screenOrder 排序(空 = 默认顺序), 再按 hiddenScreens 过滤(设置页固定保留)
|
||||
val allNavItems = listOf(Screen.Satellites, Screen.Passes, Screen.Radar, Screen.Mutual, Screen.Roaming, Screen.CwDecode, Screen.WavelogLog, Screen.AmSat, Screen.Map, Screen.Settings)
|
||||
.sortedBy { screen ->
|
||||
// 未知(新页面如 CwDecode 不在旧持久化顺序里): 用默认顺序位置(漫游↔地图), 再兜底最后
|
||||
val idx = otherSettings.screenOrder.indexOf(screen.screenId)
|
||||
if (idx != -1) idx
|
||||
else com.rtbishop.look4sat.core.presentation.defaultScreenOrder.indexOf(screen.screenId).let {
|
||||
if (it != -1) it else Int.MAX_VALUE
|
||||
}
|
||||
}
|
||||
.filter { it.screenId !in otherSettings.hiddenScreens || it is Screen.Settings }
|
||||
// 4.5.1 折叠菜单: 主菜单(底部栏 5 槽) + 更多菜单(溢出页面)
|
||||
// 老用户迁移: 已持久化的 subMenuOrder 不含新页面 WavelogLog → 追加到子菜单尾部
|
||||
val subOrder = (otherSettings.subMenuOrder.ifEmpty { com.rtbishop.look4sat.core.presentation.defaultSubMenuOrder })
|
||||
.let { list -> if ("WavelogLog" in list) list else list + "WavelogLog" }
|
||||
.let { list -> if ("AMSAT" in list) list else list + "AMSAT" }
|
||||
val mainNavItems = remember(allNavItems, subOrder) {
|
||||
allNavItems.filter { it.screenId !in subOrder }.take(5)
|
||||
// Menu layout is resolved in core:domain so the bar and the settings editor
|
||||
// cannot disagree, and so Settings can never be pushed out of both menus.
|
||||
val allNavItems = listOf(
|
||||
Screen.Satellites, Screen.Passes, Screen.Radar, Screen.Mutual, Screen.Roaming,
|
||||
Screen.CwDecode, Screen.WavelogLog, Screen.AmSat, Screen.Map, Screen.Settings
|
||||
)
|
||||
val menuLayout = remember(
|
||||
otherSettings.screenOrder, otherSettings.subMenuOrder, otherSettings.hiddenScreens
|
||||
) {
|
||||
MenuLayout.resolve(
|
||||
allScreenIds = allNavItems.map { it.screenId },
|
||||
screenOrder = otherSettings.screenOrder,
|
||||
subMenuOrder = otherSettings.subMenuOrder,
|
||||
hiddenScreenIds = otherSettings.hiddenScreens
|
||||
)
|
||||
}
|
||||
val moreNavItems = remember(allNavItems, subOrder) {
|
||||
subOrder.mapNotNull { id -> allNavItems.find { it.screenId == id } }
|
||||
val mainNavItems = remember(menuLayout) {
|
||||
menuLayout.mainIds.mapNotNull { id -> allNavItems.find { it.screenId == id } }
|
||||
}
|
||||
val moreNavItems = remember(menuLayout) {
|
||||
menuLayout.moreIds.mapNotNull { id -> allNavItems.find { it.screenId == id } }
|
||||
}
|
||||
var moreExpanded by remember { mutableStateOf(false) }
|
||||
// 更多菜单打开时拦截返回: 先关菜单
|
||||
// Intercept Back while the More menu is open: close the menu first
|
||||
BackHandler(enabled = moreExpanded) { moreExpanded = false }
|
||||
// Activity-scoped so the mutual query results survive navigation to Radar and back
|
||||
val mutualViewModel: MutualViewModel = viewModel(
|
||||
@@ -188,20 +192,16 @@ fun MainScreen(navigateToRadar: () -> Unit = {}) {
|
||||
NavigationSuiteScaffold(
|
||||
navigationSuiteItems = {
|
||||
mainNavItems.forEach { screen ->
|
||||
val isSelected = when (currentKey) {
|
||||
is Screen.Satellites -> screen is Screen.Satellites
|
||||
is Screen.Passes -> screen is Screen.Passes
|
||||
is Screen.Radar -> screen is Screen.Radar
|
||||
is Screen.Mutual -> screen is Screen.Mutual
|
||||
is Screen.CwDecode -> screen is Screen.CwDecode
|
||||
is Screen.WavelogLog -> screen is Screen.WavelogLog
|
||||
is Screen.AmSat -> screen is Screen.AmSat
|
||||
is Screen.Map -> screen is Screen.Map
|
||||
is Screen.Settings -> screen is Screen.Settings
|
||||
else -> false
|
||||
}
|
||||
// Screen subclasses are data objects, so identity is enough and
|
||||
// newly added pages highlight without touching this call site.
|
||||
val isSelected = currentKey == screen
|
||||
item(
|
||||
icon = { Icon(painterResource(screen.iconResId), stringResource(screen.titleResId)) },
|
||||
icon = {
|
||||
Icon(
|
||||
painter = painterResource(screen.iconResId),
|
||||
contentDescription = stringResource(screen.titleResId)
|
||||
)
|
||||
},
|
||||
label = { Text(stringResource(screen.titleResId)) },
|
||||
selected = isSelected,
|
||||
onClick = {
|
||||
@@ -212,7 +212,7 @@ fun MainScreen(navigateToRadar: () -> Unit = {}) {
|
||||
}
|
||||
)
|
||||
}
|
||||
// 更多菜单按钮(固定第 6 槽, 子菜单非空才显示)
|
||||
// More-menu button (fixed slot 6; shown only when the sub-menu is non-empty)
|
||||
if (moreNavItems.isNotEmpty()) {
|
||||
item(
|
||||
icon = {
|
||||
@@ -260,7 +260,6 @@ fun MainScreen(navigateToRadar: () -> Unit = {}) {
|
||||
container.setMutualPassData(MutualPassData())
|
||||
container.satelliteRepo.selectPass(catNum, aosTime)
|
||||
backStack.add(Screen.Radar)
|
||||
// navigateToRadar()
|
||||
}
|
||||
}
|
||||
entry<Screen.Radar> {
|
||||
@@ -287,7 +286,7 @@ fun MainScreen(navigateToRadar: () -> Unit = {}) {
|
||||
CwDecodeScreen()
|
||||
}
|
||||
entry<Screen.AmSat> {
|
||||
SatStatusScreen(container = container)
|
||||
SatStatusDestination()
|
||||
}
|
||||
entry<Screen.WavelogLog> {
|
||||
WavelogLogScreen(queue = container.wavelogQueue)
|
||||
@@ -346,14 +345,18 @@ fun MainScreen(navigateToRadar: () -> Unit = {}) {
|
||||
}
|
||||
}
|
||||
}
|
||||
// 更多菜单弹出面板(覆盖在内容上, 底部栏上方; spring 弹跳)
|
||||
// More-menu popup panel (slim strip anchored to the bottom-right corner)
|
||||
AnimatedVisibility(
|
||||
visible = moreExpanded,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
enter = expandVertically(
|
||||
animationSpec = spring(dampingRatio = Spring.DampingRatioMediumBouncy)
|
||||
) + fadeIn(),
|
||||
exit = shrinkVertically() + fadeOut()
|
||||
enter = scaleIn(
|
||||
animationSpec = tween(150),
|
||||
transformOrigin = TransformOrigin(1f, 1f)
|
||||
) + fadeIn(animationSpec = tween(150)),
|
||||
exit = scaleOut(
|
||||
animationSpec = tween(120),
|
||||
transformOrigin = TransformOrigin(1f, 1f)
|
||||
) + fadeOut(animationSpec = tween(120))
|
||||
) {
|
||||
MoreMenuPopup(
|
||||
items = moreNavItems,
|
||||
@@ -369,4 +372,4 @@ fun MainScreen(navigateToRadar: () -> Unit = {}) {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,13 @@
|
||||
/*
|
||||
* MoreMenuPopup.kt — 底部导航「更多」二级菜单弹出面板(4.5.1)。
|
||||
* MoreMenuPopup.kt - bottom-nav "More" second-level menu popup panel (4.5.1).
|
||||
*
|
||||
* 覆盖在内容区上(底部栏上方, 右对齐), 竖排菜单项(图标+文本+箭头),
|
||||
* 当前页高亮; 点击遮罩关闭, 点击项跳转。弹出/收起动画由调用处
|
||||
* (MainScreen 的 AnimatedVisibility + spring)驱动。
|
||||
* A slim right-aligned strip above the bottom bar, vertical menu items (icon+text+arrow),
|
||||
* current page highlighted; tap outside to close, tap an item to navigate. No dimming scrim, so the
|
||||
* page stays readable. Open/close animation is driven by the caller (MainScreen's AnimatedVisibility).
|
||||
*/
|
||||
package com.rtbishop.look4sat
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
@@ -18,6 +18,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
@@ -45,31 +46,30 @@ fun MoreMenuPopup(
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colorScheme.scrim.copy(alpha = 0.35f))
|
||||
// Transparent catcher: taps outside the card dismiss the menu without
|
||||
// dimming the page behind it.
|
||||
.clickable(onClick = onDismiss)
|
||||
) {
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomEnd)
|
||||
.padding(12.dp),
|
||||
.padding(12.dp)
|
||||
.widthIn(max = 232.dp)
|
||||
// Swallow taps on the card so they do not reach the dismiss
|
||||
// catcher underneath.
|
||||
.clickable(enabled = false) {},
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.6f)),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainerHigh
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainer
|
||||
)
|
||||
) {
|
||||
Column(modifier = Modifier.padding(vertical = 4.dp)) {
|
||||
items.forEach { screen ->
|
||||
val isSelected = when (currentKey) {
|
||||
is Screen.Satellites -> screen is Screen.Satellites
|
||||
is Screen.Passes -> screen is Screen.Passes
|
||||
is Screen.Radar -> screen is Screen.Radar
|
||||
is Screen.Mutual -> screen is Screen.Mutual
|
||||
is Screen.CwDecode -> screen is Screen.CwDecode
|
||||
is Screen.WavelogLog -> screen is Screen.WavelogLog
|
||||
is Screen.Map -> screen is Screen.Map
|
||||
is Screen.Settings -> screen is Screen.Settings
|
||||
else -> false
|
||||
}
|
||||
// Screen subclasses are data objects, so identity is enough. The
|
||||
// old per-type when was missing AmSat and Roaming, leaving those
|
||||
// pages unhighlighted while open.
|
||||
val isSelected = currentKey == screen
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
|
||||
+39
-6
@@ -19,17 +19,50 @@ package com.rtbishop.look4sat.convention
|
||||
|
||||
import org.gradle.api.Plugin
|
||||
import org.gradle.api.Project
|
||||
import org.gradle.kotlin.dsl.dependencies
|
||||
import org.gradle.kotlin.dsl.configure
|
||||
import org.jetbrains.kotlin.gradle.dsl.KotlinMultiplatformExtension
|
||||
|
||||
/**
|
||||
* core:domain is the one module shared by every platform: it holds the orbital math, the data
|
||||
* models and the repository contracts, and none of it touches Android APIs. It is a Kotlin
|
||||
* Multiplatform module (JVM for Android, Kotlin/Native for iOS) rather than a JVM one so the
|
||||
* same compiled logic runs on both platforms instead of being reimplemented.
|
||||
*
|
||||
* Android modules consume the jvm target; the iOS app consumes the framework built from the
|
||||
* ios targets. Anything JVM-only - org.json, java.net, java.io, java.util.Locale,
|
||||
* String.format - cannot live in commonMain, because Kotlin/Native has none of them.
|
||||
*/
|
||||
@Suppress("Unused")
|
||||
internal class CoreDomainPlugin : Plugin<Project> {
|
||||
override fun apply(target: Project) = with(target) {
|
||||
applyPlugin(libs.plugins.kotlin.jvm)
|
||||
applyPlugin(libs.plugins.kotlin.multiplatform)
|
||||
applyPlugin(libs.plugins.kotlin.serialization)
|
||||
setupKotlin()
|
||||
dependencies {
|
||||
implementation(libs.kotlin.coroutines)
|
||||
implementation(libs.kotlin.serialization)
|
||||
extensions.configure<KotlinMultiplatformExtension> {
|
||||
jvmToolchain(libs.versions.jdkVersion.get().toInt())
|
||||
jvm()
|
||||
listOf(iosArm64(), iosSimulatorArm64()).forEach { iosTarget ->
|
||||
iosTarget.binaries.framework {
|
||||
baseName = "Look4SatCore"
|
||||
isStatic = true
|
||||
}
|
||||
}
|
||||
// The bare name accessors (commonMain, jvmTest, ...) are script-only syntax;
|
||||
// plugin source has to resolve through the container members, so configure each
|
||||
// source set by name. getByName is safe here: jvm() above has just created the
|
||||
// jvm source sets, the same pattern the local probe build script relies on.
|
||||
sourceSets.getByName("commonMain").dependencies {
|
||||
implementation(libs.kotlin.coroutines)
|
||||
implementation(libs.kotlin.serialization)
|
||||
}
|
||||
sourceSets.getByName("commonTest").dependencies {
|
||||
implementation(libs.kotlin.test)
|
||||
implementation(libs.test.coroutines)
|
||||
}
|
||||
// JVM-only tests live here: the AndroidManifest check reads the file system, and
|
||||
// the formatter oracle tests compare against java.lang.String.format.
|
||||
sourceSets.getByName("jvmTest").dependencies {
|
||||
implementation(libs.test.junit4)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+6
@@ -62,6 +62,12 @@ internal fun Project.setupAndroidApp() {
|
||||
versionCode = libs.versions.appVersionCode.get().toInt()
|
||||
versionName = libs.versions.appVersionName.get()
|
||||
}
|
||||
// The APRS login line reports the app version to every station on the network. It used
|
||||
// to be a literal in core:data and drifted twice, so the app module now reads
|
||||
// BuildConfig.VERSION_NAME - which AGP 8 only generates when asked.
|
||||
buildFeatures {
|
||||
buildConfig = true
|
||||
}
|
||||
buildTypes {
|
||||
debug {
|
||||
applicationIdSuffix = ".debug"
|
||||
|
||||
@@ -5,3 +5,9 @@ plugins {
|
||||
android {
|
||||
namespace = "com.rtbishop.look4sat.core.data"
|
||||
}
|
||||
|
||||
dependencies {
|
||||
// DeepCW 神经网络 CW 解码推理。ONNX 推理属 Android 平台依赖, 放此处而非
|
||||
// core:domain —— 后者须保持纯 Kotlin/JVM 以留 KMP 迁移余地 (见 AGENTS.md)。
|
||||
implementation(libs.other.onnxruntime)
|
||||
}
|
||||
@@ -1,16 +1,23 @@
|
||||
package com.rtbishop.look4sat.core.data.aprs
|
||||
|
||||
import com.rtbishop.look4sat.core.domain.aprs.AprsLogin
|
||||
import com.rtbishop.look4sat.core.domain.aprs.AprsPacket
|
||||
import java.io.BufferedReader
|
||||
import java.io.IOException
|
||||
import java.io.InputStreamReader
|
||||
import java.io.OutputStreamWriter
|
||||
import java.io.PrintWriter
|
||||
import java.net.InetSocketAddress
|
||||
import java.net.Socket
|
||||
import java.net.SocketTimeoutException
|
||||
|
||||
/**
|
||||
* APRS-IS TCP 客户端(从 APRSdroid TcpUploader.scala 逆向移植)。
|
||||
* 纯文本协议:登录一行 + 每行一个包;断线 30 秒重连。
|
||||
* APRS-IS TCP client. Plain text: the server greets, the client logs in, then one packet per line.
|
||||
*
|
||||
* Two things here decide whether the operator can trust the app at all. A packet sent on a dead
|
||||
* socket must not report success, and a login the server refused to verify must not look like a
|
||||
* working connection - an unverified client stays connected while the server silently drops
|
||||
* everything it sends.
|
||||
*/
|
||||
class AprsIsClient(
|
||||
private val host: String,
|
||||
@@ -18,6 +25,7 @@ class AprsIsClient(
|
||||
private val callsign: String,
|
||||
private val ssid: String,
|
||||
private val passcode: Int,
|
||||
private val softwareName: String,
|
||||
private val version: String,
|
||||
private val filter: String = "",
|
||||
private val timeoutSec: Int = 120
|
||||
@@ -27,70 +35,188 @@ class AprsIsClient(
|
||||
private var reader: BufferedReader? = null
|
||||
private val lock = Any()
|
||||
|
||||
val isConnected: Boolean
|
||||
get() = synchronized(lock) { socket?.isConnected == true && !socket!!.isClosed }
|
||||
/**
|
||||
* What the server said about this login, or null before a login has been attempted.
|
||||
*
|
||||
* Kept as state rather than only thrown, because [AprsLogin.Outcome.Unverified] is not a
|
||||
* connection error: the socket is up and writes succeed. The operator has to be told, or
|
||||
* they will watch reports "succeed" for hours while nothing reaches the network.
|
||||
*/
|
||||
@Volatile
|
||||
var loginOutcome: AprsLogin.Outcome? = null
|
||||
private set
|
||||
|
||||
/** 建立连接 + 登录(同步阻塞,调用方放后台线程) */
|
||||
val isConnected: Boolean
|
||||
get() = synchronized(lock) { socket?.isConnected == true && socket?.isClosed == false }
|
||||
|
||||
/** True when the server verified the passcode, so packets from this client are accepted. */
|
||||
val isVerified: Boolean get() = loginOutcome is AprsLogin.Outcome.Verified
|
||||
|
||||
/**
|
||||
* True only when the server told us it did NOT verify the login.
|
||||
*
|
||||
* Distinct from `!isVerified` on purpose. [AprsLogin.Outcome.Unverified] means the server
|
||||
* said so and really is discarding our packets. [AprsLogin.Outcome.Unknown] means we could
|
||||
* not recognise its answer - the packets may well be landing - so blaming the operator's
|
||||
* passcode for that would send them to fix something that is not broken.
|
||||
*/
|
||||
val isRefusedByServer: Boolean get() = loginOutcome is AprsLogin.Outcome.Unverified
|
||||
|
||||
/**
|
||||
* Connect and log in. Blocking; call from a background thread.
|
||||
*
|
||||
* Throws when the connection cannot be made or the server rejected the login outright.
|
||||
* A login the server accepted but did not verify returns normally and leaves
|
||||
* [loginOutcome] as [AprsLogin.Outcome.Unverified] for the caller to surface.
|
||||
*/
|
||||
@Throws(Exception::class)
|
||||
fun connect() {
|
||||
disconnect()
|
||||
loginOutcome = null
|
||||
val s = Socket()
|
||||
s.connect(InetSocketAddress(host, port), 30_000)
|
||||
s.soTimeout = timeoutSec * 1000
|
||||
s.tcpNoDelay = true
|
||||
synchronized(lock) {
|
||||
socket = s
|
||||
writer = PrintWriter(OutputStreamWriter(s.getOutputStream(), Charsets.ISO_8859_1), true)
|
||||
reader = BufferedReader(InputStreamReader(s.getInputStream(), Charsets.ISO_8859_1), 256)
|
||||
}
|
||||
// 登录行
|
||||
val login = AprsPacket.formatLogin(callsign, ssid, passcode, version) + filter
|
||||
writer?.println(login)
|
||||
// 读登录响应(aprsc 返回 # logresp ... verified/unverified)
|
||||
runCatching {
|
||||
s.soTimeout = 8000
|
||||
val resp = reader?.readLine()
|
||||
if (resp != null && (resp.contains("Invalid", ignoreCase = true) ||
|
||||
resp.contains("unverified", ignoreCase = true))) {
|
||||
throw IllegalArgumentException(resp.trim())
|
||||
try {
|
||||
s.connect(InetSocketAddress(host, port), CONNECT_MS)
|
||||
s.tcpNoDelay = true
|
||||
synchronized(lock) {
|
||||
socket = s
|
||||
writer = PrintWriter(OutputStreamWriter(s.getOutputStream(), Charsets.ISO_8859_1), true)
|
||||
reader = BufferedReader(InputStreamReader(s.getInputStream(), Charsets.ISO_8859_1), 256)
|
||||
}
|
||||
// 恢复超时
|
||||
s.soTimeout = LOGIN_MS
|
||||
// The spec has the client log in AFTER the server's identification line, so read the
|
||||
// greeting first. Anything starting with # is a comment and may be skipped.
|
||||
readGreeting(s)?.let { refusal ->
|
||||
// The server refused before we even logged in. Previously this verdict was
|
||||
// computed and then overwritten by readLoginResponse, so the branch was a lie.
|
||||
loginOutcome = refusal
|
||||
throw IllegalArgumentException(refusal.detail)
|
||||
}
|
||||
val login = AprsLogin.line(callsign, ssid, passcode, softwareName, version, filter)
|
||||
writer?.print(login)
|
||||
writer?.print(CRLF)
|
||||
writer?.flush()
|
||||
loginOutcome = readLoginResponse()
|
||||
s.soTimeout = timeoutSec * 1000
|
||||
val outcome = loginOutcome
|
||||
if (outcome is AprsLogin.Outcome.Rejected) throw IllegalArgumentException(outcome.detail)
|
||||
} catch (e: Exception) {
|
||||
// Close the local socket before re-throwing so it does not leak when the failure
|
||||
// lands after connect() but before the field assignment - otherwise periodic
|
||||
// reconnects accumulate file descriptors until no more can be opened.
|
||||
runCatching { s.close() }
|
||||
synchronized(lock) {
|
||||
writer = null
|
||||
reader = null
|
||||
socket = null
|
||||
}
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送一个 APRS 包(一行)并尝试读服务器确认。
|
||||
* 返回 null=发送失败;Pair(ok, detail)=发送结果(服务器错误原文在 detail)
|
||||
* Consume the server's greeting comment, returning a refusal when it is not one.
|
||||
*
|
||||
* Absence is tolerated because some servers send none, but on a short probe window rather
|
||||
* than the full login timeout: waiting LOGIN_MS for a greeting that will never come cost
|
||||
* eight seconds on every single connect to such a server.
|
||||
*/
|
||||
private fun readGreeting(socket: Socket): AprsLogin.Outcome.Rejected? {
|
||||
val previous = socket.soTimeout
|
||||
return try {
|
||||
socket.soTimeout = GREETING_MS
|
||||
val line = reader?.readLine() ?: return null
|
||||
// A server that opens with anything but a comment is refusing us.
|
||||
if (line.startsWith("#")) null else AprsLogin.Outcome.Rejected(line.trim())
|
||||
} catch (ignored: IOException) {
|
||||
null
|
||||
} finally {
|
||||
runCatching { socket.soTimeout = previous }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read lines until the login verdict arrives, skipping keepalive comments.
|
||||
*
|
||||
* Bounded by the read timeout, so an unresponsive server cannot hang the caller.
|
||||
*/
|
||||
private fun readLoginResponse(): AprsLogin.Outcome {
|
||||
// Bounded by a deadline, not a line count: comments are free to skip, and a chatty
|
||||
// server that sent six of them before its verdict used to exhaust a fixed budget and
|
||||
// turn an accepted login into Unknown - telling the operator their passcode was wrong
|
||||
// when it had just been accepted.
|
||||
val deadline = System.currentTimeMillis() + LOGIN_MS
|
||||
while (System.currentTimeMillis() < deadline) {
|
||||
val line = try {
|
||||
reader?.readLine()
|
||||
} catch (timeout: SocketTimeoutException) {
|
||||
return AprsLogin.Outcome.Unknown("no response within ${LOGIN_MS}ms")
|
||||
} catch (failure: IOException) {
|
||||
return AprsLogin.Outcome.Rejected(failure.message ?: "login read failed")
|
||||
} ?: return AprsLogin.Outcome.Rejected("connection closed during login")
|
||||
AprsLogin.parse(line)?.let { return it }
|
||||
}
|
||||
return AprsLogin.Outcome.Unknown("no login response recognised")
|
||||
}
|
||||
|
||||
/**
|
||||
* Send one packet and report what happened.
|
||||
*
|
||||
* Returns null when there is no connection to write to. Otherwise a pair of whether the
|
||||
* packet went out and a detail string for the operator.
|
||||
*/
|
||||
fun sendPacket(packetLine: String): Pair<Boolean, String>? {
|
||||
synchronized(lock) {
|
||||
val w = writer ?: return null
|
||||
w.println(packetLine)
|
||||
// Reading the response inside the same lock: disconnect() may run concurrently and
|
||||
// null these fields, and reading outside the lock raced with that - the read could
|
||||
// hit a just-closed socket and be reported as a successful send.
|
||||
// CRLF explicitly rather than println: the spec requires "TNC2 format terminated by
|
||||
// a carriage return, line feed sequence", and println emits the platform separator,
|
||||
// a bare LF on Android. An earlier draft of this method sent no terminator at all,
|
||||
// which leaves the server's line reader waiting forever while every send reports
|
||||
// success - exactly the failure this class exists to prevent.
|
||||
w.print(packetLine)
|
||||
w.print(CRLF)
|
||||
w.flush()
|
||||
if (w.checkError()) return Pair(false, "write failed")
|
||||
}
|
||||
// 尝试读服务器响应(短超时 3s;APRS-IS 对格式错误会回错误行)
|
||||
return runCatching {
|
||||
val s = socket ?: return@runCatching Pair(true, "OK")
|
||||
val oldTimeout = s.soTimeout
|
||||
s.soTimeout = 3000
|
||||
try {
|
||||
val resp = reader?.readLine()
|
||||
if (resp != null && (resp.contains("Invalid", ignoreCase = true) ||
|
||||
resp.contains("error", ignoreCase = true))) {
|
||||
Pair(false, resp.trim())
|
||||
} else {
|
||||
Pair(true, if (resp.isNullOrBlank()) "OK" else resp.trim())
|
||||
}
|
||||
val s = socket ?: return Pair(false, "not connected")
|
||||
val previousTimeout = s.soTimeout
|
||||
return try {
|
||||
s.soTimeout = ACK_MS
|
||||
classifyAck(reader?.readLine())
|
||||
} catch (timeout: SocketTimeoutException) {
|
||||
// Silence is the normal case: APRS-IS does not acknowledge a position report, so
|
||||
// nothing arriving means the line went out and the server had nothing to say.
|
||||
// Telling this apart from a broken connection is the point of this method - the
|
||||
// previous version treated EVERY exception as success, so a dead socket reported
|
||||
// "sent OK" and made every real failure invisible, including a rejected login.
|
||||
Pair(true, "sent")
|
||||
} catch (failure: IOException) {
|
||||
Pair(false, failure.message ?: "read failed")
|
||||
} finally {
|
||||
s.soTimeout = oldTimeout
|
||||
runCatching { s.soTimeout = previousTimeout }
|
||||
}
|
||||
}.getOrElse { Pair(true, "OK") }
|
||||
}
|
||||
}
|
||||
|
||||
/** 读一行(服务器响应,超时抛异常) */
|
||||
fun readLine(): String? {
|
||||
return reader?.readLine()
|
||||
/**
|
||||
* Interpret whatever the server sent back after a packet.
|
||||
*
|
||||
* Shares AprsLogin's judgement rather than keeping its own, because the two disagreed in a way
|
||||
* that mattered: treating any leading `#` as harmless meant `# Port full` and `# Login by user
|
||||
* not allowed` - both of which mean the server is about to drop us - were reported as a
|
||||
* successful send. APRS-IS does not acknowledge position reports, so a harmless comment still
|
||||
* counts as sent; anything the server says that is not harmless does not.
|
||||
*/
|
||||
private fun classifyAck(response: String?): Pair<Boolean, String> {
|
||||
if (response == null) return Pair(false, "connection closed by server")
|
||||
return when (val verdict = AprsLogin.parse(response)) {
|
||||
// A keepalive or identification comment: no verdict, so the write stands.
|
||||
null -> Pair(true, "sent")
|
||||
is AprsLogin.Outcome.Rejected -> Pair(false, verdict.detail)
|
||||
// A late login verdict is not about this packet, and loginOutcome already carries it.
|
||||
else -> Pair(true, "sent")
|
||||
}
|
||||
}
|
||||
|
||||
fun disconnect() {
|
||||
@@ -103,4 +229,16 @@ class AprsIsClient(
|
||||
socket = null
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
/** Line terminator the protocol requires, independent of the platform's own. */
|
||||
const val CRLF = "\r\n"
|
||||
|
||||
const val CONNECT_MS = 30_000
|
||||
const val LOGIN_MS = 8_000
|
||||
const val ACK_MS = 3_000
|
||||
|
||||
/** Probe window for the greeting, short because its absence is legitimate. */
|
||||
const val GREETING_MS = 2_000
|
||||
}
|
||||
}
|
||||
@@ -1,19 +1,18 @@
|
||||
package com.rtbishop.look4sat.core.data.aprs
|
||||
|
||||
import com.rtbishop.look4sat.core.domain.aprs.AprsPacket
|
||||
import com.rtbishop.look4sat.core.domain.aprs.AprsPosition
|
||||
import com.rtbishop.look4sat.core.domain.aprs.AprsBeacon
|
||||
import com.rtbishop.look4sat.core.domain.aprs.AprsPasscode
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/** APRS 连接状态 */
|
||||
/** APRS connection state */
|
||||
enum class AprsState { Idle, Connecting, Connected, Disconnected, Error }
|
||||
|
||||
/** APRS 配置(SharedPreferences 持久化,填入即保存) */
|
||||
/** APRS config (persisted in SharedPreferences, saved as filled) */
|
||||
data class AprsConfig(
|
||||
val enabled: Boolean = false,
|
||||
val server: String = "euro.aprs2.net",
|
||||
@@ -29,17 +28,44 @@ data class AprsConfig(
|
||||
val includeAltitude: Boolean = true
|
||||
)
|
||||
|
||||
/** APRS 上报结果 */
|
||||
/** APRS report result */
|
||||
data class AprsReport(
|
||||
val timestamp: Long,
|
||||
val packet: String,
|
||||
val ok: Boolean,
|
||||
val detail: String
|
||||
val detail: String,
|
||||
/**
|
||||
* False only when the server told us it did not verify the login.
|
||||
*
|
||||
* Carried separately from [ok] because the two are independent: a refused client's writes
|
||||
* still succeed, so the packet leaves the phone and looks sent, while aprsc discards every
|
||||
* one of them. Without surfacing it the operator can watch reports succeed for hours with
|
||||
* nothing reaching the network. A login whose response we simply could not parse leaves this
|
||||
* true, since the packets may be landing and the passcode is not at fault.
|
||||
*/
|
||||
val verified: Boolean = true,
|
||||
/**
|
||||
* True when the operator asked for a receive-only connection.
|
||||
*
|
||||
* Distinguished from a refused login because both log in with -1 and the server answers
|
||||
* "unverified" to each: without this, deliberately choosing receive-only - the one way to
|
||||
* test a setup without putting anything on the network - was reported as a wrong passcode
|
||||
* and sent the operator to fix something they had set on purpose.
|
||||
*/
|
||||
|
||||
)
|
||||
|
||||
/** 上报调度器(周期上报 + 手动触发),连接管理放在前台服务 */
|
||||
/** Report scheduler (periodic + manual trigger); connection management lives in the foreground service */
|
||||
class AprsReporter(
|
||||
private val configProvider: () -> AprsConfig,
|
||||
/**
|
||||
* The app's own version, reported to APRS-IS in the login line.
|
||||
*
|
||||
* Passed in because core:data has no BuildConfig. It used to be a literal here and drifted
|
||||
* exactly as predicted: it still read 4.6.0 two releases later, so every station on the
|
||||
* network was told the wrong version. A caller in the app module can read the real one.
|
||||
*/
|
||||
private val appVersion: String,
|
||||
private val positionProvider: () -> Pair<Double, Double>? = { null },
|
||||
private val onState: (AprsState) -> Unit = {},
|
||||
private val onReport: (AprsReport) -> Unit = {}
|
||||
@@ -51,7 +77,7 @@ class AprsReporter(
|
||||
|
||||
val isRunning: Boolean get() = job?.isActive == true
|
||||
|
||||
/** 启动周期上报(前台服务调用) */
|
||||
/** Start periodic reporting (called by the foreground service) */
|
||||
fun start() {
|
||||
stop()
|
||||
val cfg = configProvider()
|
||||
@@ -59,12 +85,11 @@ class AprsReporter(
|
||||
onState(AprsState.Error)
|
||||
return
|
||||
}
|
||||
job = scope.launch {
|
||||
while (isActive) {
|
||||
reportOnce()
|
||||
delay(cfg.intervalMin.coerceAtLeast(1) * 60_000L)
|
||||
}
|
||||
}
|
||||
// One report now; the service's exact alarm drives every one after this. The loop that
|
||||
// used to live here relied on a coroutine delay, which Doze defeats - the timer fires on
|
||||
// schedule and then finds network access suspended, so the beacon stopped whenever the
|
||||
// screen locked while the notification still claimed it was running.
|
||||
job = scope.launch { reportOnce() }
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
@@ -75,7 +100,7 @@ class AprsReporter(
|
||||
onState(AprsState.Idle)
|
||||
}
|
||||
|
||||
/** 手动触发一次(立即上报,不等待周期) */
|
||||
/** Trigger one report manually (immediately, without waiting for the cycle) */
|
||||
fun reportNow() {
|
||||
manualJob?.cancel()
|
||||
manualJob = scope.launch { reportOnce() }
|
||||
@@ -86,41 +111,92 @@ class AprsReporter(
|
||||
if (!cfg.enabled || cfg.callsign.isBlank()) return
|
||||
onState(AprsState.Connecting)
|
||||
try {
|
||||
// The packet is built BEFORE connecting: there is no reason to open a session and log
|
||||
// in only to discover there is nothing to send, which happened every five minutes for
|
||||
// an operator whose QTH was unset.
|
||||
val pos = positionProvider()
|
||||
val beacon = AprsBeacon.build(
|
||||
callsign = cfg.callsign,
|
||||
ssid = cfg.ssid,
|
||||
latitude = pos?.first,
|
||||
longitude = pos?.second,
|
||||
symbolTable = cfg.symbolTable,
|
||||
symbolCode = cfg.symbolCode,
|
||||
comment = cfg.statusText
|
||||
)
|
||||
// Nothing goes out without a position. Substituting 0,0 put this station in the Gulf
|
||||
// of Guinea on the global network, under the operator's own callsign.
|
||||
if (beacon is AprsBeacon.Result.Blocked) {
|
||||
onState(AprsState.Error)
|
||||
onReport(
|
||||
AprsReport(System.currentTimeMillis(), "", false, refusalDetail(beacon.refusal))
|
||||
)
|
||||
return
|
||||
}
|
||||
val packetLine = (beacon as AprsBeacon.Result.Line).text
|
||||
|
||||
val c = client ?: AprsIsClient(
|
||||
host = cfg.server,
|
||||
port = cfg.port,
|
||||
callsign = cfg.callsign,
|
||||
ssid = cfg.ssid,
|
||||
passcode = cfg.passcode.toIntOrNull()?.takeIf { it >= 0 } ?: AprsPacket.passcode(cfg.callsign),
|
||||
version = "Look4Sat 4.5.4"
|
||||
// Never derives one: a blank or wrong entry logs in receive-only rather than
|
||||
// transmitting under a passcode the app invented for an unchecked licence.
|
||||
passcode = AprsPasscode.loginValue(cfg.callsign, cfg.passcode),
|
||||
// Two fields, because APRS-IS wants `vers <name> <version>` as separate tokens.
|
||||
softwareName = "Look4Sat",
|
||||
version = appVersion
|
||||
).also { client = it }
|
||||
if (!c.isConnected) c.connect()
|
||||
onState(AprsState.Connected)
|
||||
|
||||
val pos = positionProvider()
|
||||
val packetLine = buildPositionPacket(cfg, pos?.first, pos?.second)
|
||||
val result = c.sendPacket(packetLine)
|
||||
val ok = result?.first == true
|
||||
val sent = result?.first == true
|
||||
val detail = result?.second ?: "no connection"
|
||||
onReport(AprsReport(System.currentTimeMillis(), packetLine, ok, detail))
|
||||
// A write that succeeded on a login the server refused to verify is not a delivered
|
||||
// packet: aprsc takes it and drops it, which is what let every real failure hide.
|
||||
// Only an explicit refusal counts against us though - a login whose response we
|
||||
// could not parse may be working fine, and blaming the passcode for that would send
|
||||
// the operator to fix something that is not broken.
|
||||
val refused = c.isRefusedByServer
|
||||
val ok = sent && !refused
|
||||
// "sent" is the write's own verdict and reads as nonsense next to a failure - the card
|
||||
// showed "failed - sent" for a refused login. When the refusal is what failed the
|
||||
// report, say that instead.
|
||||
val reported = when {
|
||||
ok -> detail
|
||||
refused -> "login not verified"
|
||||
else -> detail
|
||||
}
|
||||
onReport(
|
||||
AprsReport(
|
||||
System.currentTimeMillis(), packetLine, ok, reported,
|
||||
verified = !refused
|
||||
)
|
||||
)
|
||||
if (ok) onState(AprsState.Connected) else onState(AprsState.Error)
|
||||
} catch (e: Exception) {
|
||||
runCatching { client?.disconnect() }
|
||||
client = null
|
||||
onState(AprsState.Error)
|
||||
onReport(AprsReport(System.currentTimeMillis(), "", false, e.message ?: "error"))
|
||||
onReport(AprsReport(System.currentTimeMillis(), "", false, e.message ?: "error", false))
|
||||
}
|
||||
}
|
||||
|
||||
/** 构造位置包:BG7NTA-5>APRS:=DDMM.MMN/DDDMM.MME<状态文本 */
|
||||
private fun buildPositionPacket(cfg: AprsConfig, lat: Double? = null, lon: Double? = null): String {
|
||||
val source = AprsPacket.formatCallSsid(cfg.callsign, cfg.ssid)
|
||||
val pos = AprsPosition(
|
||||
latitude = lat ?: 0.0,
|
||||
longitude = lon ?: 0.0,
|
||||
symbolTable = cfg.symbolTable.firstOrNull() ?: '/',
|
||||
symbolCode = cfg.symbolCode.firstOrNull() ?: '>'
|
||||
)
|
||||
return "$source>APRS:=${pos.toUncompressedString()}${cfg.statusText}"
|
||||
|
||||
/** A short reason for a refusal, for the operator's last-report line. */
|
||||
private fun refusalDetail(refusal: AprsBeacon.Refusal): String = when (refusal) {
|
||||
AprsBeacon.Refusal.NoPosition -> "no position yet"
|
||||
AprsBeacon.Refusal.NoCallsign -> "no callsign set"
|
||||
is AprsBeacon.Refusal.ImpossiblePosition -> "position out of range"
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
companion object {
|
||||
|
||||
/** Floor for the reporting interval, in minutes. */
|
||||
const val MIN_INTERVAL_MIN = 5
|
||||
}
|
||||
|
||||
}
|
||||
@@ -3,8 +3,8 @@ package com.rtbishop.look4sat.core.data.aprs
|
||||
import android.content.Context
|
||||
|
||||
/**
|
||||
* APRS 配置存取 + 服务 action 常量(被 feature/settings 和 app 服务共用,
|
||||
* 避免 feature 反向依赖 app)。
|
||||
* APRS config storage + service action constants (shared by feature/settings and the app service,
|
||||
* so feature never depends on app).
|
||||
*/
|
||||
object AprsStore {
|
||||
|
||||
@@ -24,11 +24,20 @@ object AprsStore {
|
||||
private const val KEY_STATUS = "status"
|
||||
private const val KEY_SYMBOL_TABLE = "symbol_table"
|
||||
private const val KEY_SYMBOL_CODE = "symbol_code"
|
||||
|
||||
/**
|
||||
* A house, not a car.
|
||||
*
|
||||
* Only fresh installs see this: saveConfig writes every key unconditionally and the enable
|
||||
* switch calls it, so anyone who has ever turned APRS on has both symbol keys on disk and this
|
||||
* fallback cannot reach them.
|
||||
*/
|
||||
private const val DEFAULT_SYMBOL_CODE = "-"
|
||||
private const val KEY_LAST_TIME = "last_report_time"
|
||||
private const val KEY_LAST_OK = "last_report_ok"
|
||||
private const val KEY_LAST_DETAIL = "last_report_detail"
|
||||
|
||||
/** 读取配置(填入即保存,无需每次打开重填) */
|
||||
/** Read config (saved as filled; no need to re-enter each time) */
|
||||
fun loadConfig(context: Context): AprsConfig {
|
||||
val p = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||
return AprsConfig(
|
||||
@@ -41,11 +50,11 @@ object AprsStore {
|
||||
intervalMin = p.getInt(KEY_INTERVAL, 5),
|
||||
statusText = p.getString(KEY_STATUS, "Look4Sat APRS") ?: "Look4Sat APRS",
|
||||
symbolTable = p.getString(KEY_SYMBOL_TABLE, "/") ?: "/",
|
||||
symbolCode = p.getString(KEY_SYMBOL_CODE, ">") ?: ">"
|
||||
symbolCode = p.getString(KEY_SYMBOL_CODE, DEFAULT_SYMBOL_CODE) ?: DEFAULT_SYMBOL_CODE
|
||||
)
|
||||
}
|
||||
|
||||
/** 上次上报结果(供设置卡片显示) */
|
||||
/** Last report result (shown on the settings card) */
|
||||
data class LastReport(val time: Long = 0L, val ok: Boolean = false, val detail: String = "")
|
||||
|
||||
fun loadLastReport(context: Context): LastReport {
|
||||
@@ -65,7 +74,7 @@ object AprsStore {
|
||||
.apply()
|
||||
}
|
||||
|
||||
/** 保存配置 */
|
||||
/** Save config */
|
||||
fun saveConfig(context: Context, cfg: AprsConfig) {
|
||||
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit()
|
||||
.putBoolean(KEY_ENABLED, cfg.enabled)
|
||||
|
||||
@@ -0,0 +1,771 @@
|
||||
/*
|
||||
* 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.cw
|
||||
|
||||
import ai.onnxruntime.OnnxTensor
|
||||
import ai.onnxruntime.OrtEnvironment
|
||||
import ai.onnxruntime.OrtSession
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import com.rtbishop.look4sat.core.domain.cw.CwCtcDecoder
|
||||
import com.rtbishop.look4sat.core.domain.cw.CwDeepBuffer
|
||||
import com.rtbishop.look4sat.core.domain.cw.CwAntiAlias
|
||||
import com.rtbishop.look4sat.core.domain.cw.CwDeepSpectrogram
|
||||
import com.rtbishop.look4sat.core.domain.cw.CwDetectionPool
|
||||
import com.rtbishop.look4sat.core.domain.cw.CwShiftDecider
|
||||
import com.rtbishop.look4sat.core.domain.cw.CwToneShifter
|
||||
import com.rtbishop.look4sat.core.domain.cw.ICwDecoder
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.json.JSONObject
|
||||
import java.nio.FloatBuffer
|
||||
|
||||
/**
|
||||
* CW decoder backed by the DeepCW neural network (AGPL-3.0, see
|
||||
* `feature/cw/licenses/NOTICE.md`).
|
||||
*
|
||||
* The model classifies a whole audio segment at once rather than streaming
|
||||
* sample by sample, and it revises earlier characters once more context
|
||||
* arrives. Incremental stitching therefore produces duplicated callsigns —
|
||||
* measured character error rates of 67-294% against 0% for whole-segment
|
||||
* decoding. Instead a [CwDeepBuffer] holds the last 20 seconds and the whole
|
||||
* window is re-decoded every 1.5 seconds, replacing [decodedText] outright.
|
||||
*
|
||||
* The model's fixed 400-1200 Hz analysis window means pitch detection is built
|
||||
* in; no spectral peak tracking or squelch gating is needed. A tone outside that
|
||||
* window is invisible to the model, so [CwToneShifter] can optionally move it in —
|
||||
* see [isToneShiftEnabled].
|
||||
*
|
||||
* @param isToneShiftEnabled read on every chunk so toggling the setting takes effect
|
||||
* without rebuilding the decoder. Defaults to disabled: with it off the audio path
|
||||
* is byte-for-byte what it was before the feature existed.
|
||||
*/
|
||||
class CwDeepDecoder(
|
||||
context: Context,
|
||||
private val isToneShiftEnabled: () -> Boolean = { false }
|
||||
) : ICwDecoder {
|
||||
|
||||
// internal, not private: the archive timing is user-visible behaviour and its test asserts
|
||||
// against these constants directly rather than a copy that could silently drift.
|
||||
internal companion object {
|
||||
const val TAG = "CwDeepDecoder"
|
||||
const val MODEL_ASSET = "deepcw/model.onnx"
|
||||
const val METADATA_ASSET = "deepcw/model.onnx.json"
|
||||
|
||||
/**
|
||||
* Evicted audio is decoded into permanent history once this much accumulates.
|
||||
*
|
||||
* This is the delay before decoded text reaches the record, and it is additive with
|
||||
* the 20 s live window: at the old 15 s the record showed nothing for the first 35 s
|
||||
* of a session, and thereafter text that had scrolled out of the live window sat
|
||||
* invisible for up to 15 s before landing - the record appeared to stall and, when
|
||||
* it was still concatenating the live window, to delete what it had just shown.
|
||||
*
|
||||
* Each batch is one full inference, so this trades CPU for latency. 4 s holds the gap
|
||||
* under the ~4.7 s a seven-character call sign takes at 18 WPM - the record must not
|
||||
* stall for longer than the one thing an operator most needs to read back - while the
|
||||
* archive path still fires less than half as often as the 1.5 s live redecode cycle.
|
||||
*/
|
||||
const val ARCHIVE_SECONDS = 4.0
|
||||
val ARCHIVE_THRESHOLD: Int = (CwDeepSpectrogram.SAMPLE_RATE * ARCHIVE_SECONDS).toInt()
|
||||
|
||||
/**
|
||||
* Samples the detector needs for a usable estimate: 0.4 s at 3200 Hz, giving
|
||||
* ~12.5 Hz resolution.
|
||||
*
|
||||
* A capture chunk is ~100 ms, which is 4410 samples at the 44.1 kHz capture
|
||||
* rate but only 320 after resampling to 3200 Hz. Gating on a single chunk
|
||||
* reaching this size would therefore never fire, so chunks are accumulated in
|
||||
* [detectionPool] until enough audio is available.
|
||||
*/
|
||||
const val DETECT_MIN_SAMPLES = 1280
|
||||
|
||||
/** Detection cadence; re-running it on every 100 ms chunk would be wasteful. */
|
||||
const val DETECT_INTERVAL_MS = 2000
|
||||
|
||||
/** Silence after which a tone reading is treated as stale. See runDetection. */
|
||||
const val TONE_EXPIRY_MS = 10_000L
|
||||
|
||||
/**
|
||||
* Minimum change in the required shift before the window is re-shifted.
|
||||
*
|
||||
* Two scan bins (12.5 Hz each) plus margin. Re-shifting drops the 20 s decode
|
||||
* window, so a tone drifting slightly - or the estimate hopping to an adjacent
|
||||
* bin - must not keep wiping context that is still perfectly decodable.
|
||||
*/
|
||||
const val SHIFT_HYSTERESIS_HZ = 40f
|
||||
}
|
||||
|
||||
private val _decodedText = MutableStateFlow("")
|
||||
override val decodedText: StateFlow<String> = _decodedText.asStateFlow()
|
||||
|
||||
/**
|
||||
* Archived text: only appended to, so a pane bound to it never loses what it showed.
|
||||
*
|
||||
* This once carried a provisional tail meant to cover the gap while audio waited to be
|
||||
* archived, but the decode feeding that tail was never wired up, so the tail was always
|
||||
* empty and the gap stayed. It is closed instead by archiving in [ARCHIVE_SECONDS]
|
||||
* batches, which no longer concatenate anything that gets rewritten.
|
||||
*/
|
||||
private val _historyText = MutableStateFlow("")
|
||||
override val historyText: StateFlow<String> = _historyText.asStateFlow()
|
||||
|
||||
/** Permanently archived text; the provisional tail is appended to this for display. */
|
||||
private var committedText = ""
|
||||
|
||||
private val _estimatedPitch = MutableStateFlow<Float?>(null)
|
||||
override val estimatedPitch: StateFlow<Float?> = _estimatedPitch.asStateFlow()
|
||||
|
||||
private val _detectedToneHz = MutableStateFlow<Float?>(null)
|
||||
override val detectedToneHz: StateFlow<Float?> = _detectedToneHz.asStateFlow()
|
||||
|
||||
private val _activeShiftHz = MutableStateFlow(0f)
|
||||
override val activeShiftHz: StateFlow<Float> = _activeShiftHz.asStateFlow()
|
||||
|
||||
private val _signalStrength = MutableStateFlow(0f)
|
||||
override val signalStrength: StateFlow<Float> = _signalStrength.asStateFlow()
|
||||
|
||||
private val _lastInferenceMs = MutableStateFlow(0)
|
||||
override val lastInferenceMs: StateFlow<Int> = _lastInferenceMs.asStateFlow()
|
||||
|
||||
private val _errorMessage = MutableStateFlow<String?>(null)
|
||||
override val errorMessage: StateFlow<String?> = _errorMessage.asStateFlow()
|
||||
|
||||
private val buffer = CwDeepBuffer()
|
||||
|
||||
/**
|
||||
* Evicted audio accumulates here until it reaches [ARCHIVE_SECONDS], then is decoded
|
||||
* once and appended to [historyText]. The batch stays short enough that the record does
|
||||
* not visibly stall, and accuracy barely suffers: this content has already been through
|
||||
* the 20 s window many times, so the archive decode is a confirmation, not a first look.
|
||||
*
|
||||
* Sized to the full window rather than the batch: [flush] hands over whatever the live
|
||||
* window holds, which can be the whole 20 s.
|
||||
*/
|
||||
private val archiveBuffer = FloatArray(CwDeepBuffer.DEFAULT_MAX_SECONDS.toInt() * CwDeepSpectrogram.SAMPLE_RATE)
|
||||
private var archiveSize = 0
|
||||
|
||||
/**
|
||||
* Window contents retired by a change of shift, waiting to be archived.
|
||||
*
|
||||
* The live window is the only route into the archive - audio gets there by being pushed
|
||||
* out - so clearing the window used to mean its audio was never decoded at all. When the
|
||||
* shift changed more often than the window took to fill, that was every sample: modelled
|
||||
* at 18 WPM with a drift every 20 s, five minutes of listening archived nothing whatever.
|
||||
* Synchronised on itself: written from the capture path and drained from both there and
|
||||
* [flush], which run on different coroutines. Capped, because a signal drifting on every
|
||||
* detection scan would otherwise queue windows faster than they can be decoded and grow
|
||||
* without bound; past the cap the oldest goes, since newer audio is what is being read.
|
||||
*/
|
||||
private val retiredAudio = ArrayDeque<FloatArray>()
|
||||
|
||||
/** Windows held awaiting archival before the oldest is dropped. */
|
||||
private val retiredAudioLimit = 4
|
||||
|
||||
/** Held while inference runs so slow devices skip work instead of queuing it. */
|
||||
private val inferenceLock = Mutex()
|
||||
|
||||
/**
|
||||
* Serialises the archive path, which mutates state the capture coroutine also touches.
|
||||
*
|
||||
* [flush] runs on a different coroutine from [processBuffer] - on pause, and from the app
|
||||
* scope as the screen leaves - and both append to [committedText], which is a read, an
|
||||
* inference lasting hundreds of milliseconds, and only then a write. Interleaved, the
|
||||
* later write wins and a whole batch of text is lost, precisely at the moment the
|
||||
* operator stops listening and starts reading. They also both touch [archiveBuffer] and
|
||||
* [archiveSize]: a reset of the index under a snapshot that already covered those slots
|
||||
* makes the same audio decode twice.
|
||||
*
|
||||
* Not [inferenceLock]: that one is a tryLock, dropping work when contended, which is
|
||||
* right for the live window (another decode is 1.5 s away) and wrong here (dropping an
|
||||
* archive batch discards the audio for good).
|
||||
*/
|
||||
private val archiveLock = Mutex()
|
||||
|
||||
/** Decides what shift to apply from successive tone estimates. */
|
||||
private val shiftDecider = CwShiftDecider(SHIFT_HYSTERESIS_HZ)
|
||||
|
||||
/** Wall clock of the last scan that actually found a tone, for [TONE_EXPIRY_MS]. */
|
||||
private var lastToneAtMs = 0L
|
||||
|
||||
/** Wall clock of the last detection scan, throttling it to [DETECT_INTERVAL_MS]. */
|
||||
private var lastDetectAtMs = 0L
|
||||
|
||||
/**
|
||||
* Pools resampled chunks until [DETECT_MIN_SAMPLES] is reached. A single capture
|
||||
* chunk is only 320 samples once resampled, so detection has to pool several.
|
||||
*/
|
||||
private val detectionPool = CwDetectionPool(DETECT_MIN_SAMPLES)
|
||||
|
||||
/** Carries Hilbert filter history and mixer phase across capture chunks. */
|
||||
private val streamingShifter = CwToneShifter.Streaming()
|
||||
|
||||
/**
|
||||
* Anti-alias filter for the decimation to [CwDeepSpectrogram.SAMPLE_RATE].
|
||||
*
|
||||
* Built on the first chunk because the capture rate is not known until then. Without
|
||||
* it everything above 1600 Hz folds into the window: a 3000 Hz tone reappeared at
|
||||
* 200 Hz at 119 times the spectral mean, and the whole 1600-22050 Hz band of hiss
|
||||
* folded down on top of the signal.
|
||||
*/
|
||||
private var antiAlias: CwAntiAlias.Streaming? = null
|
||||
private var antiAliasRate = 0
|
||||
|
||||
/**
|
||||
* Previous value of the setting, so a toggle can invalidate buffered audio.
|
||||
* Null until the first chunk: a decoder created while the setting is already on
|
||||
* must not treat that as a change and wipe an empty buffer.
|
||||
*/
|
||||
private var toneShiftWasEnabled: Boolean? = null
|
||||
|
||||
private var environment: OrtEnvironment? = null
|
||||
private var session: OrtSession? = null
|
||||
private var chars: List<String> = emptyList()
|
||||
private var blankIndex = 41
|
||||
private var inputName = "spectrogram"
|
||||
private var outputName = "log_probs"
|
||||
|
||||
private val appContext = context.applicationContext
|
||||
private var loadAttempted = false
|
||||
|
||||
init {
|
||||
CwProbe.init(appContext)
|
||||
CwProbe.step("decoder_constructed")
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads metadata and the ONNX session on first use.
|
||||
*
|
||||
* Deliberately not done in `init`: loading pulls in ONNX Runtime's native
|
||||
* library, and a failure there surfaces as [UnsatisfiedLinkError]. Thrown
|
||||
* from a constructor it would take down the whole composable that created
|
||||
* the decoder, so the work happens here where it can be reported through
|
||||
* [errorMessage] instead.
|
||||
*
|
||||
* @return true when the session is ready to run.
|
||||
*/
|
||||
private fun ensureLoaded(): Boolean {
|
||||
if (session != null) return true
|
||||
if (loadAttempted) return false
|
||||
loadAttempted = true
|
||||
CwProbe.step("load_begin")
|
||||
try {
|
||||
val metadata = JSONObject(
|
||||
appContext.assets.open(METADATA_ASSET).bufferedReader().use { it.readText() }
|
||||
)
|
||||
val charArray = metadata.getJSONArray("chars")
|
||||
chars = List(charArray.length()) { charArray.getString(it) }
|
||||
blankIndex = metadata.getInt("blank_index")
|
||||
inputName = metadata.getString("onnx_input_name")
|
||||
outputName = metadata.getString("onnx_output_name")
|
||||
|
||||
val modelBytes = appContext.assets.open(MODEL_ASSET).use { it.readBytes() }
|
||||
val env = OrtEnvironment.getEnvironment()
|
||||
environment = env
|
||||
val options = OrtSession.SessionOptions().apply {
|
||||
// Keep a core free for audio capture and the UI; the default
|
||||
// would spread inference across every core on the device.
|
||||
val threads = (Runtime.getRuntime().availableProcessors() - 1).coerceIn(1, 4)
|
||||
setIntraOpNumThreads(threads)
|
||||
}
|
||||
session = env.createSession(modelBytes, options)
|
||||
CwProbe.step("load_session_ok")
|
||||
Log.i(TAG, "DeepCW ready: ${modelBytes.size} bytes, ${chars.size} classes")
|
||||
return true
|
||||
} catch (t: Throwable) {
|
||||
// Catches UnsatisfiedLinkError (missing/mismatched .so) as well as
|
||||
// asset and session failures.
|
||||
CwProbe.step("load_failed:${t.javaClass.simpleName}")
|
||||
Log.e(TAG, "DeepCW model failed to load", t)
|
||||
_errorMessage.value =
|
||||
"CW model failed to load: ${t.message ?: t.javaClass.simpleName}"
|
||||
// Persist the failure for devices without logcat access.
|
||||
runCatching {
|
||||
val sw = java.io.StringWriter()
|
||||
t.printStackTrace(java.io.PrintWriter(sw))
|
||||
java.io.File(appContext.filesDir, "deepcw_load_error.txt")
|
||||
.writeText("${t.javaClass.name}: ${t.message}\n${sw}\n")
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun processBuffer(samples: FloatArray, sampleRate: Int) {
|
||||
if (samples.isEmpty()) return
|
||||
if (!ensureLoaded()) return
|
||||
|
||||
// Filter before decimating. resampleLinear interpolates without removing anything
|
||||
// above the new Nyquist, so this has to happen first or the fold is already baked in.
|
||||
if (antiAlias == null || antiAliasRate != sampleRate) {
|
||||
antiAlias = CwAntiAlias.Streaming(sampleRate, CwDeepSpectrogram.SAMPLE_RATE)
|
||||
antiAliasRate = sampleRate
|
||||
}
|
||||
val bandLimited = antiAlias?.process(samples) ?: samples
|
||||
// The filter holds back its group delay, so the first call returns nothing.
|
||||
if (bandLimited.isEmpty()) return
|
||||
|
||||
val resampled = CwDeepSpectrogram.resampleLinear(
|
||||
bandLimited, sampleRate, CwDeepSpectrogram.SAMPLE_RATE
|
||||
)
|
||||
val prepared = applyToneShift(resampled)
|
||||
|
||||
// Anything applyToneShift just retired from the window is older than what follows, so
|
||||
// it is archived before the new audio is buffered - otherwise the record comes out
|
||||
// with its text transposed. Archived whole rather than accumulated: it is already a
|
||||
// full window's worth, and holding it back would only expose it to the next retirement.
|
||||
val retired = drainRetiredAudio()
|
||||
for (batch in retired) {
|
||||
try {
|
||||
archiveDecode(batch)
|
||||
} catch (t: Throwable) {
|
||||
if (t is CancellationException) throw t
|
||||
Log.e(TAG, "retired audio decode failed", t)
|
||||
}
|
||||
}
|
||||
|
||||
val shouldRedecode = buffer.append(prepared)
|
||||
|
||||
// Archive audio that scrolled out of the live window. It is decoded once
|
||||
// when a full archive chunk has accumulated, so old text does not vanish.
|
||||
val overflow = buffer.drainOverflow()
|
||||
if (overflow.isNotEmpty()) {
|
||||
for (v in overflow) {
|
||||
// Flush before appending when the buffer is full, so large batches
|
||||
// (e.g. 47999 samples already accumulated + 64000 new overflow)
|
||||
// do not silently drop audio that scrolled out of the live window.
|
||||
if (archiveSize >= archiveBuffer.size) {
|
||||
val audio = archiveBuffer.copyOf(archiveSize)
|
||||
archiveSize = 0
|
||||
try {
|
||||
archiveDecode(audio)
|
||||
} catch (t: Throwable) {
|
||||
if (t is CancellationException) throw t
|
||||
Log.e(TAG, "archive decode failed", t)
|
||||
}
|
||||
}
|
||||
archiveBuffer[archiveSize++] = v
|
||||
}
|
||||
// Final flush when threshold is reached (e.g. exactly 48000 accumulated).
|
||||
if (archiveSize >= ARCHIVE_THRESHOLD) {
|
||||
val audio = archiveBuffer.copyOf(archiveSize)
|
||||
archiveSize = 0
|
||||
try {
|
||||
archiveDecode(audio)
|
||||
} catch (t: Throwable) {
|
||||
if (t is CancellationException) throw t
|
||||
Log.e(TAG, "archive decode failed", t)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!shouldRedecode || !buffer.hasEnoughAudio) return
|
||||
|
||||
// Drop this cycle rather than queue when the previous run is still going.
|
||||
if (!inferenceLock.tryLock()) {
|
||||
Log.d(TAG, "inference still running, skipping this interval")
|
||||
return
|
||||
}
|
||||
try {
|
||||
decodeWindow(buffer.snapshot())
|
||||
} catch (t: Throwable) {
|
||||
// Cancellation is normal when the user pauses: the capture coroutine
|
||||
// is cancelled while an inference is in flight. Never swallow it as
|
||||
// a decode error — rethrow so the coroutine machinery works, and do
|
||||
// not flash a spurious "decode failed" banner.
|
||||
if (t is CancellationException) throw t
|
||||
Log.e(TAG, "inference failed", t)
|
||||
_errorMessage.value = "CW decode failed: ${t.message ?: t.javaClass.simpleName}"
|
||||
runCatching {
|
||||
val sw = java.io.StringWriter()
|
||||
t.printStackTrace(java.io.PrintWriter(sw))
|
||||
java.io.File(appContext.filesDir, "deepcw_infer_error.txt")
|
||||
.writeText("${t.javaClass.name}: ${t.message}\n${sw}\n")
|
||||
}
|
||||
} finally {
|
||||
inferenceLock.unlock()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Move an out-of-window tone into the model's analysis window when the user has
|
||||
* enabled it.
|
||||
*
|
||||
* The detection scan is a bin-by-bin DFT, so it runs at most every
|
||||
* [DETECT_INTERVAL_MS] rather than on every ~100 ms capture chunk; the decision it
|
||||
* produces is cached in [_activeShiftHz] and applied to the chunks in between. A
|
||||
* tone already inside the window yields a zero shift, and then this returns the
|
||||
* caller's array untouched.
|
||||
*
|
||||
* @return the audio to buffer: [resampled] itself whenever no shift applies.
|
||||
*/
|
||||
private fun applyToneShift(resampled: FloatArray): FloatArray {
|
||||
val enabled = isToneShiftEnabled()
|
||||
|
||||
// A toggle invalidates whatever is already buffered: those samples were moved by
|
||||
// the old setting and cannot be un-shifted, so the 20 s window would keep
|
||||
// decoding them - and the pitch readout would correct them by the wrong amount -
|
||||
// for up to 20 s after the user acted. Seeded from the current setting on the
|
||||
// first chunk so starting up with it already on is not treated as a change.
|
||||
val previousEnabled = toneShiftWasEnabled ?: enabled
|
||||
toneShiftWasEnabled = enabled
|
||||
if (enabled != previousEnabled) {
|
||||
Log.i(TAG, "toneShift: setting changed to $enabled, dropping buffered audio")
|
||||
dropBufferedAudio()
|
||||
_activeShiftHz.value = 0f
|
||||
_detectedToneHz.value = null
|
||||
lastToneAtMs = 0L
|
||||
shiftDecider.reset()
|
||||
lastDetectAtMs = 0L
|
||||
detectionPool.clear()
|
||||
streamingShifter.reset()
|
||||
}
|
||||
|
||||
// Detection runs whether or not shifting is enabled. It is the only measurement
|
||||
// that can see past the model's window, so with it skipped an out-of-window tone
|
||||
// left the UI with nothing truthful to show: the spectrogram's own pitch readout
|
||||
// is arithmetically confined to the window and reports the leakage piled against
|
||||
// the nearest edge, so a 1500 Hz tone published "1200 Hz" and a healthy signal
|
||||
// level while decoding nothing at all.
|
||||
detectionPool.add(resampled)
|
||||
|
||||
val now = System.currentTimeMillis()
|
||||
val elapsed = now - lastDetectAtMs
|
||||
if (detectionPool.isReady && elapsed >= DETECT_INTERVAL_MS) {
|
||||
lastDetectAtMs = now
|
||||
runDetection(detectionPool.drain(), shiftEnabled = enabled)
|
||||
}
|
||||
|
||||
if (!enabled) return resampled
|
||||
|
||||
// Streaming keeps the Hilbert filter history and mixer phase across chunks;
|
||||
// shifting each chunk in isolation distorted the 62 samples at its edges.
|
||||
return streamingShifter.process(resampled, _activeShiftHz.value, CwDeepSpectrogram.SAMPLE_RATE)
|
||||
}
|
||||
|
||||
/**
|
||||
* Discard buffered audio that was shifted by a now-stale amount.
|
||||
*
|
||||
* The live window and the pending archive chunk both hold shifted samples that
|
||||
* cannot be un-shifted, so they are dropped rather than decoded against the new
|
||||
* shift. Text already committed to [historyText] stays: it was correct when decoded.
|
||||
*/
|
||||
/**
|
||||
* Drop audio that was shifted by a setting no longer in force - but keep what can be kept.
|
||||
*
|
||||
* The live window has to go: it holds samples moved by two different amounts, and one
|
||||
* spectrogram over both smears the tone. The pending archive batch does not. Those samples
|
||||
* already left the window, they were shifted consistently, and they are complete, so
|
||||
* discarding them threw away decodable audio for no reason. They are left in place here to
|
||||
* be archived by the normal path, which keeps this function non-suspending: its two
|
||||
* callers sit on the synchronous capture path, and making them suspend to run an inference
|
||||
* here would put a decode inside the tone-detection scan.
|
||||
*
|
||||
* It mattered because this runs on every change of shift, which tracks the detected tone,
|
||||
* which drifts across a pass. Modelled at 18 WPM, a drift every 20 s left the record
|
||||
* permanently empty however long the operator listened: the window was wiped before any
|
||||
* batch could complete, so nothing was ever committed. With the record still concatenating
|
||||
* the live decode at the time, each wipe visibly cut the transcript short as well - text
|
||||
* going backwards, then never accumulating at all.
|
||||
*/
|
||||
/** Take every retired window, oldest first, leaving the queue empty. */
|
||||
private fun drainRetiredAudio(): List<FloatArray> = synchronized(retiredAudio) {
|
||||
if (retiredAudio.isEmpty()) {
|
||||
emptyList()
|
||||
} else {
|
||||
retiredAudio.toList().also { retiredAudio.clear() }
|
||||
}
|
||||
}
|
||||
|
||||
private fun dropBufferedAudio() {
|
||||
// Retired, not discarded. The samples cannot stay in the window - mixing two shifts
|
||||
// in one spectrogram smears the tone - but they are internally consistent and
|
||||
// complete, so they decode fine on their own. Handed to the archive path rather than
|
||||
// decoded here, because both callers sit on the synchronous capture path.
|
||||
val retiring = buffer.snapshot()
|
||||
if (retiring.isNotEmpty()) {
|
||||
synchronized(retiredAudio) {
|
||||
while (retiredAudio.size >= retiredAudioLimit) {
|
||||
Log.w(TAG, "retired audio queue full, dropping the oldest window")
|
||||
retiredAudio.removeFirst()
|
||||
}
|
||||
retiredAudio.addLast(retiring)
|
||||
}
|
||||
}
|
||||
buffer.reset()
|
||||
// Committed text stays: it was correct for audio that really was archived.
|
||||
_historyText.value = committedText
|
||||
}
|
||||
|
||||
/**
|
||||
* Feed one detection to [shiftDecider] and log what it decided.
|
||||
*
|
||||
* The rule itself lives in core:domain so it can be tested directly; keeping it here
|
||||
* meant tests could only restate it, and a restated rule cannot fail when the real
|
||||
* one is wrong - four injected defects once left the whole suite green.
|
||||
*/
|
||||
private fun runDetection(sample: FloatArray, shiftEnabled: Boolean) {
|
||||
val analysis = CwToneShifter.analyse(sample, CwDeepSpectrogram.SAMPLE_RATE)
|
||||
|
||||
// Published either way: the UI needs the real pitch to say why nothing decodes
|
||||
// when shifting is off and the tone is out of range. Held through silences for
|
||||
// the same reason the shift is - CW is gaps, and a gap is not a retune - but not
|
||||
// indefinitely: without an expiry the last out-of-band reading survived every
|
||||
// silent scan, so after retuning into the band the hint kept naming a frequency
|
||||
// the operator had left. Ten seconds clears comfortably any real gap, the longest
|
||||
// being about 1.7 s at 5 WPM between words plus a few seconds of thinking.
|
||||
val tone = analysis.toneHz
|
||||
if (tone != null) {
|
||||
_detectedToneHz.value = tone
|
||||
lastToneAtMs = System.currentTimeMillis()
|
||||
} else if (System.currentTimeMillis() - lastToneAtMs > TONE_EXPIRY_MS) {
|
||||
_detectedToneHz.value = null
|
||||
}
|
||||
|
||||
if (!shiftEnabled) return
|
||||
|
||||
val decision = shiftDecider.accept(analysis)
|
||||
_activeShiftHz.value = decision.shiftHz
|
||||
|
||||
when (decision.outcome) {
|
||||
CwShiftDecider.Outcome.NO_TONE -> Log.d(
|
||||
TAG,
|
||||
"toneShift: no tone in ${sample.size} samples, keeping shift=${decision.shiftHz}Hz"
|
||||
)
|
||||
|
||||
CwShiftDecider.Outcome.WITHIN_HYSTERESIS -> Log.d(
|
||||
TAG,
|
||||
"toneShift: tone=${decision.toneHz}Hz within ${CwShiftDecider.DEFAULT_HYSTERESIS_HZ}Hz " +
|
||||
"of anchor ${shiftDecider.anchorToneHz}Hz, keeping shift=${decision.shiftHz}Hz"
|
||||
)
|
||||
|
||||
CwShiftDecider.Outcome.NO_SHIFT_NEEDED -> Log.d(
|
||||
TAG,
|
||||
"toneShift: tone=${decision.toneHz}Hz inside " +
|
||||
"${CwDeepSpectrogram.MIN_FREQ_HZ}-${CwDeepSpectrogram.MAX_FREQ_HZ}Hz, no shift"
|
||||
)
|
||||
|
||||
CwShiftDecider.Outcome.SHIFTED -> Log.i(
|
||||
TAG,
|
||||
"toneShift: tone=${decision.toneHz}Hz outside window, " +
|
||||
"shifting ${decision.shiftHz}Hz to ${CwToneShifter.TARGET_HZ}Hz"
|
||||
)
|
||||
}
|
||||
|
||||
if (decision.changed) {
|
||||
// The window still holds audio moved by the old amount. Mixing two shifts in
|
||||
// one spectrogram smears the tone, and the pitch readout could only be right
|
||||
// for one of them, so rebuild the window from the new shift.
|
||||
Log.i(TAG, "toneShift: shift changed, dropping buffered audio")
|
||||
dropBufferedAudio()
|
||||
streamingShifter.reset()
|
||||
CwProbe.step("tone_shift tone=${decision.toneHz} shift=${decision.shiftHz}")
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun decodeWindow(window: FloatArray) = withContext(Dispatchers.Default) {
|
||||
val activeSession = session ?: return@withContext
|
||||
val activeEnvironment = environment ?: return@withContext
|
||||
CwProbe.step("infer_begin frames=${window.size}")
|
||||
|
||||
val spectrogram = CwDeepSpectrogram.compute(window)
|
||||
val text = runInference(activeSession, activeEnvironment, spectrogram)
|
||||
|
||||
// Replace, never append: the model rewrites earlier characters as more
|
||||
// context arrives, so appending would leave stale guesses on screen.
|
||||
_decodedText.value = text
|
||||
|
||||
updateSignalMetrics(spectrogram)
|
||||
}
|
||||
|
||||
/**
|
||||
* Archive whatever audio is still in the pipeline, so stopping does not discard it.
|
||||
*
|
||||
* Two places hold audio that would otherwise never be decoded into the record: the
|
||||
* batch accumulating towards [ARCHIVE_THRESHOLD], and the live window itself, whose
|
||||
* contents only ever reach the archive by being pushed out by newer audio. Together
|
||||
* that is the last [CwDeepBuffer.DEFAULT_MAX_SECONDS] + [ARCHIVE_SECONDS] of a session
|
||||
* - which includes the end of every transmission, the part with the call sign in it.
|
||||
*
|
||||
* Order matters: the pending batch left the window before anything still in it, so it
|
||||
* has to be archived first or the record comes out with its text transposed.
|
||||
*/
|
||||
override suspend fun flush() = archiveLock.withLock {
|
||||
// Oldest first, all the way down: retired window contents, then the batch accumulating
|
||||
// towards the threshold, then what is still live.
|
||||
for (batch in drainRetiredAudio()) {
|
||||
runCatching { archiveDecodeLocked(batch) }
|
||||
.onFailure { if (it is CancellationException) throw it }
|
||||
}
|
||||
if (archiveSize > 0) {
|
||||
val pending = archiveBuffer.copyOf(archiveSize)
|
||||
archiveSize = 0
|
||||
runCatching { archiveDecodeLocked(pending) }
|
||||
.onFailure { if (it is CancellationException) throw it }
|
||||
}
|
||||
// Draining the window empties it, so a second flush cannot double-archive the tail.
|
||||
val window = buffer.snapshot()
|
||||
if (window.isNotEmpty()) {
|
||||
buffer.reset()
|
||||
runCatching { archiveDecodeLocked(window) }
|
||||
.onFailure { if (it is CancellationException) throw it }
|
||||
}
|
||||
// The live line described audio that is now in the record; leaving it would show the
|
||||
// same characters twice, in two places, one of them stale.
|
||||
_decodedText.value = ""
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode a chunk of audio that has scrolled out of the live window and
|
||||
* append it to [historyText]. Unlike the live window this never replaces —
|
||||
* the archived audio is final, so its text is permanent.
|
||||
*/
|
||||
private suspend fun archiveDecode(audio: FloatArray) = archiveLock.withLock {
|
||||
archiveDecodeLocked(audio)
|
||||
}
|
||||
|
||||
/** [archiveDecode] without the lock, for callers already holding [archiveLock]. */
|
||||
private suspend fun archiveDecodeLocked(audio: FloatArray) = withContext(Dispatchers.Default) {
|
||||
val activeSession = session ?: return@withContext
|
||||
val activeEnvironment = environment ?: return@withContext
|
||||
if (audio.size < CwDeepSpectrogram.FFT_LENGTH) return@withContext
|
||||
val spectrogram = CwDeepSpectrogram.compute(audio)
|
||||
val text = runInference(activeSession, activeEnvironment, spectrogram)
|
||||
committedText += text
|
||||
_historyText.value = committedText
|
||||
}
|
||||
|
||||
/** Run the ONNX model over a pre-computed spectrogram and return the decoded text. */
|
||||
private fun runInference(
|
||||
activeSession: OrtSession,
|
||||
activeEnvironment: OrtEnvironment,
|
||||
spectrogram: Array<FloatArray>
|
||||
): String {
|
||||
val frames = spectrogram.size
|
||||
val bins = CwDeepSpectrogram.FREQUENCY_BINS
|
||||
|
||||
val flat = FloatBuffer.allocate(frames * bins)
|
||||
for (frame in spectrogram) flat.put(frame)
|
||||
flat.rewind()
|
||||
|
||||
val shape = longArrayOf(1, 1, frames.toLong(), bins.toLong())
|
||||
val startedAt = System.currentTimeMillis()
|
||||
val text: String
|
||||
OnnxTensor.createTensor(activeEnvironment, flat, shape).use { input ->
|
||||
activeSession.run(mapOf(inputName to input)).use { result ->
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val logits = result[outputName].get().value as Array<Array<FloatArray>>
|
||||
text = CwCtcDecoder.greedy(logits, chars, blankIndex)
|
||||
}
|
||||
}
|
||||
_lastInferenceMs.value = (System.currentTimeMillis() - startedAt).toInt()
|
||||
CwProbe.step("infer_done ms=${_lastInferenceMs.value}")
|
||||
return text
|
||||
}
|
||||
|
||||
/**
|
||||
* Report the loudest bin as the tone pitch and its prominence over the
|
||||
* window mean as a 0..1 strength, purely for the UI readout.
|
||||
*/
|
||||
private fun updateSignalMetrics(spectrogram: Array<FloatArray>) {
|
||||
if (spectrogram.isEmpty()) return
|
||||
var bestBin = 0
|
||||
var bestValue = 0f
|
||||
var total = 0f
|
||||
var count = 0
|
||||
for (frame in spectrogram) {
|
||||
for (bin in frame.indices) {
|
||||
val value = frame[bin]
|
||||
total += value
|
||||
count++
|
||||
if (value > bestValue) {
|
||||
bestValue = value
|
||||
bestBin = bin
|
||||
}
|
||||
}
|
||||
}
|
||||
if (count == 0 || bestValue <= 0f) return
|
||||
|
||||
val binHz = CwDeepSpectrogram.SAMPLE_RATE.toDouble() / CwDeepSpectrogram.FFT_LENGTH
|
||||
// Relative bin 0 is 400 Hz; absolute bin index is 32 + bestBin.
|
||||
val absoluteBin = 32 + bestBin
|
||||
// Undo the shift before reporting: the spectrogram sees the moved tone, but
|
||||
// the readout must show the pitch the operator actually hears on the radio.
|
||||
_estimatedPitch.value = (absoluteBin * binHz - _activeShiftHz.value).toFloat()
|
||||
|
||||
val mean = total / count
|
||||
val prominence = ((bestValue - mean) / bestValue).coerceIn(0f, 1f)
|
||||
|
||||
// The meter claims something decodable is present, so it needs a tone the scan has
|
||||
// actually confirmed inside the window - not merely the absence of a confirmed
|
||||
// out-of-window one. Requiring the confirmation is what covers the intermittent
|
||||
// case: a slow fist out of band at 15% duty scores 2.5 against MIN_PROMINENCE 4.5,
|
||||
// so no tone is reported, and a condition keyed on "confirmed outside" stayed false
|
||||
// and let the meter read half scale on window-edge leakage beside an empty
|
||||
// transcript - the exact reading this gate exists to suppress.
|
||||
val confirmed = _detectedToneHz.value
|
||||
val decodable = confirmed != null &&
|
||||
(_activeShiftHz.value != 0f || CwToneShifter.isInsideWindow(confirmed))
|
||||
_signalStrength.value = if (decodable) prominence else 0f
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear everything. Not serialised against [archiveLock]: an archive decode already in
|
||||
* flight can land its batch after this returns, leaving a few characters behind. The
|
||||
* operator asked to clear and can ask again; making this suspend to close that window
|
||||
* would push it onto every caller, including a synchronous button handler.
|
||||
*/
|
||||
override fun reset() {
|
||||
antiAlias?.reset()
|
||||
buffer.reset()
|
||||
synchronized(retiredAudio) { retiredAudio.clear() }
|
||||
_decodedText.value = ""
|
||||
_historyText.value = ""
|
||||
committedText = ""
|
||||
archiveSize = 0
|
||||
_estimatedPitch.value = null
|
||||
_detectedToneHz.value = null
|
||||
lastToneAtMs = 0L
|
||||
_signalStrength.value = 0f
|
||||
_lastInferenceMs.value = 0
|
||||
// Re-detect from scratch: the operator may have retuned before resetting.
|
||||
_activeShiftHz.value = 0f
|
||||
shiftDecider.reset()
|
||||
lastDetectAtMs = 0L
|
||||
detectionPool.clear()
|
||||
streamingShifter.reset()
|
||||
// Leave toneShiftWasEnabled unset so the next chunk re-seeds it from the
|
||||
// current setting instead of reporting a spurious change.
|
||||
toneShiftWasEnabled = null
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
try {
|
||||
session?.close()
|
||||
} catch (t: Throwable) {
|
||||
Log.w(TAG, "session close failed", t)
|
||||
}
|
||||
session = null
|
||||
environment = null
|
||||
}
|
||||
}
|
||||
@@ -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.data.cw
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
|
||||
/**
|
||||
* Minimal crash-probe logger for diagnosing crashes that produce no Java
|
||||
* stack trace (native faults, low-memory kills). Each step appends one line
|
||||
* to `files/probe_cw.txt`; if the process dies mid-way the last line shows
|
||||
* exactly where. No adb or logcat required.
|
||||
*/
|
||||
internal object CwProbe {
|
||||
|
||||
/** Keep the diagnostic file bounded: the decoder writes two lines per
|
||||
* 1.5 s inference tick (~170 KB/hour), so without a cap it grows without
|
||||
* limit on every release build. Truncate instead of deleting so the
|
||||
* probe keeps the last diagnostics before a crash. */
|
||||
private const val MAX_FILE_BYTES = 1_048_576L // 1 MiB
|
||||
|
||||
private var dir: java.io.File? = null
|
||||
|
||||
fun init(context: Context) {
|
||||
dir = context.filesDir
|
||||
}
|
||||
|
||||
fun step(label: String) {
|
||||
val target = dir ?: return
|
||||
runCatching {
|
||||
val line = "${System.currentTimeMillis()} $label"
|
||||
val file = java.io.File(target, "probe_cw.txt")
|
||||
if (file.length() > MAX_FILE_BYTES) file.delete()
|
||||
file.appendText("$line\n")
|
||||
Log.i("CwProbe", line)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -45,7 +45,12 @@ interface Look4SatDao {
|
||||
@Query("DELETE FROM entries")
|
||||
suspend fun deleteEntries()
|
||||
|
||||
@Query("SELECT catnum FROM radios WHERE downlinkMode IN (:modes)")
|
||||
@Query(
|
||||
"""
|
||||
SELECT DISTINCT catnum FROM radios WHERE isAlive = 1
|
||||
AND (downlinkMode IN (:modes) OR uplinkMode IN (:modes))
|
||||
"""
|
||||
)
|
||||
suspend fun getIdsWithModes(modes: List<String>): List<Int>
|
||||
|
||||
@Query("SELECT COUNT(*) FROM radios")
|
||||
|
||||
+14
@@ -76,10 +76,12 @@ class BluetoothReporter(
|
||||
private fun ensureRotatorConnected() {
|
||||
if (rotatorConnected || rotatorConnecting || rotatorDeviceId.isBlank()) return
|
||||
reporterScope.launch {
|
||||
var opened: android.bluetooth.BluetoothSocket? = null
|
||||
try {
|
||||
rotatorConnecting = true
|
||||
val device = bluetoothManager.adapter.getRemoteDevice(rotatorDeviceId)
|
||||
val socket = device.createInsecureRfcommSocketToServiceRecord(sppId)
|
||||
opened = socket
|
||||
socket.connect()
|
||||
rotatorSocket = socket
|
||||
rotatorStream = socket.outputStream
|
||||
@@ -87,6 +89,11 @@ class BluetoothReporter(
|
||||
Log.i(tag, "Rotator connected to $rotatorDeviceId")
|
||||
} catch (e: Exception) {
|
||||
Log.e(tag, "Rotator connect error: ${e.message}")
|
||||
// Close the socket we opened, otherwise a failure after connect()
|
||||
// leaks it: nothing else holds a reference once this returns.
|
||||
runCatching { opened?.close() }
|
||||
rotatorSocket = null
|
||||
rotatorStream = null
|
||||
rotatorConnected = false
|
||||
} finally {
|
||||
rotatorConnecting = false
|
||||
@@ -97,10 +104,12 @@ class BluetoothReporter(
|
||||
private fun ensureFrequencyConnected() {
|
||||
if (frequencyConnected || frequencyConnecting || frequencyDeviceId.isBlank()) return
|
||||
reporterScope.launch {
|
||||
var opened: android.bluetooth.BluetoothSocket? = null
|
||||
try {
|
||||
frequencyConnecting = true
|
||||
val device = bluetoothManager.adapter.getRemoteDevice(frequencyDeviceId)
|
||||
val socket = device.createInsecureRfcommSocketToServiceRecord(sppId)
|
||||
opened = socket
|
||||
socket.connect()
|
||||
frequencySocket = socket
|
||||
frequencyStream = socket.outputStream
|
||||
@@ -108,6 +117,11 @@ class BluetoothReporter(
|
||||
Log.i(tag, "Frequency connected to $frequencyDeviceId")
|
||||
} catch (e: Exception) {
|
||||
Log.e(tag, "Frequency connect error: ${e.message}")
|
||||
// Close the socket we opened, otherwise a failure after connect()
|
||||
// leaks it: nothing else holds a reference once this returns.
|
||||
runCatching { opened?.close() }
|
||||
frequencySocket = null
|
||||
frequencyStream = null
|
||||
frequencyConnected = false
|
||||
} finally {
|
||||
frequencyConnecting = false
|
||||
|
||||
@@ -42,6 +42,9 @@ class Ft817Controller(
|
||||
private val commandDelayMs = 200L
|
||||
private val maxAckReadFailures = 3
|
||||
|
||||
/** Largest frequency the 4-byte BCD / 10 Hz CAT field can represent. */
|
||||
private val maxFrequencyHz = 999_999_990L
|
||||
|
||||
private var socket: BluetoothSocket? = null
|
||||
private var outputStream: OutputStream? = null
|
||||
private var inputStream: InputStream? = null
|
||||
@@ -53,9 +56,11 @@ class Ft817Controller(
|
||||
override suspend fun connect(): Boolean = withContext(Dispatchers.IO) {
|
||||
if (isConnected) return@withContext true
|
||||
if (deviceAddress.isBlank()) return@withContext false
|
||||
var opened: android.bluetooth.BluetoothSocket? = null
|
||||
try {
|
||||
val device = bluetoothManager.adapter.getRemoteDevice(deviceAddress)
|
||||
val btSocket = device.createInsecureRfcommSocketToServiceRecord(sppId)
|
||||
opened = btSocket
|
||||
btSocket.connect()
|
||||
socket = btSocket
|
||||
outputStream = btSocket.outputStream
|
||||
@@ -66,6 +71,13 @@ class Ft817Controller(
|
||||
true
|
||||
} catch (e: Exception) {
|
||||
Log.e(tag, "Connect error: ${e.message}")
|
||||
// Close the socket we opened. Without this a failure after connect()
|
||||
// (e.g. outputStream throwing) leaks the Bluetooth socket, because
|
||||
// disconnect() only closes what already reached the fields.
|
||||
runCatching { opened?.close() }
|
||||
socket = null
|
||||
outputStream = null
|
||||
inputStream = null
|
||||
isConnected = false
|
||||
false
|
||||
}
|
||||
@@ -91,6 +103,16 @@ class Ft817Controller(
|
||||
}
|
||||
|
||||
override suspend fun setFrequency(frequencyHz: Long): Boolean = withContext(Dispatchers.IO) {
|
||||
// The FT-817 CAT frequency field is 4 BCD bytes at 10 Hz resolution,
|
||||
// so the protocol cannot express anything above 999,999,990 Hz. Below
|
||||
// that the encoder is exact; above it, the %08d formatting silently
|
||||
// drops the leading digit and the radio receives a frequency ten
|
||||
// times lower (e.g. 1267.6 MHz becomes 126.76 MHz), and the tracking
|
||||
// loop's read-back then locks onto the wrong band. Reject instead.
|
||||
if (frequencyHz > maxFrequencyHz) {
|
||||
Log.e(tag, "setFrequency rejected: $frequencyHz Hz exceeds FT-817 CAT limit $maxFrequencyHz")
|
||||
return@withContext false
|
||||
}
|
||||
ioMutex.withLock {
|
||||
sendCommandWithAck(Ft817CatProtocol.buildSetFreqCommand(frequencyHz))
|
||||
}
|
||||
|
||||
@@ -67,9 +67,11 @@ class Ic705Controller(
|
||||
override suspend fun connect(): Boolean = withContext(Dispatchers.IO) {
|
||||
if (isConnected) return@withContext true
|
||||
if (deviceAddress.isBlank()) return@withContext false
|
||||
var opened: android.bluetooth.BluetoothSocket? = null
|
||||
try {
|
||||
val device = bluetoothManager.adapter.getRemoteDevice(deviceAddress)
|
||||
val btSocket = device.createInsecureRfcommSocketToServiceRecord(sppId)
|
||||
opened = btSocket
|
||||
btSocket.connect()
|
||||
socket = btSocket
|
||||
outputStream = btSocket.outputStream
|
||||
@@ -84,6 +86,13 @@ class Ic705Controller(
|
||||
true
|
||||
} catch (e: Exception) {
|
||||
Log.e(tag, "Connect error: ${e.message}")
|
||||
// Close the socket we opened. Without this a failure after connect()
|
||||
// (e.g. outputStream throwing) leaks the Bluetooth socket, because
|
||||
// disconnect() only closes what already reached the fields.
|
||||
runCatching { opened?.close() }
|
||||
socket = null
|
||||
outputStream = null
|
||||
inputStream = null
|
||||
isConnected = false
|
||||
false
|
||||
}
|
||||
|
||||
+21
-2
@@ -71,14 +71,19 @@ class NetworkReporter(
|
||||
private fun ensureRotatorConnected() {
|
||||
if (rotatorConnected || rotatorConnecting || rotatorServer.isBlank()) return
|
||||
reporterScope.launch {
|
||||
var opened: SocketChannel? = null
|
||||
try {
|
||||
rotatorConnecting = true
|
||||
rotatorSocket = SocketChannel.open(InetSocketAddress(rotatorServer, rotatorPort))
|
||||
opened = SocketChannel.open(InetSocketAddress(rotatorServer, rotatorPort))
|
||||
rotatorSocket = opened
|
||||
rotatorConnected = true
|
||||
println("NetworkReporter: Rotator connected to $rotatorServer:$rotatorPort")
|
||||
} catch (e: Exception) {
|
||||
println("NetworkReporter rotator connect error: ${e.message}")
|
||||
rotatorConnected = false
|
||||
// Close a socket that connected but failed during setup, so a
|
||||
// broken channel is never left referenced without a closer.
|
||||
opened?.close()
|
||||
} finally {
|
||||
rotatorConnecting = false
|
||||
}
|
||||
@@ -88,14 +93,17 @@ class NetworkReporter(
|
||||
private fun ensureFrequencyConnected() {
|
||||
if (frequencyConnected || frequencyConnecting || frequencyServer.isBlank()) return
|
||||
reporterScope.launch {
|
||||
var opened: SocketChannel? = null
|
||||
try {
|
||||
frequencyConnecting = true
|
||||
frequencySocket = SocketChannel.open(InetSocketAddress(frequencyServer, frequencyPort))
|
||||
opened = SocketChannel.open(InetSocketAddress(frequencyServer, frequencyPort))
|
||||
frequencySocket = opened
|
||||
frequencyConnected = true
|
||||
println("NetworkReporter: Frequency connected to $frequencyServer:$frequencyPort")
|
||||
} catch (e: Exception) {
|
||||
println("NetworkReporter frequency connect error: ${e.message}")
|
||||
frequencyConnected = false
|
||||
opened?.close()
|
||||
} finally {
|
||||
frequencyConnecting = false
|
||||
}
|
||||
@@ -111,6 +119,17 @@ class NetworkReporter(
|
||||
} catch (e: Exception) {
|
||||
println("NetworkReporter write error: ${e.message}")
|
||||
onError()
|
||||
// The channel failed a write: drop it and its closure obligation.
|
||||
// Leaving it referenced lets the next connect overwrite the field
|
||||
// and leak the old channel. Only the field the caller passed is
|
||||
// cleared, matching the connected=false the onError sets.
|
||||
if (socket === rotatorSocket) {
|
||||
rotatorSocket?.close()
|
||||
rotatorSocket = null
|
||||
} else if (socket === frequencySocket) {
|
||||
frequencySocket?.close()
|
||||
frequencySocket = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+16
-8
@@ -267,12 +267,18 @@ class RadioTrackingService(
|
||||
|
||||
if (tuningRadio.isEmpty()) {
|
||||
if (txNow != null && txNow.isConnected && txRadioFreq != null) {
|
||||
txNow.setFrequency(txRadioFreq)
|
||||
lastSetTxFreq = txRadioFreq.toDouble()
|
||||
// Only remember the frequency we actually wrote: if the radio
|
||||
// rejects it (FT-817 CAT limit) or the link dropped, keeping
|
||||
// lastSetTxFreq updated would make the manual-tuning detector
|
||||
// see a phantom dial change on the next read-back.
|
||||
if (txNow.setFrequency(txRadioFreq)) {
|
||||
lastSetTxFreq = txRadioFreq.toDouble()
|
||||
}
|
||||
}
|
||||
if (rxNow != null && rxNow.isConnected && rxRadioFreq != null) {
|
||||
rxNow.setFrequency(rxRadioFreq)
|
||||
lastSetRxFreq = rxRadioFreq.toDouble()
|
||||
if (rxNow.setFrequency(rxRadioFreq)) {
|
||||
lastSetRxFreq = rxRadioFreq.toDouble()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -450,13 +456,15 @@ class RadioTrackingService(
|
||||
// 0x25/00 = active (RX) VFO, 0x25/01 = inactive (TX) VFO.
|
||||
if (rxRadioFreq != null) {
|
||||
Log.d(tag, "Split loop RX (0x25/00): ${rxRadioFreq}Hz")
|
||||
radio.setWorkingFrequency(rxRadioFreq)
|
||||
lastSetRxFreq = rxRadioFreq.toDouble()
|
||||
if (radio.setWorkingFrequency(rxRadioFreq)) {
|
||||
lastSetRxFreq = rxRadioFreq.toDouble()
|
||||
}
|
||||
}
|
||||
if (txRadioFreq != null) {
|
||||
Log.d(tag, "Split loop TX (0x25/01): ${txRadioFreq}Hz")
|
||||
radio.setTxVfoFrequency(txRadioFreq)
|
||||
lastSetTxFreq = txRadioFreq.toDouble()
|
||||
if (radio.setTxVfoFrequency(txRadioFreq)) {
|
||||
lastSetTxFreq = txRadioFreq.toDouble()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -36,6 +36,7 @@ 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.source.LocalSource
|
||||
import com.rtbishop.look4sat.core.data.source.OkHttpHttpClient
|
||||
import com.rtbishop.look4sat.core.data.source.RemoteSource
|
||||
import com.rtbishop.look4sat.core.data.usecase.AddToCalendar
|
||||
import com.rtbishop.look4sat.core.data.usecase.AudioCapture
|
||||
@@ -69,19 +70,32 @@ import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import com.rtbishop.look4sat.core.data.qrz.QrzGridLookup
|
||||
import com.rtbishop.look4sat.core.domain.qrz.IQrzGridLookup
|
||||
import okhttp3.OkHttpClient
|
||||
import com.rtbishop.look4sat.core.data.wavelog.LotwSatellitesRepo
|
||||
import com.rtbishop.look4sat.core.domain.wavelog.WaveLogApi
|
||||
|
||||
class MainContainer(private val context: Context) : IMainContainer {
|
||||
|
||||
private val localSource = provideLocalSource()
|
||||
private val remoteSource by lazy { provideRemoteSource() }
|
||||
|
||||
/**
|
||||
* WaveLogApi is a plain object in core:domain, and shared code has no socket API of its own on
|
||||
* iOS, so the container hands it the platform client. Its requests used to be made by an
|
||||
* HttpURLConnection built inside WaveLogApi, which only ever existed on the JVM.
|
||||
*/
|
||||
init {
|
||||
WaveLogApi.installHttpClient(OkHttpHttpClient(OkHttpClient.Builder().build()))
|
||||
}
|
||||
private val mainHandler = CoroutineExceptionHandler { _, error -> println("MainHandler: $error") }
|
||||
override val appScope = CoroutineScope(SupervisorJob() + Dispatchers.Default + mainHandler)
|
||||
override val settingsRepo = provideSettingsRepo()
|
||||
override val selectionRepo = provideSelectionRepo()
|
||||
override val satelliteRepo = provideSatelliteRepo()
|
||||
override val databaseRepo = provideDatabaseRepo()
|
||||
override val amSatRepo = AmSatRepository(remoteSource)
|
||||
override val amSatRepo by lazy { AmSatRepository(remoteSource) }
|
||||
override val radioTrackingService: IRadioTrackingService by lazy {
|
||||
val manager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
|
||||
RadioTrackingService(appScope, manager, satelliteRepo, settingsRepo)
|
||||
@@ -96,13 +110,28 @@ class MainContainer(private val context: Context) : IMainContainer {
|
||||
|
||||
override fun provideAddToCalendar(): IAddToCalendar = AddToCalendar(context)
|
||||
|
||||
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) {}
|
||||
}
|
||||
|
||||
override fun provideShowToast(): IShowToast = ShowToast(context)
|
||||
|
||||
override fun provideAudioCapture(): IAudioCapture = AudioCapture()
|
||||
|
||||
// 每次调用返回新实例: 调用方负责 close() 释放 OrtSession, 且 Radar 内嵌
|
||||
// 面板与独立 CW 页各自持有自己的解码器
|
||||
override fun provideCwDecoder(): com.rtbishop.look4sat.core.domain.cw.ICwDecoder =
|
||||
com.rtbishop.look4sat.core.data.cw.CwDeepDecoder(context) {
|
||||
// Read per chunk so toggling the setting applies without restarting capture.
|
||||
settingsRepo.otherSettings.value.cwToneShiftEnabled
|
||||
}
|
||||
|
||||
override fun provideSaveImage(): ISaveImage = SaveImage(context)
|
||||
|
||||
// WaveLog 日志(4.5.2): 本地队列 + 上传器(共享实例)
|
||||
// WaveLog logging (4.5.2): local queue + uploader (shared instance)
|
||||
override val wavelogQueue: WavelogQueue by lazy {
|
||||
val prefs = context.getSharedPreferences("wavelog", Context.MODE_PRIVATE)
|
||||
WavelogQueue(object : IWavelogQueueStore {
|
||||
@@ -112,6 +141,28 @@ class MainContainer(private val context: Context) : IMainContainer {
|
||||
}
|
||||
override fun provideWavelogUploader(): WavelogUploader = WavelogUploader(settingsRepo, wavelogQueue)
|
||||
|
||||
private val lotwRepo: LotwSatellitesRepo by lazy {
|
||||
LotwSatellitesRepo(context).also { it.restore() }
|
||||
}
|
||||
|
||||
override fun provideLotwSatellitesRepo(): com.rtbishop.look4sat.core.domain.wavelog.ILotwSatellitesRepo = lotwRepo
|
||||
|
||||
/**
|
||||
* QRZ grid lookup. Holds the cookie read so no composable has to: the log screen used to pull
|
||||
* it out of SharedPreferences through LocalContext, putting disk access inside composition.
|
||||
*/
|
||||
private val qrzGridLookup: QrzGridLookup by lazy {
|
||||
QrzGridLookup(
|
||||
context.getSharedPreferences(QrzGridLookup.PREFS_NAME, Context.MODE_PRIVATE),
|
||||
OkHttpClient.Builder()
|
||||
.connectTimeout(15, java.util.concurrent.TimeUnit.SECONDS)
|
||||
.readTimeout(20, java.util.concurrent.TimeUnit.SECONDS)
|
||||
.build()
|
||||
)
|
||||
}
|
||||
|
||||
override fun provideQrzGridLookup(): IQrzGridLookup = qrzGridLookup
|
||||
|
||||
override fun provideBluetoothReporter(): IReporter {
|
||||
val manager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
|
||||
val rc = settingsRepo.rcSettings.value
|
||||
|
||||
@@ -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.data.qrz
|
||||
|
||||
import android.content.SharedPreferences
|
||||
import com.rtbishop.look4sat.core.domain.qrz.IQrzGridLookup
|
||||
import com.rtbishop.look4sat.core.domain.qrz.QrzGrid
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import okhttp3.OkHttpClient
|
||||
|
||||
/**
|
||||
* Grid lookup backed by the cookie the operator pasted into settings.
|
||||
*
|
||||
* Owns the cookie read so the log screen does not: a composable used to pull it out of
|
||||
* SharedPreferences through LocalContext on every submission, which put disk access inside
|
||||
* composition and went around the repository layer.
|
||||
*/
|
||||
class QrzGridLookup(
|
||||
private val preferences: SharedPreferences,
|
||||
httpClient: OkHttpClient,
|
||||
dispatcher: CoroutineDispatcher = Dispatchers.IO
|
||||
) : IQrzGridLookup {
|
||||
|
||||
private val source = QrzGridSource(httpClient, dispatcher)
|
||||
|
||||
override suspend fun lookup(callsign: String): QrzGrid {
|
||||
val cookie = preferences.getString(COOKIE_KEY, "").orEmpty()
|
||||
// No cookie and an expired one call for the same thing from the operator, so they report
|
||||
// the same way rather than adding a fourth outcome nobody could act on differently.
|
||||
if (cookie.isBlank()) return QrzGrid.SignedOut
|
||||
return source.lookupGrid(callsign, cookie)
|
||||
}
|
||||
|
||||
override suspend fun signedInAs(): String? {
|
||||
val cookie = preferences.getString(COOKIE_KEY, "").orEmpty()
|
||||
if (cookie.isBlank()) return null
|
||||
return source.lookupOwnCallsign(cookie)
|
||||
}
|
||||
|
||||
companion object {
|
||||
/** Where the settings screen stores what the operator pasted. */
|
||||
const val PREFS_NAME = "qrz_cookie"
|
||||
const val COOKIE_KEY = "cookie"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
/*
|
||||
* 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.qrz
|
||||
|
||||
import com.rtbishop.look4sat.core.domain.qrz.QrzGrid
|
||||
import com.rtbishop.look4sat.core.domain.qrz.QrzGridParser
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
|
||||
/**
|
||||
* Reads a station's Maidenhead locator off its QRZ.com page.
|
||||
*
|
||||
* QRZ has no free lookup API for this, so the page is fetched with the operator's own session
|
||||
* cookie and parsed. The cookie is pasted by the operator in settings and never built into the
|
||||
* app. Parsing lives in [QrzGridParser] so it can be tested without a network; this class only
|
||||
* fetches and retries.
|
||||
*/
|
||||
class QrzGridSource(
|
||||
private val httpClient: OkHttpClient,
|
||||
private val dispatcher: CoroutineDispatcher
|
||||
) {
|
||||
|
||||
/**
|
||||
* Look up [callsign]'s locator.
|
||||
*
|
||||
* Retried because this runs on a phone, mid-pass, often on mobile data - a single timeout
|
||||
* used to mean the QSO was logged without a grid and the operator was never told. Retries
|
||||
* are bounded and backed off so a genuinely unreachable QRZ costs at most a few seconds:
|
||||
* only transport failures are retried, since a page that loaded and parsed will not parse
|
||||
* differently on a second attempt.
|
||||
*/
|
||||
suspend fun lookupGrid(callsign: String, cookieHeader: String): QrzGrid =
|
||||
withContext(dispatcher) {
|
||||
if (callsign.isBlank() || cookieHeader.isBlank()) return@withContext QrzGrid.SignedOut
|
||||
val url = "$DB_URL${callsign.trim().uppercase()}"
|
||||
fetchWithRetry(url, cookieHeader)?.let(QrzGridParser::parseGrid)
|
||||
?: QrzGrid.Unreachable(MAX_ATTEMPTS)
|
||||
}
|
||||
|
||||
/**
|
||||
* The callsign the pasted cookie is signed in as, so settings can show the operator whose
|
||||
* account it belongs to rather than just claiming success.
|
||||
*/
|
||||
suspend fun lookupOwnCallsign(cookieHeader: String): String? = withContext(dispatcher) {
|
||||
if (cookieHeader.isBlank()) return@withContext null
|
||||
fetchWithRetry(DB_URL, cookieHeader)?.let(QrzGridParser::parseOwnCallsign)
|
||||
}
|
||||
|
||||
/** Fetch [url], retrying transport failures with backoff. Null when every attempt failed. */
|
||||
/**
|
||||
* Normalise whatever the operator pasted into a Cookie header value.
|
||||
*
|
||||
* They paste either a raw `k=v; k=v` header or the JSON array a cookie-export extension
|
||||
* produces. The old client normalised this and the rewrite dropped it, so a JSON export that
|
||||
* used to work went out as a literal JSON blob, QRZ served its signed-out page, and the app
|
||||
* told the operator their cookie had expired when it was perfectly good.
|
||||
*/
|
||||
private fun normalise(raw: String): String = QrzGridParser.cookieHeader(raw)
|
||||
|
||||
private suspend fun fetchWithRetry(url: String, rawCookie: String): String? {
|
||||
val cookieHeader = normalise(rawCookie)
|
||||
if (cookieHeader.isBlank()) return null
|
||||
repeat(MAX_ATTEMPTS) { attempt ->
|
||||
try {
|
||||
val request = Request.Builder().url(url)
|
||||
.header("User-Agent", USER_AGENT)
|
||||
.header("Cookie", cookieHeader)
|
||||
.build()
|
||||
httpClient.newCall(request).execute().use { response ->
|
||||
if (response.isSuccessful) return response.body.string()
|
||||
// A 4xx will repeat identically, so only server-side faults are worth retrying.
|
||||
if (response.code < 500) return null
|
||||
}
|
||||
} catch (exception: CancellationException) {
|
||||
throw exception
|
||||
} catch (exception: Exception) {
|
||||
println("QrzGridSource attempt ${attempt + 1} failed: $exception")
|
||||
}
|
||||
if (attempt < MAX_ATTEMPTS - 1) delay(BACKOFF_MS[attempt])
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private companion object {
|
||||
/** Bare form is the signed-in home page; a callsign appended is that station's page. */
|
||||
const val DB_URL = "https://www.qrz.com/db/"
|
||||
const val USER_AGENT = "Mozilla/5.0 (Linux; Android 13) Look4Sat"
|
||||
const val MAX_ATTEMPTS = 3
|
||||
|
||||
/** Waits before the second and third attempt. Short enough to finish inside a pass. */
|
||||
val BACKOFF_MS = longArrayOf(700L, 2_000L)
|
||||
}
|
||||
}
|
||||
+241
-9
@@ -1,21 +1,253 @@
|
||||
package com.rtbishop.look4sat.core.data.repository
|
||||
|
||||
import com.rtbishop.look4sat.core.data.source.AmSatParser
|
||||
import com.rtbishop.look4sat.core.domain.model.SatDay
|
||||
import com.rtbishop.look4sat.core.domain.model.SatReport
|
||||
import com.rtbishop.look4sat.core.domain.model.SatSlot
|
||||
import com.rtbishop.look4sat.core.domain.model.SatStatus
|
||||
import com.rtbishop.look4sat.core.domain.model.SatStatusPage
|
||||
import com.rtbishop.look4sat.core.domain.repository.IAmSatRepository
|
||||
import com.rtbishop.look4sat.core.domain.source.IRemoteSource
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.json.JSONObject
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Calendar
|
||||
import java.util.Locale
|
||||
import java.util.TimeZone
|
||||
|
||||
/** AMSAT 状态仓库: 抓 HTML → 解析 → SatStatusPage */
|
||||
/**
|
||||
* One report from the AMSAT API (data layer model).
|
||||
*
|
||||
* Internal rather than private so [AmSatRepository.buildStatuses] can be unit-tested:
|
||||
* the JSON parsing around it needs Android's JSONObject, which is a stub on the JVM.
|
||||
*/
|
||||
internal data class ApiReport(
|
||||
val id: String,
|
||||
val name: String,
|
||||
val callsign: String,
|
||||
val report: String,
|
||||
val gridSquare: String,
|
||||
val reportedTimeUtcSec: Long
|
||||
)
|
||||
|
||||
/** AMSAT status repository using RemoteSource (Clean Architecture: data layer handles HTTP). */
|
||||
class AmSatRepository(private val remoteSource: IRemoteSource) : IAmSatRepository {
|
||||
|
||||
override suspend fun fetchStatus(): SatStatusPage? {
|
||||
val html = remoteSource.getStatusHtml() ?: return null
|
||||
if (html.isBlank()) return null
|
||||
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) {
|
||||
val nowSec = System.currentTimeMillis() / 1000
|
||||
val catalogJson = remoteSource.getAmSatCatalog() ?: return@withContext 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 names = parseCatalog(catalogJson)
|
||||
val reports = parseReports(reportsJson)
|
||||
|
||||
if (names.isEmpty() && reports.isEmpty()) return@withContext null
|
||||
|
||||
val statuses = buildStatuses(names, reports, nowSec)
|
||||
val reportMap = reports.associate { it.id to toSatReport(it) }
|
||||
|
||||
// The summary endpoint tells us how many reports each satellite actually has,
|
||||
// independent of the 500-record cap. Mark any satellite whose global pull is
|
||||
// incomplete so the UI can show a data-coverage note.
|
||||
val summaryJson = remoteSource.getAmSatSummary(hours = 72)
|
||||
val expectedCounts = parseSummary(summaryJson)
|
||||
val marked = statuses.map { status ->
|
||||
val expected = expectedCounts[status.name]
|
||||
val actual = status.days.sumOf { day -> day.slots.sumOf { it.count } }
|
||||
if (expected != null && expected > actual) status.copy(summaryCount = expected)
|
||||
else status
|
||||
}
|
||||
|
||||
SatStatusPage(System.currentTimeMillis(), marked, reportMap)
|
||||
}
|
||||
|
||||
/** Parse catalog JSON to list of satellite names */
|
||||
private fun parseCatalog(json: String): List<String> {
|
||||
return try {
|
||||
AmSatParser.parse(html, System.currentTimeMillis())
|
||||
} catch (exception: Exception) {
|
||||
println("AmSatRepository parse exception: $exception")
|
||||
null
|
||||
val arr = JSONObject(json).getJSONArray("data")
|
||||
(0 until arr.length()).map { arr.getJSONObject(it).getString("name") }
|
||||
} catch (_: Exception) {
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
/** Parse reports JSON to list of ApiReport domain objects */
|
||||
private fun parseReports(json: String): List<ApiReport> {
|
||||
return try {
|
||||
val arr = JSONObject(json).getJSONArray("data")
|
||||
(0 until arr.length()).mapNotNull { i ->
|
||||
val o = arr.getJSONObject(i)
|
||||
val iso = o.optString("reported_time", "")
|
||||
if (iso.isEmpty()) null else ApiReport(
|
||||
id = o.optString("id", ""),
|
||||
name = o.optString("name", ""),
|
||||
callsign = o.optString("callsign", ""),
|
||||
report = o.optString("report", ""),
|
||||
gridSquare = o.optString("grid_square", ""),
|
||||
reportedTimeUtcSec = parseIsoUtcSec(iso)
|
||||
)
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse summary JSON to per-satellite report counts.
|
||||
*
|
||||
* The summary aggregates across all statuses, so a satellite with both "heard" and
|
||||
* "not heard" entries appears once; we sum its report_count across all its rows.
|
||||
* Returns an empty map (not null) on failure so the caller can just check for
|
||||
* missing keys — a failed summary call degrades gracefully to "no coverage marker".
|
||||
*/
|
||||
private fun parseSummary(json: String?): Map<String, Int> {
|
||||
if (json == null) return emptyMap()
|
||||
return try {
|
||||
val arr = JSONObject(json).getJSONArray("data")
|
||||
val out = mutableMapOf<String, Int>()
|
||||
for (i in 0 until arr.length()) {
|
||||
val o = arr.getJSONObject(i)
|
||||
val name = o.optString("name", "")
|
||||
val count = o.optInt("report_count", 0)
|
||||
if (name.isNotEmpty() && count > 0) {
|
||||
out[name] = (out[name] ?: 0) + count
|
||||
}
|
||||
}
|
||||
out
|
||||
} catch (_: Exception) {
|
||||
emptyMap()
|
||||
}
|
||||
}
|
||||
|
||||
/** Parse ISO 8601 UTC timestamp to epoch seconds (e.g., "2026-08-05T07:30:00Z") */
|
||||
private fun parseIsoUtcSec(iso: String): Long {
|
||||
return try {
|
||||
(isoUtcFormat.parse(iso)?.time ?: 0L) / 1000
|
||||
} catch (_: Exception) {
|
||||
0L
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build one SatStatus (3 days x 12 two-hour slots) per catalog satellite.
|
||||
*
|
||||
* Days are UTC calendar days and slots are fixed UTC bands, matching amsat.org: day 0
|
||||
* is today, its slot 0 covers 22:00-24:00 UTC and slot 11 covers 00:00-02:00, so both
|
||||
* the day list and the slots inside it read newest-first.
|
||||
*
|
||||
* A rolling window anchored on "now" was wrong: fetching at 06:07 UTC put 17.9 hours
|
||||
* of yesterday into the cell labelled today. Checked against a live amsat.org page of
|
||||
* 1021 reports, 73% landed in the wrong day column.
|
||||
*/
|
||||
internal fun buildStatuses(names: List<String>, reports: List<ApiReport>, nowSec: Long): List<SatStatus> {
|
||||
val byName = reports.groupBy { it.name }
|
||||
val monthAbbr = arrayOf("Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec")
|
||||
val utc = Calendar.getInstance(TimeZone.getTimeZone("UTC"))
|
||||
|
||||
// Midnight UTC today, the anchor every slot boundary is derived from.
|
||||
utc.timeInMillis = nowSec * 1000
|
||||
utc.set(Calendar.HOUR_OF_DAY, 0)
|
||||
utc.set(Calendar.MINUTE, 0)
|
||||
utc.set(Calendar.SECOND, 0)
|
||||
utc.set(Calendar.MILLISECOND, 0)
|
||||
val todayMidnightSec = utc.timeInMillis / 1000
|
||||
|
||||
// Reuses the same Calendar, which is safe only because each pass assigns
|
||||
// timeInMillis outright rather than adjusting fields. After this loop it points at
|
||||
// the oldest day, so anything added below must set the time again before reading.
|
||||
val labels = (0 until 3).map { d ->
|
||||
utc.timeInMillis = (todayMidnightSec - d * 86400L) * 1000
|
||||
"${monthAbbr[utc.get(Calendar.MONTH)]} ${utc.get(Calendar.DAY_OF_MONTH)}"
|
||||
}
|
||||
// Oldest report across the whole response, marking how far back the data reaches.
|
||||
// Taken globally rather than per satellite: a quiet satellite has no reports of its
|
||||
// own, but the slots it shares with the rest of the response were still covered.
|
||||
//
|
||||
// Timestamps of zero are excluded: parseIsoUtcSec returns 0 when a reported_time
|
||||
// fails to parse, and a single such record would drag this back to 1970 and mark
|
||||
// nothing as uncovered, silently reverting the distinction.
|
||||
val dataFromSec = reports.asSequence()
|
||||
.map { it.reportedTimeUtcSec }
|
||||
.filter { it > 0L }
|
||||
.minOrNull()
|
||||
?: todayMidnightSec
|
||||
|
||||
return names.map { name ->
|
||||
val satReports = byName[name].orEmpty()
|
||||
val days = (0 until 3).map { dayIdx ->
|
||||
val dayStart = todayMidnightSec - dayIdx * 86400L
|
||||
val slots = (0 until 12).map { slotIdx ->
|
||||
// Slot 0 is the last band of the day, so the day reads newest-first.
|
||||
val slotStart = dayStart + (11 - slotIdx) * 7200L
|
||||
val slotEnd = slotStart + 7200L
|
||||
val inSlot = satReports.filter { it.reportedTimeUtcSec in slotStart until slotEnd }
|
||||
if (inSlot.isEmpty()) {
|
||||
// A slot entirely before the data starts is unknown, not silent.
|
||||
val colour = if (slotEnd <= dataFromSec) NO_DATA_GRAY else NO_REPORT_GRAY
|
||||
SatSlot(statusColor = colour, count = 0)
|
||||
} else {
|
||||
val newest = inSlot.maxByOrNull { it.reportedTimeUtcSec }!!
|
||||
SatSlot(
|
||||
statusColor = statusColorOf(newest.report),
|
||||
count = inSlot.size,
|
||||
reportIds = inSlot.map { it.id }
|
||||
)
|
||||
}
|
||||
}
|
||||
SatDay(dateLabel = labels[dayIdx], slots = slots)
|
||||
}
|
||||
SatStatus(name = name, days = days)
|
||||
}
|
||||
}
|
||||
|
||||
private fun toSatReport(r: ApiReport): SatReport {
|
||||
val cal = Calendar.getInstance(TimeZone.getTimeZone("UTC"))
|
||||
cal.timeInMillis = r.reportedTimeUtcSec * 1000
|
||||
val hh = cal.get(Calendar.HOUR_OF_DAY).toString().padStart(2, '0')
|
||||
val mm = cal.get(Calendar.MINUTE).toString().padStart(2, '0')
|
||||
val y = cal.get(Calendar.YEAR)
|
||||
val mo = (cal.get(Calendar.MONTH) + 1).toString().padStart(2, '0')
|
||||
val d = cal.get(Calendar.DAY_OF_MONTH).toString().padStart(2, '0')
|
||||
return SatReport(
|
||||
id = r.id,
|
||||
statusText = r.report,
|
||||
call = r.callsign,
|
||||
grid = r.gridSquare,
|
||||
dateUtc = "$y-$mo-$d",
|
||||
timeUtc = "$hh:$mm UTC"
|
||||
)
|
||||
}
|
||||
|
||||
/** Map status text to color value (for UI rendering). */
|
||||
private fun statusColorOf(report: String): Long = when (report.lowercase()) {
|
||||
"heard", "crew active" -> ACTIVE_BLUE
|
||||
"telemetry only" -> TLM_ORANGE
|
||||
"not heard" -> NOT_HEARD_PINK
|
||||
else -> CONFLICT_DEEP_ORANGE
|
||||
}
|
||||
|
||||
companion object {
|
||||
// AMSAT official status colors (from amsat.org/status)
|
||||
private const val ACTIVE_BLUE = 0xFF648FFF
|
||||
private const val TLM_ORANGE = 0xFFFFB000
|
||||
private const val NOT_HEARD_PINK = 0xFFDC267F
|
||||
private const val CONFLICT_DEEP_ORANGE = 0xFFFE6100
|
||||
private const val NO_REPORT_GRAY = 0xFFC0C0C0
|
||||
|
||||
/**
|
||||
* Slots older than the data we actually received.
|
||||
*
|
||||
* The API caps at 500 records however many hours are requested. Measured live: a
|
||||
* 72-hour request returned 500 reports spanning only 49 hours, leaving the oldest
|
||||
* 9.5 hours of the third day with no data at all. Painting those the same grey as
|
||||
* "nobody reported" claimed knowledge we do not have, so they get a lighter shade.
|
||||
*/
|
||||
private const val NO_DATA_GRAY = 0xFFE8E8E8
|
||||
}
|
||||
}
|
||||
+70
-40
@@ -29,7 +29,7 @@ import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.awaitAll
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.InputStream
|
||||
import java.io.ByteArrayInputStream
|
||||
import java.util.zip.ZipInputStream
|
||||
|
||||
class DatabaseRepo(
|
||||
@@ -42,10 +42,21 @@ class DatabaseRepo(
|
||||
|
||||
private val customSourceType = "Other"
|
||||
|
||||
/**
|
||||
* Type key for satellites fetched from a custom URL.
|
||||
*
|
||||
* Separate from customSourceType because setSatelliteTypeIds overwrites rather than merges, so
|
||||
* sharing "Other" with manual file import meant each wiped the other's type index. The
|
||||
* satellites stayed in the database and stayed selectable either way - only their grouping in
|
||||
* the type filter was lost - but the two sources are different things and deserve different
|
||||
* keys.
|
||||
*/
|
||||
private val customUrlType = "Custom"
|
||||
|
||||
override suspend fun updateTLEFromFile(uri: String): Int = withContext(dispatcher) {
|
||||
var importedCount = 0
|
||||
remoteSource.getFileStream(uri)?.let { stream ->
|
||||
val entries = parseSatelliteStream(uri, unwrapIfZipped(uri, stream))
|
||||
remoteSource.getFileBytes(uri)?.let { data ->
|
||||
val entries = parseSatelliteData(uri, unwrapIfZipped(uri, data))
|
||||
localSource.insertEntries(entries)
|
||||
settingsRepo.setSatelliteTypeIds(customSourceType, entries.map { it.catnum })
|
||||
importedCount = entries.size
|
||||
@@ -56,8 +67,8 @@ class DatabaseRepo(
|
||||
|
||||
override suspend fun updateTransceiversFromFile(uri: String): Int = withContext(dispatcher) {
|
||||
var importedCount = 0
|
||||
remoteSource.getFileStream(uri)?.let { stream ->
|
||||
val transceivers = dataParser.parseJSONStream(unwrapIfZipped(uri, stream))
|
||||
remoteSource.getFileBytes(uri)?.let { data ->
|
||||
val transceivers = dataParser.parseJSON(unwrapIfZipped(uri, data).decodeToString())
|
||||
localSource.insertRadios(transceivers)
|
||||
importedCount = transceivers.size
|
||||
}
|
||||
@@ -67,36 +78,56 @@ class DatabaseRepo(
|
||||
|
||||
override suspend fun updateFromRemote() = withContext(dispatcher) {
|
||||
val dataSourcesSettings = settingsRepo.dataSourcesSettings.value
|
||||
val tleUrls = buildMap {
|
||||
putAll(Sources.satelliteDataUrls)
|
||||
// 开关开且 URL 非空 -> All 用自定义 URL;否则用默认 URL(在线更新默认源)
|
||||
put("All", if (dataSourcesSettings.useCustomTLE && dataSourcesSettings.tleUrl.isNotBlank())
|
||||
dataSourcesSettings.tleUrl else Sources.defaultTleUrl)
|
||||
}.filterValues { it.isNotBlank() }
|
||||
val radioUrls = buildMap {
|
||||
putAll(Sources.transceiversDataUrls)
|
||||
put("SatNOGS", if (dataSourcesSettings.useCustomTransceivers && dataSourcesSettings.transceiversUrl.isNotBlank())
|
||||
dataSourcesSettings.transceiversUrl else Sources.defaultTransceiversUrl)
|
||||
}.filterValues { it.isNotBlank() }
|
||||
// A custom URL REPLACES the built-in sources rather than joining them. The previous map
|
||||
// overwrote only the "All" value and still fetched the other 26, so switching this on meant
|
||||
// "my source AND yours" - which defeats the reasons for setting one: a mirror, a filtered
|
||||
// subset, an offline server, or a network where Celestrak is unreachable. On a blocked link
|
||||
// the real behaviour was 26 failing requests.
|
||||
//
|
||||
// Satellites already stored do not disappear: insertEntries is OnConflictStrategy.REPLACE
|
||||
// and nothing is deleted before the insert, so rows the new source does not mention survive.
|
||||
// The type index for the skipped keys goes stale rather than empty, which is the honest
|
||||
// outcome - it is the last known membership, not a claim about this fetch.
|
||||
//
|
||||
// The key is customUrlType, not "All": setSatelliteTypeIds early-returns on "All", so
|
||||
// indexing under it was always a no-op and satellites from a custom URL were never
|
||||
// reachable by the type filter at all. They now are.
|
||||
val tleUrls = if (dataSourcesSettings.useCustomTLE && dataSourcesSettings.tleUrl.isNotBlank()) {
|
||||
mapOf(customUrlType to dataSourcesSettings.tleUrl)
|
||||
} else {
|
||||
Sources.satelliteDataUrls.filterValues { it.isNotBlank() }
|
||||
}
|
||||
val radioUrls = if (dataSourcesSettings.useCustomTransceivers &&
|
||||
dataSourcesSettings.transceiversUrl.isNotBlank()
|
||||
) {
|
||||
mapOf("SatNOGS" to dataSourcesSettings.transceiversUrl)
|
||||
} else {
|
||||
Sources.transceiversDataUrls.filterValues { it.isNotBlank() }
|
||||
}
|
||||
// launch all network requests concurrently
|
||||
val tleJobs = tleUrls.values.map { url -> async { url to remoteSource.getNetworkStream(url) } }
|
||||
val radioJobs = radioUrls.values.map { url -> async { url to remoteSource.getNetworkStream(url) } }
|
||||
// 统计成功源数: 0 成功视为更新失败(不刷时间戳, 抛异常让 UI 提示)
|
||||
val tleJobs = tleUrls.values.map { url -> async { url to remoteSource.getNetworkBytes(url) } }
|
||||
val radioJobs = radioUrls.values.map { url -> async { url to remoteSource.getNetworkBytes(url) } }
|
||||
val tleResults = tleJobs.awaitAll()
|
||||
val radioResults = radioJobs.awaitAll()
|
||||
val successCount = tleResults.count { it.second != null } + radioResults.count { it.second != null }
|
||||
if (successCount == 0) {
|
||||
throw java.io.IOException("All data sources failed to download")
|
||||
// Orbital elements are counted on their own. A combined count let a successful transceivers
|
||||
// fetch stand in for a failed orbital one: with a custom TLE URL there are two requests
|
||||
// rather than 28, so if that URL was down and SatNOGS answered, the total was 1, no
|
||||
// exception was raised, and setUpdateSuccessful stamped a fresh timestamp for an update
|
||||
// that refreshed no orbital data at all - which also suppressed the 48-hour auto-update
|
||||
// retry that keys off that timestamp. The failure existed before but 26 other sources hid
|
||||
// it; replacing them made it easy to hit.
|
||||
if (tleResults.none { it.second != null }) {
|
||||
throw java.io.IOException("No orbital data source could be downloaded")
|
||||
}
|
||||
// parse fetched data concurrently and associate with types
|
||||
val importedEntries = tleResults.flatMap { (url, stream) ->
|
||||
val importedEntries = tleResults.flatMap { (url, data) ->
|
||||
val type = tleUrls.entries.find { it.value == url }?.key ?: customSourceType
|
||||
stream?.let { parseSatelliteStream(url, unwrapIfZipped(url, it)) }.orEmpty().also { entries ->
|
||||
data?.let { parseSatelliteData(url, unwrapIfZipped(url, it)) }.orEmpty().also { entries ->
|
||||
settingsRepo.setSatelliteTypeIds(type, entries.map { it.catnum })
|
||||
}
|
||||
}
|
||||
val importedRadios = radioResults.flatMap { (url, stream) ->
|
||||
stream?.let { dataParser.parseJSONStream(unwrapIfZipped(url, it)) }.orEmpty()
|
||||
val importedRadios = radioResults.flatMap { (url, data) ->
|
||||
data?.let { dataParser.parseJSON(unwrapIfZipped(url, it).decodeToString()) }.orEmpty()
|
||||
}
|
||||
// insert parsed data into the database
|
||||
localSource.insertEntries(importedEntries)
|
||||
@@ -110,11 +141,11 @@ class DatabaseRepo(
|
||||
setUpdateSuccessful(0L)
|
||||
}
|
||||
|
||||
private suspend fun parseSatelliteStream(url: String, stream: InputStream): List<OrbitalData> {
|
||||
val bufferedStream = stream.buffered()
|
||||
private suspend fun parseSatelliteData(url: String, data: ByteArray): List<OrbitalData> {
|
||||
val text = data.decodeToString()
|
||||
return when {
|
||||
hasCsvHint(url) || looksLikeCsv(bufferedStream) -> dataParser.parseCSVStream(bufferedStream)
|
||||
else -> dataParser.parseTLEStream(bufferedStream)
|
||||
hasCsvHint(url) || looksLikeCsv(text) -> dataParser.parseCSV(text)
|
||||
else -> dataParser.parseTLE(text)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -124,14 +155,9 @@ class DatabaseRepo(
|
||||
url.endsWith(".csv.zip", ignoreCase = true)
|
||||
}
|
||||
|
||||
private fun looksLikeCsv(stream: InputStream): Boolean {
|
||||
if (!stream.markSupported()) return false
|
||||
stream.mark(4096)
|
||||
val preview = ByteArray(4096)
|
||||
val length = stream.read(preview)
|
||||
stream.reset()
|
||||
if (length <= 0) return false
|
||||
val line = preview.decodeToString(0, length).lineSequence().firstOrNull()?.trim().orEmpty()
|
||||
private fun looksLikeCsv(text: String): Boolean {
|
||||
val line = text.lineSequence().firstOrNull()?.trim().orEmpty()
|
||||
if (line.isEmpty()) return false
|
||||
return line.contains("OBJECT_NAME", ignoreCase = true) ||
|
||||
line.contains("NORAD_CAT_ID", ignoreCase = true) ||
|
||||
line.count { it == ',' } >= 4
|
||||
@@ -143,6 +169,10 @@ class DatabaseRepo(
|
||||
)
|
||||
}
|
||||
|
||||
private fun unwrapIfZipped(url: String, stream: InputStream): InputStream =
|
||||
if (url.endsWith(".zip", ignoreCase = true)) ZipInputStream(stream).apply { nextEntry } else stream
|
||||
private fun unwrapIfZipped(url: String, data: ByteArray): ByteArray =
|
||||
if (url.endsWith(".zip", ignoreCase = true)) {
|
||||
ZipInputStream(ByteArrayInputStream(data)).apply { nextEntry }.readBytes()
|
||||
} else {
|
||||
data
|
||||
}
|
||||
}
|
||||
+71
-49
@@ -34,9 +34,13 @@ import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.util.TimeZone
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
class SatelliteRepo(
|
||||
private val dispatcher: CoroutineDispatcher,
|
||||
@@ -50,6 +54,10 @@ class SatelliteRepo(
|
||||
private val _isCalculating = MutableStateFlow(false)
|
||||
override val isCalculating: StateFlow<Boolean> = _isCalculating
|
||||
|
||||
// Serializes pass calculation. Callers queue instead of being dropped: a
|
||||
// dropped call would silently discard the filter the user just applied.
|
||||
private val calculationMutex = Mutex()
|
||||
|
||||
private val _satellites = MutableStateFlow<List<OrbitalObject>>(emptyList())
|
||||
override val satellites: StateFlow<List<OrbitalObject>> = _satellites
|
||||
|
||||
@@ -63,19 +71,23 @@ class SatelliteRepo(
|
||||
override suspend fun getRadiosWithId(id: Int) = localStorage.getRadiosWithId(id)
|
||||
|
||||
override suspend fun initRepository() = withContext(dispatcher) {
|
||||
settingsRepo.selectedIds.collect { selectedIds ->
|
||||
_satellites.update { localStorage.getEntriesWithIds(selectedIds) }
|
||||
val settings = settingsRepo.passesSettings.value
|
||||
calculatePasses(
|
||||
time = System.currentTimeMillis(),
|
||||
hoursAhead = settings.hoursAhead,
|
||||
minElevation = settings.minElevation,
|
||||
aosStartMinute = settings.aosStartMinute,
|
||||
aosEndMinute = settings.aosEndMinute,
|
||||
invertAosTimeWindow = settings.invertAosTimeWindow,
|
||||
modes = settings.selectedModes
|
||||
)
|
||||
}
|
||||
combine(
|
||||
settingsRepo.selectedIds,
|
||||
settingsRepo.stationPosition
|
||||
) { selectedIds, _ -> selectedIds }
|
||||
.collect { selectedIds ->
|
||||
_satellites.update { localStorage.getEntriesWithIds(selectedIds) }
|
||||
val settings = settingsRepo.passesSettings.value
|
||||
calculatePasses(
|
||||
time = System.currentTimeMillis(),
|
||||
hoursAhead = settings.hoursAhead,
|
||||
minElevation = settings.minElevation,
|
||||
aosStartMinute = settings.aosStartMinute,
|
||||
aosEndMinute = settings.aosEndMinute,
|
||||
invertAosTimeWindow = settings.invertAosTimeWindow,
|
||||
modes = settingsRepo.selectedSatModes.value
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun getPosition(sat: OrbitalObject, pos: GeoPos, time: Long): OrbitalPos {
|
||||
@@ -117,45 +129,55 @@ class SatelliteRepo(
|
||||
invertAosTimeWindow: Boolean,
|
||||
modes: List<String>
|
||||
) {
|
||||
_isCalculating.value = true
|
||||
// Normalize to the start of the current minute so that coarse 60-second stepping
|
||||
// in getLeoPass always begins from the same phase, producing stable AOS/LOS times
|
||||
val normalizedTime = time / 60_000L * 60_000L
|
||||
val currentSatellites = _satellites.value
|
||||
withContext(dispatcher) {
|
||||
val idsWithModes = localStorage.getIdsWithModes(modes)
|
||||
val stationPos = settingsRepo.stationPosition.value
|
||||
val filteredSatellites = if (idsWithModes.isEmpty()) {
|
||||
currentSatellites
|
||||
} else {
|
||||
currentSatellites.filter { it.data.catnum in idsWithModes }
|
||||
}
|
||||
// Compute passes for each satellite in parallel
|
||||
val passLists = coroutineScope {
|
||||
filteredSatellites.map { satellite ->
|
||||
async { satellite.getPasses(stationPos, normalizedTime, hoursAhead) }
|
||||
}.awaitAll()
|
||||
}
|
||||
// Flatten and filter in a single pass
|
||||
val timeFuture = normalizedTime + (hoursAhead * 60L * 60L * 1000L)
|
||||
val newPasses = ArrayList<OrbitalPass>()
|
||||
for (list in passLists) {
|
||||
for (pass in list) {
|
||||
if (
|
||||
pass.losTime > time
|
||||
&& pass.aosTime < timeFuture
|
||||
&& pass.maxElevation > minElevation
|
||||
&& (pass.isDeepSpace || isAosInRange(pass.aosTime, aosStartMinute, aosEndMinute, invertAosTimeWindow))
|
||||
) {
|
||||
newPasses.add(pass)
|
||||
// Queue behind any in-flight calculation rather than dropping this call:
|
||||
// every invocation carries filter settings the user just chose, so a
|
||||
// dropped one leaves the list showing results for the previous filter.
|
||||
calculationMutex.withLock {
|
||||
_isCalculating.value = true
|
||||
try {
|
||||
// Normalize to the start of the current minute so that coarse 60-second stepping
|
||||
// in getLeoPass always begins from the same phase, producing stable AOS/LOS times
|
||||
val normalizedTime = time / 60_000L * 60_000L
|
||||
val currentSatellites = _satellites.value
|
||||
withContext(dispatcher) {
|
||||
val idsWithModes = localStorage.getIdsWithModes(modes)
|
||||
val stationPos = settingsRepo.stationPosition.value
|
||||
val filteredSatellites = if (idsWithModes.isEmpty()) {
|
||||
currentSatellites
|
||||
} else {
|
||||
currentSatellites.filter { it.data.catnum in idsWithModes }
|
||||
}
|
||||
// Compute passes for each satellite in parallel
|
||||
val passLists = coroutineScope {
|
||||
filteredSatellites.map { satellite ->
|
||||
async { satellite.getPasses(stationPos, normalizedTime, hoursAhead) }
|
||||
}.awaitAll()
|
||||
}
|
||||
// Flatten and filter in a single pass
|
||||
val timeFuture = normalizedTime + (hoursAhead * 60L * 60L * 1000L)
|
||||
val newPasses = ArrayList<OrbitalPass>()
|
||||
for (list in passLists) {
|
||||
for (pass in list) {
|
||||
if (
|
||||
pass.losTime > time
|
||||
&& pass.aosTime < timeFuture
|
||||
&& pass.maxElevation > minElevation
|
||||
&& (pass.isDeepSpace || isAosInRange(pass.aosTime, aosStartMinute, aosEndMinute, invertAosTimeWindow))
|
||||
) {
|
||||
newPasses.add(pass)
|
||||
}
|
||||
}
|
||||
}
|
||||
newPasses.sortBy { it.aosTime }
|
||||
delay(1000) // Simulate loading time for better UX
|
||||
_passes.update { newPasses }
|
||||
}
|
||||
} finally {
|
||||
// finally: a thrown/cancelled calculation must not leave the
|
||||
// progress indicator spinning forever.
|
||||
_isCalculating.value = false
|
||||
}
|
||||
newPasses.sortBy { it.aosTime }
|
||||
delay(1000) // Simulate loading time for better UX
|
||||
_passes.update { newPasses }
|
||||
}
|
||||
_isCalculating.value = false
|
||||
}
|
||||
|
||||
private fun isAosInRange(
|
||||
@@ -170,7 +192,7 @@ class SatelliteRepo(
|
||||
val inRange = if (aosStartMinute <= aosEndMinute) {
|
||||
aosMinute in aosStartMinute..aosEndMinute
|
||||
} else {
|
||||
aosMinute >= aosStartMinute || aosMinute <= aosEndMinute
|
||||
aosMinute !in (aosEndMinute + 1)..<aosStartMinute
|
||||
}
|
||||
return if (invertAosTimeWindow) !inRange else inRange
|
||||
}
|
||||
|
||||
+16
-13
@@ -38,16 +38,16 @@ class SelectionRepo(
|
||||
) : ISelectionRepo {
|
||||
|
||||
private val currentItems = MutableStateFlow<List<SatItem>>(emptyList())
|
||||
private val currentTypes = MutableStateFlow(settingsRepo.selectedTypes.value)
|
||||
private val currentQuery = MutableStateFlow("")
|
||||
|
||||
// Resolve type IDs once when types change, then filter items reactively.
|
||||
// Resolve sat IDs once when modes change, then filter items reactively.
|
||||
// The HashSet gives O(1) catnum lookups instead of O(n) with a List.
|
||||
private val itemsWithTypes = currentTypes.flatMapLatest { types: List<String> ->
|
||||
val catnumSet: Set<Int>? = if (types.isEmpty()) {
|
||||
// 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()) {
|
||||
null // null = no filtering
|
||||
} else {
|
||||
val ids = settingsRepo.getSatelliteTypesIds(types)
|
||||
val ids = localSource.getIdsWithModes(list)
|
||||
if (ids.isEmpty()) null else ids.toHashSet()
|
||||
}
|
||||
currentItems.map { items ->
|
||||
@@ -56,14 +56,18 @@ class SelectionRepo(
|
||||
}
|
||||
|
||||
private val itemsWithQuery = currentQuery.flatMapLatest { query ->
|
||||
itemsWithTypes.map { items -> filterByQuery(items, query) }
|
||||
itemsWithModes.map { items ->
|
||||
filterByQuery(items, query).sortedWith(
|
||||
compareByDescending<SatItem> { it.isSelected }
|
||||
.thenBy { it.name }
|
||||
.thenBy { it.catnum }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override fun getCurrentTypes() = currentTypes.value
|
||||
override fun getCurrentModes() = settingsRepo.selectedSatModes.value
|
||||
|
||||
override fun getTypesList() = Sources.satelliteDataUrls.keys.sorted().toMutableList().apply {
|
||||
removeAt(0)
|
||||
}
|
||||
override fun getModesList() = Sources.satelliteModes
|
||||
|
||||
override suspend fun getEntriesFlow() = withContext(dispatcher) {
|
||||
val selectedIds = settingsRepo.selectedIds.value.toHashSet()
|
||||
@@ -73,9 +77,8 @@ class SelectionRepo(
|
||||
return@withContext itemsWithQuery
|
||||
}
|
||||
|
||||
override suspend fun setTypes(types: List<String>) {
|
||||
currentTypes.value = types
|
||||
settingsRepo.setSelectedTypes(types)
|
||||
override suspend fun setModes(modes: List<String>) {
|
||||
settingsRepo.setSelectedSatModes(modes)
|
||||
}
|
||||
|
||||
override suspend fun setQuery(query: String) {
|
||||
|
||||
+108
-35
@@ -29,15 +29,18 @@ 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.source.Sources
|
||||
import com.rtbishop.look4sat.core.domain.model.Constants
|
||||
|
||||
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
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import org.json.JSONObject
|
||||
|
||||
class SettingsRepo(
|
||||
private val context: android.content.Context,
|
||||
@@ -69,9 +72,9 @@ class SettingsRepo(
|
||||
private val keyFrequencyAddress = "frequencyAddress"
|
||||
private val keyFrequencyPort = "frequencyPort"
|
||||
private val keyFrequencyFormat = "frequencyFormat"
|
||||
private val keyFrequencyOffsetHz = "frequencyOffsetHz"
|
||||
private val keySelectedIds = "selectedIds"
|
||||
private val keySelectedTypes = "selectedTypes"
|
||||
private val keySelectedModes = "selectedModes"
|
||||
private val keySelectedSatModes = "selectedSatModes"
|
||||
private val keyStateOfAutoUpdate = "stateOfAutoUpdate"
|
||||
private val keyStateOfSensors = "stateOfSensors"
|
||||
private val keyStateOfSweep = "stateOfSweep"
|
||||
@@ -100,13 +103,18 @@ class SettingsRepo(
|
||||
private val keyWavelogApiKey = "wavelogApiKey"
|
||||
private val keyWavelogStationId = "wavelogStationId"
|
||||
private val keyWavelogAutoUpload = "wavelogAutoUpload"
|
||||
private val keyRadarCompassOffset = "radarCompassOffset"
|
||||
private val keyRadarCompassOffsetElev = "radarCompassOffsetElev"
|
||||
private val keyCwToneShiftEnabled = "cwToneShiftEnabled"
|
||||
private val keyAmsatDayStripes = "amsatDayStripes"
|
||||
|
||||
private val separatorComma = ","
|
||||
|
||||
//region # Satellites selection settings
|
||||
private val _satelliteSelection = MutableStateFlow(getSelectedIds())
|
||||
private val _typesSelection = MutableStateFlow(getSelectedTypes())
|
||||
private val _satelliteModeSelection = MutableStateFlow(getSelectedSatModes())
|
||||
override val selectedIds: StateFlow<List<Int>> = _satelliteSelection
|
||||
override val selectedTypes: StateFlow<List<String>> = _typesSelection
|
||||
override val selectedSatModes: StateFlow<List<String>> = _satelliteModeSelection
|
||||
|
||||
override fun setSelectedIds(ids: List<Int>) {
|
||||
val selectionString = ids.joinToString(separatorComma)
|
||||
@@ -114,10 +122,10 @@ class SettingsRepo(
|
||||
_satelliteSelection.value = ids
|
||||
}
|
||||
|
||||
override fun setSelectedTypes(types: List<String>) {
|
||||
val typesString = types.joinToString(separatorComma)
|
||||
preferences.edit { putString(keySelectedTypes, typesString) }
|
||||
_typesSelection.value = types
|
||||
override fun setSelectedSatModes(modes: List<String>) {
|
||||
val modesString = modes.joinToString(separatorComma)
|
||||
preferences.edit { putString(keySelectedSatModes, modesString) }
|
||||
_satelliteModeSelection.value = modes
|
||||
}
|
||||
|
||||
private fun getSelectedIds(): List<Int> {
|
||||
@@ -126,10 +134,10 @@ class SettingsRepo(
|
||||
return selectionString.split(separatorComma).map { it.toInt() }
|
||||
}
|
||||
|
||||
private fun getSelectedTypes(): List<String> {
|
||||
val typesString = preferences.getString(keySelectedTypes, "Amateur")
|
||||
if (typesString.isNullOrEmpty()) return emptyList()
|
||||
return typesString.split(separatorComma)
|
||||
private fun getSelectedSatModes(): List<String> {
|
||||
val modesString = preferences.getString(keySelectedSatModes, null)
|
||||
if (modesString.isNullOrEmpty()) return emptyList()
|
||||
return modesString.split(separatorComma).sorted()
|
||||
}
|
||||
//endregion
|
||||
|
||||
@@ -144,7 +152,6 @@ class SettingsRepo(
|
||||
putInt(keyFilterAosStartMinute, settings.aosStartMinute)
|
||||
putInt(keyFilterAosEndMinute, settings.aosEndMinute)
|
||||
putBoolean(keyFilterAosInvert, settings.invertAosTimeWindow)
|
||||
putString(keySelectedModes, settings.selectedModes.joinToString(separatorComma))
|
||||
_passesSettings.value = settings
|
||||
}
|
||||
|
||||
@@ -155,16 +162,13 @@ class SettingsRepo(
|
||||
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,
|
||||
selectedModes
|
||||
invertAosTimeWindow
|
||||
)
|
||||
}
|
||||
//endregion
|
||||
@@ -181,15 +185,18 @@ class SettingsRepo(
|
||||
}
|
||||
|
||||
override fun setStationPosition(latitude: Double, longitude: Double, altitude: Double): Boolean {
|
||||
val newLongitude = if (longitude > 180.0) longitude - 180 else longitude
|
||||
// Wrap an out-of-range longitude into -180..180. Subtracting 180 (the
|
||||
// previous behaviour) mapped 270 to +90 instead of -90, i.e. the wrong
|
||||
// hemisphere, and 360 to +180 instead of 0.
|
||||
val newLongitude = ((longitude + 180.0).mod(360.0)) - 180.0
|
||||
val locator = positionToQth(latitude, newLongitude) ?: return false
|
||||
setStationPosition(latitude, newLongitude, altitude, locator)
|
||||
return true
|
||||
}
|
||||
|
||||
/** GPS 定位: 一次性 getCurrentLocation(GPS 优先, 15 秒超时), 拿到位置才返回 true */
|
||||
/** GPS fix: one-shot getCurrentLocation (GPS first, 15 s timeout); returns true only with a fix */
|
||||
override suspend fun setStationPosition(): Boolean {
|
||||
// 权限前置: 无定位权限直接失败(不吞异常)
|
||||
// Permission gate: fail fast without location permission (no swallowed exceptions)
|
||||
if (androidx.core.content.ContextCompat.checkSelfPermission(
|
||||
context, android.Manifest.permission.ACCESS_FINE_LOCATION
|
||||
) != android.content.pm.PackageManager.PERMISSION_GRANTED
|
||||
@@ -202,7 +209,7 @@ class SettingsRepo(
|
||||
val signal = android.os.CancellationSignal()
|
||||
val handler = android.os.Handler(android.os.Looper.getMainLooper())
|
||||
val executor = java.util.concurrent.Executor { handler.post(it) }
|
||||
// 15 秒超时
|
||||
// 15 s timeout
|
||||
val timeout = handler.postDelayed({
|
||||
signal.cancel()
|
||||
if (cont.isActive) cont.resume(false) { }
|
||||
@@ -262,6 +269,7 @@ 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 ->
|
||||
@@ -325,6 +333,10 @@ class SettingsRepo(
|
||||
override val rcSettings: StateFlow<RCSettings> = _rcSettings
|
||||
|
||||
override fun updateRCSettings(settings: RCSettings) {
|
||||
val clampedFreqOffsetHz = settings.frequencyOffsetHz.coerceIn(
|
||||
Constants.FREQ_OFFSET_MIN_HZ,
|
||||
Constants.FREQ_OFFSET_MAX_HZ
|
||||
)
|
||||
preferences.edit {
|
||||
putBoolean(keyRotatorState, settings.rotatorState)
|
||||
putString(keyRotatorAddress, settings.rotatorAddress)
|
||||
@@ -334,6 +346,7 @@ class SettingsRepo(
|
||||
putString(keyFrequencyAddress, settings.frequencyAddress)
|
||||
putString(keyFrequencyPort, settings.frequencyPort)
|
||||
putString(keyFrequencyFormat, settings.frequencyFormat)
|
||||
putLong(keyFrequencyOffsetHz, clampedFreqOffsetHz)
|
||||
putBoolean(keyBluetoothRotatorState, settings.bluetoothRotatorState)
|
||||
putString(keyBluetoothRotatorFormat, settings.bluetoothRotatorFormat)
|
||||
putString(keyBluetoothRotatorName, settings.bluetoothRotatorName)
|
||||
@@ -342,7 +355,7 @@ class SettingsRepo(
|
||||
putString(keyBluetoothFrequencyFormat, settings.bluetoothFrequencyFormat)
|
||||
putString(keyBluetoothFrequencyAddress, settings.bluetoothFrequencyAddress)
|
||||
}
|
||||
_rcSettings.value = settings
|
||||
_rcSettings.value = settings.copy(frequencyOffsetHz = clampedFreqOffsetHz)
|
||||
}
|
||||
|
||||
private fun getRCSettings(): RCSettings = RCSettings(
|
||||
@@ -354,6 +367,8 @@ class SettingsRepo(
|
||||
frequencyAddress = preferences.getString(keyFrequencyAddress, null) ?: "127.0.0.1",
|
||||
frequencyPort = preferences.getString(keyFrequencyPort, null) ?: "4532",
|
||||
frequencyFormat = preferences.getString(keyFrequencyFormat, null) ?: $$"F $FREQ",
|
||||
frequencyOffsetHz = preferences.getLong(keyFrequencyOffsetHz, 0L)
|
||||
.coerceIn(Constants.FREQ_OFFSET_MIN_HZ, Constants.FREQ_OFFSET_MAX_HZ),
|
||||
bluetoothRotatorState = preferences.getBoolean(keyBluetoothRotatorState, false),
|
||||
bluetoothRotatorFormat = preferences.getString(keyBluetoothRotatorFormat, null) ?: $$"P $AZ $EL",
|
||||
bluetoothRotatorName = preferences.getString(keyBluetoothRotatorName, null) ?: "Default",
|
||||
@@ -390,6 +405,11 @@ class SettingsRepo(
|
||||
putString(keyWavelogApiKey, new.wavelogApiKey)
|
||||
putString(keyWavelogStationId, new.wavelogStationId)
|
||||
putBoolean(keyWavelogAutoUpload, new.wavelogAutoUpload)
|
||||
putFloat(keyRadarCompassOffset, new.radarCompassOffset)
|
||||
putFloat(keyRadarCompassOffsetElev, new.radarCompassOffsetElev)
|
||||
putBoolean(keyCwToneShiftEnabled, new.cwToneShiftEnabled)
|
||||
putBoolean(keyAmsatDayStripes, new.amsatDayStripes)
|
||||
|
||||
}
|
||||
new
|
||||
}
|
||||
@@ -413,7 +433,11 @@ class SettingsRepo(
|
||||
wavelogUrl = preferences.getString(keyWavelogUrl, null) ?: "",
|
||||
wavelogApiKey = preferences.getString(keyWavelogApiKey, null) ?: "",
|
||||
wavelogStationId = preferences.getString(keyWavelogStationId, null) ?: "",
|
||||
wavelogAutoUpload = preferences.getBoolean(keyWavelogAutoUpload, false)
|
||||
wavelogAutoUpload = preferences.getBoolean(keyWavelogAutoUpload, false),
|
||||
radarCompassOffset = preferences.getFloat(keyRadarCompassOffset, 0f),
|
||||
radarCompassOffsetElev = preferences.getFloat(keyRadarCompassOffsetElev, 0f),
|
||||
cwToneShiftEnabled = preferences.getBoolean(keyCwToneShiftEnabled, false),
|
||||
amsatDayStripes = preferences.getBoolean(keyAmsatDayStripes, true)
|
||||
)
|
||||
//endregion
|
||||
|
||||
@@ -431,20 +455,47 @@ class SettingsRepo(
|
||||
_dataSourcesSettings.value = settings
|
||||
}
|
||||
|
||||
/** Placeholders a 4.4.7-era build could persist. Neither is a reachable address. */
|
||||
private val placeholderTleUrl = "https://example.com/tle.txt"
|
||||
private val placeholderRadioUrl = "https://example.com/radio.json"
|
||||
private val keyPlaceholderUrlsMigrated = "placeholderUrlsMigrated"
|
||||
|
||||
/**
|
||||
* Replace the example.com placeholders an old build could store.
|
||||
*
|
||||
* Runs once, following the pattern of migrateRCFormats. This used to be a rewrite applied on
|
||||
* every read, so the stored value and the returned value disagreed indefinitely and nothing
|
||||
* ever settled the difference.
|
||||
*/
|
||||
private fun migratePlaceholderUrls() {
|
||||
if (preferences.getBoolean(keyPlaceholderUrlsMigrated, false)) return
|
||||
preferences.edit {
|
||||
if (preferences.getString(keyTleUrl, null) == placeholderTleUrl) {
|
||||
putString(keyTleUrl, Sources.defaultTleUrl)
|
||||
putBoolean(keyUseCustomTle, false)
|
||||
}
|
||||
if (preferences.getString(keyTransceiversUrl, null) == placeholderRadioUrl) {
|
||||
putString(keyTransceiversUrl, Sources.defaultTransceiversUrl)
|
||||
putBoolean(keyUseCustomTransceivers, false)
|
||||
}
|
||||
putBoolean(keyPlaceholderUrlsMigrated, true)
|
||||
}
|
||||
}
|
||||
|
||||
private fun getDataSourcesSettings(): DataSourcesSettings {
|
||||
// 4.4.8 修复: 旧版 example.com 占位 URL 视为未配置 -> 替换为真实默认 URL 且开关强制关闭,
|
||||
// 否则在线更新的 All/SatNOGS 源会指向错误地址导致更新失败
|
||||
val storedTleUrl = preferences.getString(keyTleUrl, Sources.defaultTleUrl) ?: Sources.defaultTleUrl
|
||||
val storedTxUrl = preferences.getString(keyTransceiversUrl, Sources.defaultTransceiversUrl) ?: Sources.defaultTransceiversUrl
|
||||
val tleUrl = if (storedTleUrl == "https://example.com/tle.txt") Sources.defaultTleUrl else storedTleUrl
|
||||
val txUrl = if (storedTxUrl == "https://example.com/radio.json") Sources.defaultTransceiversUrl else storedTxUrl
|
||||
migratePlaceholderUrls()
|
||||
// The switch is reported as the operator set it. It used to be ANDed with
|
||||
// `url != default`, so typing the default URL by hand switched custom sources off by
|
||||
// itself and the settings screen showed a state nobody had chosen.
|
||||
return DataSourcesSettings(
|
||||
useCustomTLE = preferences.getBoolean(keyUseCustomTle, false) && tleUrl != Sources.defaultTleUrl,
|
||||
useCustomTransceivers = preferences.getBoolean(keyUseCustomTransceivers, false) && txUrl != Sources.defaultTransceiversUrl,
|
||||
tleUrl = tleUrl,
|
||||
transceiversUrl = txUrl
|
||||
useCustomTLE = preferences.getBoolean(keyUseCustomTle, false),
|
||||
useCustomTransceivers = preferences.getBoolean(keyUseCustomTransceivers, false),
|
||||
tleUrl = preferences.getString(keyTleUrl, Sources.defaultTleUrl) ?: Sources.defaultTleUrl,
|
||||
transceiversUrl = preferences.getString(keyTransceiversUrl, Sources.defaultTransceiversUrl)
|
||||
?: Sources.defaultTransceiversUrl
|
||||
)
|
||||
}
|
||||
|
||||
//endregion
|
||||
|
||||
//region # Radio control settings
|
||||
@@ -484,5 +535,27 @@ class SettingsRepo(
|
||||
baudRate = preferences.getInt(keyRadioBaudRate, 4800),
|
||||
splitMode = preferences.getBoolean(keyRadioSplitMode, false)
|
||||
)
|
||||
//endregion
|
||||
|
||||
private val keySatelliteOffsets = "satelliteOffsets"
|
||||
|
||||
override fun getSatelliteOffset(catnum: Int): String {
|
||||
val json = preferences.getString(keySatelliteOffsets, "{}") ?: "{}"
|
||||
return try {
|
||||
JSONObject(json).optString(catnum.toString(), "")
|
||||
} catch (_: Exception) {
|
||||
""
|
||||
}
|
||||
}
|
||||
|
||||
override fun setSatelliteOffset(catnum: Int, offset: String) {
|
||||
val json = preferences.getString(keySatelliteOffsets, "{}") ?: "{}"
|
||||
val updated = try {
|
||||
val obj = JSONObject(json)
|
||||
if (offset.isEmpty()) obj.remove(catnum.toString()) else obj.put(catnum.toString(), offset)
|
||||
obj.toString()
|
||||
} catch (_: Exception) {
|
||||
"""{"$catnum": "$offset"}"""
|
||||
}
|
||||
preferences.edit { putString(keySatelliteOffsets, updated) }
|
||||
}
|
||||
}
|
||||
@@ -1,136 +0,0 @@
|
||||
package com.rtbishop.look4sat.core.data.source
|
||||
|
||||
import com.rtbishop.look4sat.core.domain.model.SatDay
|
||||
import com.rtbishop.look4sat.core.domain.model.SatReport
|
||||
import com.rtbishop.look4sat.core.domain.model.SatSlot
|
||||
import com.rtbishop.look4sat.core.domain.model.SatStatus
|
||||
import com.rtbishop.look4sat.core.domain.model.SatStatusPage
|
||||
import java.util.regex.Pattern
|
||||
|
||||
/**
|
||||
* AMSAT 卫星状态页解析器(https://amsat.org/status/)
|
||||
*
|
||||
* 页面结构(静态 HTML, 2026-08 实测):
|
||||
* - 状态表: <table> 内 48 行, 表头 = Name + 6 天(每个 colspan=12)
|
||||
* 数据行 73 格: [0]=卫星名, [1..72] = 6 天 × 12 个 2 小时槽(新→旧)
|
||||
* 每格 <td width=9 bgcolor="颜色">数字</td>, 有报告时数字带
|
||||
* docTips.show('id') 链接
|
||||
* - 报告详情在页面内嵌 JS:
|
||||
* tips.a885153 = new Array(5,5,120,'状态<br>呼号<br>网格<br>日期<br>时间段 UTC')
|
||||
*
|
||||
* 状态色: #648fff=活跃, #ffb000=仅遥测, #dc267f=未听到, #fe6100=矛盾
|
||||
*/
|
||||
object AmSatParser {
|
||||
|
||||
private val STATUS_COLORS = mapOf(
|
||||
"#648fff" to 0xFF648FFF.toLong(),
|
||||
"#ffb000" to 0xFFFFB000.toLong(),
|
||||
"#dc267f" to 0xFFDC267F.toLong(),
|
||||
"#fe6100" to 0xFFFE6100.toLong()
|
||||
)
|
||||
private val GRAY = 0xFFC0C0C0.toLong()
|
||||
|
||||
private val rowRe = Pattern.compile("<tr>(.*?)</tr>", Pattern.DOTALL)
|
||||
private val cellRe = Pattern.compile("(<t[dh][^>]*>.*?</t[dh]>)", Pattern.DOTALL)
|
||||
private val bgRe = Pattern.compile("bgcolor=\"?(#[0-9a-fA-F]{6}|C0C0C0)\"?")
|
||||
private val linkRe = Pattern.compile("docTips\\.show\\('(a\\d+)'\\)")
|
||||
private val tipRe = Pattern.compile(
|
||||
"tips\\.(a\\d+)\\s*=\\s*new\\s+Array\\(\\s*\\d+,\\s*\\d+,\\s*\\d+,\\s*'([^']*)'"
|
||||
)
|
||||
|
||||
/** 解析完整页面 */
|
||||
fun parse(html: String, fetchedAtUtcMs: Long): SatStatusPage {
|
||||
val reports = parseReports(html)
|
||||
val statuses = parseStatusTable(html, reports)
|
||||
return SatStatusPage(fetchedAtUtcMs, statuses, reports)
|
||||
}
|
||||
|
||||
/** 提取全部报告(tips.* JS 数组) */
|
||||
fun parseReports(html: String): Map<String, SatReport> {
|
||||
val map = mutableMapOf<String, SatReport>()
|
||||
val m = tipRe.matcher(html)
|
||||
while (m.find()) {
|
||||
val id = m.group(1)
|
||||
val parts = m.group(2).split("<br>")
|
||||
map[id] = SatReport(
|
||||
id = id,
|
||||
statusText = parts.getOrElse(0) { "" }.trim(),
|
||||
call = parts.getOrElse(1) { "" }.trim(),
|
||||
grid = parts.getOrElse(2) { "" }.trim(),
|
||||
dateUtc = parts.getOrElse(3) { "" }.trim(),
|
||||
timeUtc = parts.getOrElse(4) { "" }.trim()
|
||||
)
|
||||
}
|
||||
return map
|
||||
}
|
||||
|
||||
/** 解析状态表(48 行卫星) */
|
||||
fun parseStatusTable(html: String, reports: Map<String, SatReport>): List<SatStatus> {
|
||||
val result = mutableListOf<SatStatus>()
|
||||
val tables = extractTables(html)
|
||||
for (table in tables) {
|
||||
val rows = rowRe.matcher(table)
|
||||
val parsed = mutableListOf<SatStatus>()
|
||||
var rowIndex = 0
|
||||
var dayHeaders: List<String> = emptyList()
|
||||
while (rows.find()) {
|
||||
val rowHtml = rows.group(1)
|
||||
val cells = cellRe.matcher(rowHtml)
|
||||
val cellList = mutableListOf<String>()
|
||||
while (cells.find()) cellList.add(cells.group(1))
|
||||
if (rowIndex == 0) {
|
||||
// 表头: Name + 6 天(每个 colspan=12)
|
||||
dayHeaders = cellList.drop(1).take(6).map { stripHtml(it) }
|
||||
rowIndex++
|
||||
continue
|
||||
}
|
||||
if (cellList.size < 7) { rowIndex++; continue }
|
||||
val name = stripHtml(cellList[0])
|
||||
if (name.isBlank()) { rowIndex++; continue }
|
||||
val days = mutableListOf<SatDay>()
|
||||
for (d in 0 until 6) {
|
||||
val start = 1 + d * 12
|
||||
val end = start + 12
|
||||
val slots = (start until end).mapNotNull { i ->
|
||||
cellList.getOrNull(i)?.let { cell ->
|
||||
val bg = bgRe.matcher(cell)
|
||||
val color = if (bg.find()) {
|
||||
STATUS_COLORS[bg.group(1).lowercase()] ?: GRAY
|
||||
} else GRAY
|
||||
val link = linkRe.matcher(cell)
|
||||
val ids = mutableListOf<String>()
|
||||
while (link.find()) ids.add(link.group(1))
|
||||
val hasReport = ids.isNotEmpty()
|
||||
val count = if (hasReport) {
|
||||
stripHtml(cell).trim().toIntOrNull() ?: ids.size
|
||||
} else 0
|
||||
SatSlot(
|
||||
statusColor = if (hasReport) color else GRAY,
|
||||
count = count,
|
||||
reportIds = ids
|
||||
)
|
||||
}
|
||||
}
|
||||
days.add(SatDay(dayHeaders.getOrElse(d) { "" }, slots))
|
||||
}
|
||||
parsed.add(SatStatus(name, days))
|
||||
rowIndex++
|
||||
}
|
||||
if (parsed.isNotEmpty()) {
|
||||
result.addAll(parsed)
|
||||
break
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private fun extractTables(html: String): List<String> {
|
||||
val result = mutableListOf<String>()
|
||||
val m = Pattern.compile("<table.*?</table>", Pattern.DOTALL).matcher(html)
|
||||
while (m.find()) result.add(m.group())
|
||||
return result
|
||||
}
|
||||
|
||||
private fun stripHtml(s: String): String =
|
||||
s.replace(Regex("<[^>]+>"), "").trim()
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
/*
|
||||
* 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.source
|
||||
|
||||
import com.rtbishop.look4sat.core.domain.source.HttpResult
|
||||
import com.rtbishop.look4sat.core.domain.source.IHttpClient
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
* Android [IHttpClient] for the shared Wavelog/QRZ code, which cannot reach java.net on iOS.
|
||||
*
|
||||
* Connect and read timeouts are the 15 s the previous HttpURLConnection client used, applied to
|
||||
* the passed client so the caller keeps one connection pool. The response body is returned for
|
||||
* error codes as well, which is what reading errorStream did.
|
||||
*/
|
||||
class OkHttpHttpClient(
|
||||
baseClient: OkHttpClient,
|
||||
dispatcher: CoroutineDispatcher = Dispatchers.IO
|
||||
) : IHttpClient {
|
||||
|
||||
private val dispatcher = dispatcher
|
||||
|
||||
private val client = baseClient.newBuilder()
|
||||
.connectTimeout(TIMEOUT_MS, TimeUnit.MILLISECONDS)
|
||||
.readTimeout(TIMEOUT_MS, TimeUnit.MILLISECONDS)
|
||||
.build()
|
||||
|
||||
override suspend fun post(url: String, headers: Map<String, String>, body: String): HttpResult =
|
||||
execute {
|
||||
Request.Builder().url(url).post(body.toRequestBody(JSON_MEDIA_TYPE)).withHeaders(headers).build()
|
||||
}
|
||||
|
||||
override suspend fun get(url: String, headers: Map<String, String>): HttpResult =
|
||||
execute { Request.Builder().url(url).withHeaders(headers).build() }
|
||||
|
||||
private suspend fun execute(buildRequest: () -> Request): HttpResult = withContext(dispatcher) {
|
||||
try {
|
||||
// Built in here, not by the caller: a URL OkHttp refuses to parse has to come back as
|
||||
// the HTTP -1 the old client reported, not as an exception thrown at the caller.
|
||||
val request = buildRequest()
|
||||
client.newCall(request).execute().use { response ->
|
||||
HttpResult(response.code, response.body.string())
|
||||
}
|
||||
} catch (exception: CancellationException) {
|
||||
throw exception
|
||||
} catch (exception: Exception) {
|
||||
HttpResult(NO_RESPONSE, "", exception.message ?: exception.javaClass.simpleName)
|
||||
}
|
||||
}
|
||||
|
||||
private fun Request.Builder.withHeaders(headers: Map<String, String>): Request.Builder {
|
||||
headers.forEach { (name, value) -> header(name, value) }
|
||||
return this
|
||||
}
|
||||
|
||||
private companion object {
|
||||
/** Matches HttpURLConnection's connect/read timeout in the original WaveLog client. */
|
||||
const val TIMEOUT_MS = 15_000L
|
||||
|
||||
/** What HttpURLConnection's responseCode() reported when a request never got a response. */
|
||||
const val NO_RESPONSE = -1
|
||||
|
||||
/** WaveLog's v2 and v1 endpoints take application/json in both directions. */
|
||||
val JSON_MEDIA_TYPE = "application/json".toMediaType()
|
||||
}
|
||||
}
|
||||
@@ -20,12 +20,11 @@ package com.rtbishop.look4sat.core.data.source
|
||||
import android.content.ContentResolver
|
||||
import androidx.core.net.toUri
|
||||
import com.rtbishop.look4sat.core.domain.source.IRemoteSource
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import java.io.ByteArrayInputStream
|
||||
import java.io.InputStream
|
||||
|
||||
class RemoteSource(
|
||||
private val dispatcher: CoroutineDispatcher,
|
||||
@@ -33,42 +32,87 @@ class RemoteSource(
|
||||
private val httpClient: OkHttpClient
|
||||
) : IRemoteSource {
|
||||
|
||||
override suspend fun getFileStream(uri: String): InputStream? = withContext(dispatcher) {
|
||||
override suspend fun getFileBytes(uri: String): ByteArray? = withContext(dispatcher) {
|
||||
try {
|
||||
val fileUri = uri.toUri()
|
||||
contentResolver.openInputStream(fileUri)?.buffered()
|
||||
contentResolver.openInputStream(fileUri)?.use { it.readBytes() }
|
||||
} catch (exception: CancellationException) {
|
||||
throw exception
|
||||
} catch (exception: Exception) {
|
||||
println("RemoteSource file stream exception: $exception")
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun getStatusHtml(): String? = withContext(dispatcher) {
|
||||
try {
|
||||
val request = Request.Builder()
|
||||
.url("https://amsat.org/status/")
|
||||
.header("User-Agent", "Mozilla/5.0 (Linux; Android 13) Look4Sat/4.5")
|
||||
.build()
|
||||
httpClient.newCall(request).execute().use { response ->
|
||||
if (!response.isSuccessful) return@use null
|
||||
response.body?.string()
|
||||
}
|
||||
} catch (exception: Exception) {
|
||||
println("RemoteSource amsat status exception: $exception")
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun getNetworkStream(url: String): InputStream? = withContext(dispatcher) {
|
||||
override suspend fun getNetworkBytes(url: String): ByteArray? = withContext(dispatcher) {
|
||||
try {
|
||||
val networkRequest = Request.Builder().url(url).build()
|
||||
// The whole body is read here, which also returns the connection to OkHttp's pool
|
||||
httpClient.newCall(networkRequest).execute().use { response ->
|
||||
if (!response.isSuccessful) return@withContext null
|
||||
ByteArrayInputStream(response.body.bytes())
|
||||
if (!response.isSuccessful) return@use null
|
||||
response.body.bytes()
|
||||
}
|
||||
} catch (exception: CancellationException) {
|
||||
throw exception
|
||||
} catch (exception: Exception) {
|
||||
println("RemoteSource network stream exception: $exception")
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun getAmSatCatalog(): String? = withContext(dispatcher) {
|
||||
try {
|
||||
val request = Request.Builder()
|
||||
.url("https://www.amsat.org/status/api/v1/catalog.php")
|
||||
.header("User-Agent", "Look4Sat/4.5.7")
|
||||
.build()
|
||||
httpClient.newCall(request).execute().use { response ->
|
||||
if (!response.isSuccessful) return@use null
|
||||
response.body?.string()
|
||||
}
|
||||
} catch (exception: CancellationException) {
|
||||
throw exception
|
||||
} catch (exception: Exception) {
|
||||
println("RemoteSource amsat catalog exception: $exception")
|
||||
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun getAmSatReports(hours: Int, limit: Int): String? = withContext(dispatcher) {
|
||||
try {
|
||||
val request = Request.Builder()
|
||||
.url("https://www.amsat.org/status/api/v1/reports.php?hours=$hours&limit=$limit")
|
||||
.header("User-Agent", "Look4Sat/4.5.7")
|
||||
.build()
|
||||
httpClient.newCall(request).execute().use { response ->
|
||||
if (!response.isSuccessful) return@use null
|
||||
response.body?.string()
|
||||
}
|
||||
} catch (exception: CancellationException) {
|
||||
throw exception
|
||||
} catch (exception: Exception) {
|
||||
println("RemoteSource amsat reports exception: $exception")
|
||||
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun getAmSatSummary(hours: Int): String? = withContext(dispatcher) {
|
||||
try {
|
||||
val request = Request.Builder()
|
||||
.url("https://www.amsat.org/status/api/v1/summary.php?hours=$hours")
|
||||
.header("User-Agent", "Look4Sat/4.5.7")
|
||||
.build()
|
||||
httpClient.newCall(request).execute().use { response ->
|
||||
if (!response.isSuccessful) return@use null
|
||||
response.body?.string()
|
||||
}
|
||||
} catch (exception: CancellationException) {
|
||||
throw exception
|
||||
} catch (exception: Exception) {
|
||||
println("RemoteSource amsat summary exception: $exception")
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -56,8 +56,14 @@ class AudioCapture : IAudioCapture {
|
||||
if (read > 0) emit(if (read == chunkSize) buffer.copyOf() else buffer.copyOfRange(0, read))
|
||||
}
|
||||
} finally {
|
||||
recorder.stop()
|
||||
recorder.release()
|
||||
// stop() on a recorder that never started throws
|
||||
// IllegalStateException; wrapping each cleanup step separately
|
||||
// keeps the original error (e.g. a permission denial during
|
||||
// startRecording) intact and guarantees release() still runs.
|
||||
// Without this, a start failure masked the real cause AND leaked
|
||||
// the recorder because release() was skipped.
|
||||
runCatching { recorder.stop() }
|
||||
runCatching { recorder.release() }
|
||||
}
|
||||
}.flowOn(Dispatchers.IO)
|
||||
}
|
||||
@@ -29,4 +29,8 @@ class ShowToast(private val context: Context) : IShowToast {
|
||||
override fun invoke(resId: Int) {
|
||||
invoke(context.getString(resId))
|
||||
}
|
||||
|
||||
override fun invoke(resId: Int, vararg formatArgs: Any) {
|
||||
invoke(context.getString(resId, *formatArgs))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
/* LotwSatellitesRepo.kt - runtime refresh of the LoTW satellite list (4.5.5).
|
||||
* Downloads ARRL's official config.tq6 (gzip XML), parses <satellite name="...">,
|
||||
* updates the LotwSatellites dynamic set + persists to SharedPreferences (survives restarts).
|
||||
* Trigger: the "Update sats" button in the settings WaveLog section (user decided: manual).
|
||||
*/
|
||||
package com.rtbishop.look4sat.core.data.wavelog
|
||||
|
||||
import android.content.Context
|
||||
import com.rtbishop.look4sat.core.domain.wavelog.ILotwSatellitesRepo
|
||||
import com.rtbishop.look4sat.core.domain.wavelog.LotwSatellites
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.BufferedReader
|
||||
import java.io.InputStreamReader
|
||||
import java.net.URL
|
||||
import java.util.zip.GZIPInputStream
|
||||
import kotlin.random.Random
|
||||
|
||||
class LotwSatellitesRepo(private val context: Context) : ILotwSatellitesRepo {
|
||||
|
||||
private val prefs = context.getSharedPreferences("lotw_satellites", Context.MODE_PRIVATE)
|
||||
|
||||
/** Restore the last downloaded list from SharedPreferences (call at process start) */
|
||||
override fun restore() {
|
||||
val saved = prefs.getString(KEY_NAMES, null)
|
||||
if (!saved.isNullOrBlank()) {
|
||||
LotwSatellites.updateNames(saved.split(",").filter { it.isNotBlank() }.toSet())
|
||||
}
|
||||
}
|
||||
|
||||
/** Download config.tq6 -> parse -> update memory + persist. Result is surfaced in the UI */
|
||||
override suspend fun refresh(): ILotwSatellitesRepo.RefreshResult = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val url = URL("https://lotw.arrl.org/lotw/config.tq6")
|
||||
val conn = url.openConnection()
|
||||
conn.connectTimeout = 10_000
|
||||
conn.readTimeout = 20_000
|
||||
conn.setRequestProperty("User-Agent", "Look4Sat-Pro/4.5.5")
|
||||
val raw = conn.getInputStream()
|
||||
val reader = BufferedReader(InputStreamReader(GZIPInputStream(raw), Charsets.UTF_8))
|
||||
val text = reader.use { it.readText() }
|
||||
// Parse <satellite name="XXX" ...> - fixed format (official ARRL XML)
|
||||
val names = Regex("""<satellite name="([^"]+)""").findAll(text)
|
||||
.map { it.groupValues[1].uppercase() }
|
||||
.toSet()
|
||||
if (names.isEmpty()) return@withContext ILotwSatellitesRepo.RefreshResult.Error("列表为空(响应异常)")
|
||||
LotwSatellites.updateNames(names)
|
||||
prefs.edit()
|
||||
.putString(KEY_NAMES, names.joinToString(","))
|
||||
.putLong(KEY_UPDATED, System.currentTimeMillis())
|
||||
.apply()
|
||||
ILotwSatellitesRepo.RefreshResult.Ok(names.size)
|
||||
} catch (e: Exception) {
|
||||
ILotwSatellitesRepo.RefreshResult.Error(e.message ?: "未知错误")
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val KEY_NAMES = "names"
|
||||
private const val KEY_UPDATED = "updated"
|
||||
}
|
||||
}
|
||||
+307
@@ -0,0 +1,307 @@
|
||||
package com.rtbishop.look4sat.core.data.aprs
|
||||
|
||||
import java.io.BufferedReader
|
||||
import java.io.InputStreamReader
|
||||
import java.io.PrintWriter
|
||||
import java.net.ServerSocket
|
||||
import java.net.Socket
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.TimeUnit
|
||||
import kotlin.concurrent.thread
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Socket-level tests against a stand-in APRS-IS server.
|
||||
*
|
||||
* These exist because the pure-logic tests could not catch the failures that matter here. A draft
|
||||
* of [AprsIsClient.sendPacket] once wrote the packet with no line terminator at all: every send
|
||||
* reported success, the server received a single unterminated stream, and nothing anywhere went
|
||||
* red. APRS-IS is a line protocol and does not acknowledge position reports, so silence is the
|
||||
* normal case - which means only a test that reads the bytes off a real socket can tell a
|
||||
* delivered packet from a lost one.
|
||||
*/
|
||||
class AprsIsClientSocketTest {
|
||||
|
||||
/**
|
||||
* A minimal APRS-IS server. Greets, answers the login as instructed, then records whatever
|
||||
* lines arrive without acknowledging them, which is what the real network does.
|
||||
*/
|
||||
private class FakeServer(
|
||||
private val greeting: String? = "# aprsc 2.1.19-g730c5c0",
|
||||
private val loginResponse: String? = "# logresp TEST verified, server FAKE",
|
||||
private val chatter: List<String> = emptyList(),
|
||||
/** Sent right after the first packet arrives, to exercise the ack read. */
|
||||
private val afterPacket: String? = null
|
||||
) : AutoCloseable {
|
||||
private val server = ServerSocket(0)
|
||||
private val ready = CountDownLatch(1)
|
||||
|
||||
/**
|
||||
* The accepted connection. Held because closing the ServerSocket only stops it listening
|
||||
* - an established connection survives, so a test that wants a dead peer has to close
|
||||
* this one. Getting that wrong made a correct implementation look broken.
|
||||
*/
|
||||
@Volatile
|
||||
private var peer: Socket? = null
|
||||
|
||||
val port: Int get() = server.localPort
|
||||
|
||||
/** Every complete line the client sent after logging in. */
|
||||
val received = mutableListOf<String>()
|
||||
|
||||
/** Raw bytes of the client's traffic, so a missing terminator is visible. */
|
||||
val rawAfterLogin = StringBuilder()
|
||||
|
||||
@Volatile
|
||||
var loginLine: String? = null
|
||||
|
||||
fun start() {
|
||||
thread(isDaemon = true) {
|
||||
runCatching {
|
||||
server.accept().use { client ->
|
||||
peer = client
|
||||
val out = PrintWriter(client.getOutputStream(), true)
|
||||
val input = BufferedReader(InputStreamReader(client.getInputStream()))
|
||||
greeting?.let { out.print(it + "\r\n"); out.flush() }
|
||||
loginLine = input.readLine()
|
||||
chatter.forEach { out.print(it + "\r\n"); out.flush() }
|
||||
loginResponse?.let { out.print(it + "\r\n"); out.flush() }
|
||||
ready.countDown()
|
||||
// Read lines but never acknowledge, exactly as APRS-IS treats positions.
|
||||
var firstPacket = true
|
||||
while (true) {
|
||||
val line = input.readLine() ?: break
|
||||
synchronized(received) {
|
||||
received += line
|
||||
rawAfterLogin.append(line)
|
||||
}
|
||||
if (firstPacket) {
|
||||
firstPacket = false
|
||||
afterPacket?.let { out.print(it + "\r\n"); out.flush() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
ready.countDown()
|
||||
}
|
||||
}
|
||||
|
||||
fun awaitLogin(): Boolean = ready.await(5, TimeUnit.SECONDS)
|
||||
|
||||
fun lines(): List<String> = synchronized(received) { received.toList() }
|
||||
|
||||
/** Close the established connection, so the client is talking to a dead peer. */
|
||||
fun dropClient() {
|
||||
runCatching { peer?.close() }
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
runCatching { server.close() }
|
||||
}
|
||||
}
|
||||
|
||||
private fun client(port: Int, passcode: Int = 12345) = AprsIsClient(
|
||||
host = "127.0.0.1",
|
||||
port = port,
|
||||
callsign = "TEST",
|
||||
ssid = "",
|
||||
passcode = passcode,
|
||||
softwareName = "Look4Sat",
|
||||
version = "test"
|
||||
)
|
||||
|
||||
/**
|
||||
* The regression that motivated this file. Two packets must arrive as two lines; without a
|
||||
* terminator they concatenate into one stream the server can never parse, while both sends
|
||||
* report success.
|
||||
*/
|
||||
@Test
|
||||
fun `each packet arrives as its own line`() {
|
||||
FakeServer().use { server ->
|
||||
server.start()
|
||||
val c = client(server.port)
|
||||
c.connect()
|
||||
assertTrue(server.awaitLogin())
|
||||
val first = c.sendPacket("TEST>APRS,TCPIP*:=0000.00N/00000.00E>one")
|
||||
val second = c.sendPacket("TEST>APRS,TCPIP*:=0000.00N/00000.00E>two")
|
||||
Thread.sleep(300)
|
||||
c.disconnect()
|
||||
|
||||
assertEquals(true, first?.first)
|
||||
assertEquals(true, second?.first)
|
||||
val lines = server.lines()
|
||||
assertEquals("both packets must reach the server as separate lines", 2, lines.size)
|
||||
assertTrue(lines[0].endsWith(">one"))
|
||||
assertTrue(lines[1].endsWith(">two"))
|
||||
}
|
||||
}
|
||||
|
||||
/** The login line has to be terminated too, or the server never reads it. */
|
||||
@Test
|
||||
fun `the server receives a complete login line`() {
|
||||
FakeServer().use { server ->
|
||||
server.start()
|
||||
val c = client(server.port)
|
||||
c.connect()
|
||||
assertTrue(server.awaitLogin())
|
||||
c.disconnect()
|
||||
assertEquals("user TEST pass 12345 vers Look4Sat test", server.loginLine)
|
||||
}
|
||||
}
|
||||
|
||||
/** A verified login is recognised and lets reports count as delivered. */
|
||||
@Test
|
||||
fun `a verified login is not reported as refused`() {
|
||||
FakeServer().use { server ->
|
||||
server.start()
|
||||
val c = client(server.port)
|
||||
c.connect()
|
||||
assertTrue(server.awaitLogin())
|
||||
assertTrue(c.isVerified)
|
||||
assertFalse(c.isRefusedByServer)
|
||||
c.disconnect()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The case the rewrite exists for: the server accepts the connection, the write succeeds,
|
||||
* and every packet is discarded. The client must say so rather than report success.
|
||||
*/
|
||||
@Test
|
||||
fun `an unverified login is flagged while the connection stays up`() {
|
||||
FakeServer(loginResponse = "# logresp TEST unverified, server FAKE").use { server ->
|
||||
server.start()
|
||||
val c = client(server.port, passcode = -1)
|
||||
c.connect()
|
||||
assertTrue(server.awaitLogin())
|
||||
assertFalse("unverified must not read as verified", c.isVerified)
|
||||
assertTrue("the server explicitly refused", c.isRefusedByServer)
|
||||
// Not a connection error: a receive-only login is legitimate and stays connected.
|
||||
assertTrue(c.isConnected)
|
||||
c.disconnect()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A chatty server used to exhaust a fixed line budget, turning an accepted login into
|
||||
* Unknown and telling the operator their passcode was wrong when it had been accepted.
|
||||
*/
|
||||
@Test
|
||||
fun `keepalive chatter before the verdict does not hide it`() {
|
||||
// The real keepalive repeats the server identification with a timestamp, captured from
|
||||
// euro.aprs2.net. A made-up "# keepalive N" would now read as a refusal, correctly - only
|
||||
// greetings and verdicts are treated as harmless.
|
||||
val chatter = List(8) { "# aprsc 2.1.21-gbfc2090 25 Aug 2026 16:41:0$it GMT T2UK 1.2.3.4:14580" }
|
||||
FakeServer(chatter = chatter).use { server ->
|
||||
server.start()
|
||||
val c = client(server.port)
|
||||
c.connect()
|
||||
assertTrue(server.awaitLogin())
|
||||
assertTrue("the verdict must be found past the comments", c.isVerified)
|
||||
c.disconnect()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A server that sends no greeting is legitimate, and must not cost the full login window on
|
||||
* every connect - that was eight seconds per attempt.
|
||||
*/
|
||||
@Test
|
||||
fun `a server without a greeting connects promptly`() {
|
||||
FakeServer(greeting = null).use { server ->
|
||||
server.start()
|
||||
val c = client(server.port)
|
||||
val started = System.currentTimeMillis()
|
||||
c.connect()
|
||||
assertTrue(server.awaitLogin())
|
||||
val elapsed = System.currentTimeMillis() - started
|
||||
c.disconnect()
|
||||
assertTrue("connect took ${elapsed}ms, expected well under the login window",
|
||||
elapsed < 6_000)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The failure a live server actually produced, and the one that mattered most.
|
||||
*
|
||||
* aprsc answers `# Invalid login: ...` and closes. That is a comment but not a logresp, so it
|
||||
* was skipped as chatter, the login timed out into Unknown - treated as "may be working" - and
|
||||
* every send afterwards reported success. Measured against euro.aprs2.net before the fix:
|
||||
* loginOutcome=Unknown, isRefusedByServer=false, sendPacket=(true, "sent").
|
||||
*/
|
||||
@Test
|
||||
fun `a refused login is not reported as a successful send`() {
|
||||
FakeServer(loginResponse = "# Invalid login: bad software version").use { server ->
|
||||
server.start()
|
||||
val c = client(server.port)
|
||||
// An outright refusal throws from connect(), which is the correct outcome.
|
||||
val threw = runCatching { c.connect() }.exceptionOrNull()
|
||||
assertTrue(server.awaitLogin())
|
||||
assertFalse("a refused login must not read as verified", c.isVerified)
|
||||
val sentOk = runCatching {
|
||||
c.sendPacket("TEST>APRS,TCPIP*:=0000.00N/00000.00E>x")?.first
|
||||
}.getOrNull()
|
||||
assertTrue(
|
||||
"the refusal must surface: threw=$threw sentOk=$sentOk",
|
||||
threw != null || sentOk != true
|
||||
)
|
||||
c.disconnect()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A server saying it is about to drop us must not read as a successful send.
|
||||
*
|
||||
* The ack read used to treat any leading `#` as harmless chatter, so `# Port full` - which
|
||||
* means the server is closing the connection - was reported as sent. It now shares the login
|
||||
* parser's judgement, so only a greeting or keepalive counts as harmless.
|
||||
*/
|
||||
@Test
|
||||
fun `a server refusal after the write is not reported as sent`() {
|
||||
FakeServer(afterPacket = "# Port full").use { server ->
|
||||
server.start()
|
||||
val c = client(server.port)
|
||||
c.connect()
|
||||
assertTrue(server.awaitLogin())
|
||||
val result = c.sendPacket("TEST>APRS,TCPIP*:=0000.00N/00000.00E>x")
|
||||
c.disconnect()
|
||||
assertEquals("a server refusal must fail the report", false, result?.first)
|
||||
}
|
||||
}
|
||||
|
||||
/** The real keepalive must still count as sent, since APRS-IS never acknowledges a position. */
|
||||
@Test
|
||||
fun `a keepalive after the write still counts as sent`() {
|
||||
val keepalive = "# aprsc 2.1.21-gbfc2090 25 Aug 2026 16:41:07 GMT T2UK 1.2.3.4:14580"
|
||||
FakeServer(afterPacket = keepalive).use { server ->
|
||||
server.start()
|
||||
val c = client(server.port)
|
||||
c.connect()
|
||||
assertTrue(server.awaitLogin())
|
||||
val result = c.sendPacket("TEST>APRS,TCPIP*:=0000.00N/00000.00E>x")
|
||||
c.disconnect()
|
||||
assertEquals("a keepalive must not fail the report", true, result?.first)
|
||||
}
|
||||
}
|
||||
|
||||
/** Sending after the server has gone must report failure, not success. */
|
||||
@Test
|
||||
fun `a send after the server closes is reported as failed`() {
|
||||
val server = FakeServer()
|
||||
server.start()
|
||||
val c = client(server.port)
|
||||
c.connect()
|
||||
assertTrue(server.awaitLogin())
|
||||
// Closing the ServerSocket alone would leave this connection alive.
|
||||
server.dropClient()
|
||||
Thread.sleep(200)
|
||||
// The first write may still land in the socket buffer; by the second the loss is certain.
|
||||
c.sendPacket("TEST>APRS,TCPIP*:=0000.00N/00000.00E>one")
|
||||
val second = c.sendPacket("TEST>APRS,TCPIP*:=0000.00N/00000.00E>two")
|
||||
c.disconnect()
|
||||
assertEquals("a send on a dead connection must not report success", false, second?.first)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
/*
|
||||
* 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.cw
|
||||
|
||||
import com.rtbishop.look4sat.core.domain.cw.CwDeepBuffer
|
||||
import com.rtbishop.look4sat.core.domain.cw.CwDeepSpectrogram
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import kotlin.math.floor
|
||||
|
||||
/**
|
||||
* How long audio waits before its text can reach the record pane.
|
||||
*
|
||||
* The record binds to archived text only. The live decode is rewritten from scratch every
|
||||
* cycle, so a pane that concatenated it lost characters the operator had already read - the
|
||||
* decoder appeared to delete its own output while still running. Binding to archived text
|
||||
* makes the pane monotonic, and the cost is latency, which is additive: audio must first be
|
||||
* pushed out of the live window, then accumulate into a full archive batch.
|
||||
*
|
||||
* [CwDeepDecoder] needs a Context and a loaded ONNX model, so it cannot be constructed here.
|
||||
* These tests read its real constants rather than copies, so retuning one without
|
||||
* reconsidering the user-visible delay fails here.
|
||||
*/
|
||||
class CwArchiveTimingTest {
|
||||
|
||||
/** Sending speed for the character counts, typical for satellite CW. */
|
||||
private val wpm = 18.0
|
||||
|
||||
/** PARIS standard: one word is five characters. */
|
||||
private val charsPerSecond = wpm * 5 / 60.0
|
||||
|
||||
private val window = CwDeepBuffer.DEFAULT_MAX_SECONDS
|
||||
private val batch = CwDeepDecoder.ARCHIVE_SECONDS
|
||||
|
||||
/** Seconds of audio that have reached the archive after listening for [elapsed]. */
|
||||
private fun archivedSeconds(elapsed: Double): Double {
|
||||
val evicted = elapsed - window
|
||||
if (evicted <= 0.0) return 0.0
|
||||
return floor(evicted / batch) * batch
|
||||
}
|
||||
|
||||
@Test
|
||||
fun thresholdMatchesTheDeclaredBatchLength() {
|
||||
assertEquals(
|
||||
"threshold must be the batch length in samples",
|
||||
(CwDeepSpectrogram.SAMPLE_RATE * batch).toInt(),
|
||||
CwDeepDecoder.ARCHIVE_THRESHOLD
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun archiveBatchIsShorterThanACallSign() {
|
||||
// A seven-character call sign at 18 WPM takes about 4.7 s. A batch longer than that
|
||||
// means the record can stall for longer than the single most important thing being
|
||||
// sent, which is what made the stall read as deletion.
|
||||
val callSignSeconds = 7 / charsPerSecond
|
||||
assertTrue(
|
||||
"batch $batch s must not exceed a call sign at $wpm WPM " +
|
||||
"(${"%.1f".format(callSignSeconds)} s)",
|
||||
batch <= callSignSeconds
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun firstTextReachesTheRecordWithinHalfAMinute() {
|
||||
// At the previous 15 s batch this was 35 s, so a short exchange ended with the record
|
||||
// still completely empty: every decoded character had only ever been in the live line,
|
||||
// which shows 64 characters and overwrites them.
|
||||
val firstArchive = window + batch
|
||||
assertTrue("first archived text must appear within 30 s, got $firstArchive s", firstArchive <= 30.0)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aThirtySecondSessionStillProducesARecord() {
|
||||
val archived = archivedSeconds(30.0)
|
||||
assertTrue("30 s of listening must archive something, got $archived s", archived > 0.0)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun theRecordNeverStallsForLongerThanOneBatch() {
|
||||
var longestStall = 0.0
|
||||
var lastGrowthAt = window
|
||||
var previous = 0.0
|
||||
var t = window
|
||||
while (t <= 600.0) {
|
||||
val archived = archivedSeconds(t)
|
||||
if (archived > previous) {
|
||||
longestStall = maxOf(longestStall, t - lastGrowthAt)
|
||||
lastGrowthAt = t
|
||||
previous = archived
|
||||
}
|
||||
t += 0.1
|
||||
}
|
||||
assertTrue(
|
||||
"record stalled ${"%.1f".format(longestStall)} s, one batch is $batch s",
|
||||
longestStall <= batch + 0.11
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun audioInFlightWhenCaptureStopsWouldLoseTheEndOfTheTransmission() {
|
||||
// Why flush() exists. Neither holding place drains on its own: the live window only
|
||||
// reaches the archive by being pushed out by newer audio, and the pending batch only
|
||||
// by filling up. Both hold the end of the transmission, where the call sign is.
|
||||
val worstCase = window + batch
|
||||
val lostCharacters = worstCase * charsPerSecond
|
||||
assertTrue(
|
||||
"flush() must exist: ${"%.0f".format(lostCharacters)} characters would be lost",
|
||||
lostCharacters > 20
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun archivingStaysRarerThanTheLiveDecode() {
|
||||
// Each batch is one inference. Shortening the batch trades CPU for latency, so it must
|
||||
// stay rarer than the live redecode or the archive path becomes the dominant cost.
|
||||
val liveIntervalSeconds = CwDeepBuffer.DEFAULT_REDECODE_INTERVAL_MS / 1000.0
|
||||
assertTrue(
|
||||
"batch $batch s must stay longer than the live cycle $liveIntervalSeconds s",
|
||||
batch > liveIntervalSeconds
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aBatchIsLongEnoughToDecode() {
|
||||
// compute() rejects audio shorter than one FFT frame, so a batch below that would be
|
||||
// silently dropped by archiveDecode's size guard and its text lost outright.
|
||||
assertTrue(
|
||||
"batch of ${CwDeepDecoder.ARCHIVE_THRESHOLD} samples must exceed " +
|
||||
"FFT_LENGTH ${CwDeepSpectrogram.FFT_LENGTH}",
|
||||
CwDeepDecoder.ARCHIVE_THRESHOLD > CwDeepSpectrogram.FFT_LENGTH
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
package com.rtbishop.look4sat.core.data.cw
|
||||
|
||||
import com.rtbishop.look4sat.core.domain.cw.CwDeepSpectrogram
|
||||
import com.rtbishop.look4sat.core.domain.cw.CwToneShifter
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertSame
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import kotlin.math.PI
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.sin
|
||||
|
||||
/**
|
||||
* The gating contract the decoder relies on: shift only when the user opted in AND the
|
||||
* tone is outside the model window.
|
||||
*
|
||||
* [CwDeepDecoder] needs a Context and a loaded ONNX model, so it cannot be constructed
|
||||
* here. What these tests do exercise is the real decision function the decoder calls -
|
||||
* [CwToneShifter.analyse] - rather than a copy of it, so a wrong verdict fails here.
|
||||
* The decoder's own sample accumulation and throttling are covered by the streaming
|
||||
* tests in core:domain.
|
||||
*/
|
||||
class CwToneShiftGateTest {
|
||||
|
||||
private val sampleRate = CwDeepSpectrogram.SAMPLE_RATE
|
||||
|
||||
private fun tone(hz: Double, samples: Int = 1600): FloatArray = FloatArray(samples) { i ->
|
||||
sin(2.0 * PI * hz * i / sampleRate).toFloat()
|
||||
}
|
||||
|
||||
/**
|
||||
* The enabled/disabled gate as [CwDeepDecoder.applyToneShift] applies it: when off
|
||||
* the audio is returned as-is, when on the verdict comes from the real analyser.
|
||||
*/
|
||||
private fun gate(audio: FloatArray, enabled: Boolean): FloatArray {
|
||||
if (!enabled) return audio
|
||||
val analysis = CwToneShifter.analyse(audio, sampleRate)
|
||||
if (!analysis.needsShift) return audio
|
||||
return CwToneShifter.shift(audio, analysis.shiftHz, sampleRate)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `disabled leaves every tone untouched`() {
|
||||
for (hz in listOf(150.0, 300.0, 800.0, 1200.0, 1500.0)) {
|
||||
val audio = tone(hz)
|
||||
assertSame(
|
||||
"$hz Hz must pass through unchanged while the setting is off",
|
||||
audio, gate(audio, enabled = false)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `enabled still leaves in-window tones untouched`() {
|
||||
for (hz in listOf(400.0, 600.0, 800.0, 1000.0, 1200.0)) {
|
||||
val audio = tone(hz)
|
||||
assertSame(
|
||||
"$hz Hz is inside the window; enabling the setting must not alter it",
|
||||
audio, gate(audio, enabled = true)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `enabled shifts only out-of-window tones`() {
|
||||
for (hz in listOf(200.0, 300.0, 1300.0, 1500.0)) {
|
||||
val audio = tone(hz)
|
||||
val result = gate(audio, enabled = true)
|
||||
assertFalse("$hz Hz should have been shifted", result === audio)
|
||||
assertEquals("shift must preserve length", audio.size, result.size)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `window edges count as inside`() {
|
||||
val analysisLow = CwToneShifter.analyse(tone(CwDeepSpectrogram.MIN_FREQ_HZ), sampleRate)
|
||||
val analysisHigh = CwToneShifter.analyse(tone(CwDeepSpectrogram.MAX_FREQ_HZ), sampleRate)
|
||||
assertFalse("400 Hz is the lower edge, inside", analysisLow.needsShift)
|
||||
assertFalse("1200 Hz is the upper edge, inside", analysisHigh.needsShift)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `shift target is inside the window`() {
|
||||
assertTrue(
|
||||
"the target must be a pitch the model can see",
|
||||
CwToneShifter.isInsideWindow(CwToneShifter.TARGET_HZ.toFloat())
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Regression guard for the defect that made the whole feature dead on arrival:
|
||||
* the decoder gated detection on a single chunk reaching DETECT_MIN_SAMPLES, but
|
||||
* AudioCapture delivers 4410 samples at 44.1 kHz, which is only 320 after
|
||||
* resampling to 3200 Hz. Detection could never run.
|
||||
*
|
||||
* The decoder now pools chunks, so what matters is that the pooled size is
|
||||
* reachable: a handful of real-sized chunks must add up to enough audio.
|
||||
*/
|
||||
@Test
|
||||
fun `pooled capture chunks reach the detection threshold`() {
|
||||
val captureRate = 44100
|
||||
val captureChunk = captureRate / 10 // AudioCapture's ~100 ms read
|
||||
val resampledChunk = captureChunk * CwDeepSpectrogram.SAMPLE_RATE / captureRate
|
||||
assertEquals(
|
||||
"a capture chunk resamples to 320 samples; if this changes revisit pooling",
|
||||
320, resampledChunk
|
||||
)
|
||||
|
||||
val threshold = 1280 // CwDeepDecoder.DETECT_MIN_SAMPLES
|
||||
val chunksNeeded = (threshold + resampledChunk - 1) / resampledChunk
|
||||
assertTrue(
|
||||
"a single chunk ($resampledChunk) must not be expected to reach $threshold",
|
||||
resampledChunk < threshold
|
||||
)
|
||||
assertTrue(
|
||||
"pooling must reach the threshold within a second of audio, needs $chunksNeeded chunks",
|
||||
chunksNeeded in 2..10
|
||||
)
|
||||
|
||||
// And that much audio must actually be enough for the detector to work.
|
||||
val pooled = tone(1500.0, samples = threshold)
|
||||
val detected = CwToneShifter.detectToneHz(pooled, sampleRate)
|
||||
assertEquals(
|
||||
"the pooled window must be long enough to detect a tone",
|
||||
1500.0, detected!!.toDouble(), 25.0
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,407 @@
|
||||
package com.rtbishop.look4sat.core.data.repository
|
||||
|
||||
import com.rtbishop.look4sat.core.domain.source.IRemoteSource
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import java.util.Calendar
|
||||
import java.util.GregorianCalendar
|
||||
import java.util.Locale
|
||||
import java.util.TimeZone
|
||||
|
||||
/**
|
||||
* ADVERSARIAL AUDIT SCRATCH FILE - delete when the audit report is written.
|
||||
* Probes buildStatuses for aliasing, midnight arithmetic and boundary defects.
|
||||
*/
|
||||
class AmSatAuditTest {
|
||||
|
||||
private object UnusedSource : IRemoteSource {
|
||||
override suspend fun getFileBytes(uri: String): ByteArray? = null
|
||||
override suspend fun getNetworkBytes(url: String): ByteArray? = null
|
||||
override suspend fun getAmSatCatalog(): String? = null
|
||||
override suspend fun getAmSatReports(hours: Int, limit: Int): String? = null
|
||||
override suspend fun getAmSatSummary(hours: Int): String? = null
|
||||
}
|
||||
|
||||
private val repo = AmSatRepository(UnusedSource)
|
||||
|
||||
private fun utc(y: Int, mo: Int, d: Int, h: Int, mi: Int = 0, s: Int = 0): Long {
|
||||
val c = Calendar.getInstance(TimeZone.getTimeZone("UTC"))
|
||||
c.clear(); c.set(y, mo - 1, d, h, mi, s)
|
||||
return c.timeInMillis / 1000
|
||||
}
|
||||
|
||||
private fun rep(name: String, at: Long, id: String, status: String = "heard") =
|
||||
ApiReport(id, name, "T", status, "AA00", at)
|
||||
|
||||
private fun labelsAt(now: Long) =
|
||||
repo.buildStatuses(listOf("X"), emptyList(), now).single().days.map { it.dateLabel }
|
||||
|
||||
/** Reference: the label a UTC instant's day should carry. */
|
||||
private fun expectLabel(y: Int, mo: Int, d: Int): String {
|
||||
val mn = arrayOf("Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug",
|
||||
"Sep", "Oct", "Nov", "Dec")
|
||||
return "${mn[mo - 1]} $d"
|
||||
}
|
||||
|
||||
// ---------- 1. midnight arithmetic under hostile inputs ----------
|
||||
|
||||
@Test
|
||||
fun auditMidnightExactlyAtMidnight() {
|
||||
assertEquals(
|
||||
listOf(expectLabel(2026, 8, 22), expectLabel(2026, 8, 21), expectLabel(2026, 8, 20)),
|
||||
labelsAt(utc(2026, 8, 22, 0, 0, 0))
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun auditMidnightOneSecondBeforeAndAfter() {
|
||||
assertEquals(
|
||||
"23:59:59 on Aug 21 must still be Aug 21",
|
||||
listOf("Aug 21", "Aug 20", "Aug 19"),
|
||||
labelsAt(utc(2026, 8, 21, 23, 59, 59))
|
||||
)
|
||||
assertEquals(
|
||||
"00:00:01 on Aug 22 must already be Aug 22",
|
||||
listOf("Aug 22", "Aug 21", "Aug 20"),
|
||||
labelsAt(utc(2026, 8, 22, 0, 0, 1))
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun auditLeapDay2028() {
|
||||
assertEquals(
|
||||
"Feb 29 2028 back to Feb 27",
|
||||
listOf("Feb 29", "Feb 28", "Feb 27"),
|
||||
labelsAt(utc(2028, 2, 29, 12))
|
||||
)
|
||||
assertEquals(
|
||||
"Mar 1 2028 must reach back through the leap day",
|
||||
listOf("Mar 1", "Feb 29", "Feb 28"),
|
||||
labelsAt(utc(2028, 3, 1, 0, 0, 0))
|
||||
)
|
||||
assertEquals(
|
||||
"Mar 1 2027 (no leap day) must skip straight to Feb 27",
|
||||
listOf("Mar 1", "Feb 28", "Feb 27"),
|
||||
labelsAt(utc(2027, 3, 1, 12))
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun auditYearBoundary() {
|
||||
assertEquals(
|
||||
listOf("Jan 1", "Dec 31", "Dec 30"),
|
||||
labelsAt(utc(2027, 1, 1, 0, 0, 0))
|
||||
)
|
||||
assertEquals(
|
||||
listOf("Jan 2", "Jan 1", "Dec 31"),
|
||||
labelsAt(utc(2027, 1, 2, 23, 59, 59))
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun auditMonthBoundariesEveryMonth() {
|
||||
// First of every month in a leap and a non-leap year.
|
||||
for (year in listOf(2027, 2028)) {
|
||||
for (mo in 1..12) {
|
||||
val now = utc(year, mo, 1, 0, 0, 0)
|
||||
val got = labelsAt(now)
|
||||
val ref = GregorianCalendar(TimeZone.getTimeZone("UTC"))
|
||||
ref.timeInMillis = now * 1000
|
||||
val want = (0 until 3).map {
|
||||
val c = ref.clone() as Calendar
|
||||
c.add(Calendar.DAY_OF_MONTH, -it)
|
||||
expectLabel(c.get(Calendar.YEAR), c.get(Calendar.MONTH) + 1,
|
||||
c.get(Calendar.DAY_OF_MONTH))
|
||||
}
|
||||
assertEquals("$year-$mo-01", want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The load-bearing claim: subtracting 86400 equals Calendar day arithmetic in UTC.
|
||||
* Proven exhaustively over 20 years of days rather than argued.
|
||||
*/
|
||||
@Test
|
||||
fun auditSubtracting86400EqualsCalendarDayArithmeticForTwentyYears() {
|
||||
val ref = GregorianCalendar(TimeZone.getTimeZone("UTC"))
|
||||
var now = utc(2020, 1, 1, 12)
|
||||
val end = utc(2040, 1, 1, 12)
|
||||
var checked = 0
|
||||
while (now < end) {
|
||||
val got = labelsAt(now)
|
||||
ref.timeInMillis = now * 1000
|
||||
val want = (0 until 3).map {
|
||||
val c = ref.clone() as Calendar
|
||||
c.add(Calendar.DAY_OF_MONTH, -it)
|
||||
expectLabel(c.get(Calendar.YEAR), c.get(Calendar.MONTH) + 1,
|
||||
c.get(Calendar.DAY_OF_MONTH))
|
||||
}
|
||||
assertEquals("at epoch $now", want, got)
|
||||
now += 86400
|
||||
checked++
|
||||
}
|
||||
assertTrue("must have checked >7000 days, got $checked", checked > 7000)
|
||||
}
|
||||
|
||||
/**
|
||||
* The device default zone must not reach the computation. Run the whole build under
|
||||
* hostile default zones including ones with DST and half-hour offsets, and under the
|
||||
* DST transition instants of those zones.
|
||||
*/
|
||||
@Test
|
||||
fun auditDefaultTimeZoneCannotInfluenceTheGrid() {
|
||||
val original = TimeZone.getDefault()
|
||||
try {
|
||||
val zones = listOf(
|
||||
"UTC", "America/New_York", "Europe/Berlin", "Australia/Lord_Howe",
|
||||
"Asia/Kolkata", "Pacific/Kiritimati", "Pacific/Niue", "Pacific/Chatham",
|
||||
"America/Sao_Paulo", "Asia/Kathmandu"
|
||||
)
|
||||
// Instants that are DST transitions in at least one zone above.
|
||||
val instants = listOf(
|
||||
utc(2026, 3, 8, 7), utc(2026, 11, 1, 6), utc(2026, 3, 29, 1),
|
||||
utc(2026, 10, 25, 1), utc(2026, 4, 5, 16), utc(2026, 10, 4, 16),
|
||||
utc(2026, 8, 22, 0, 0, 0), utc(2026, 8, 22, 23, 59, 59),
|
||||
utc(2027, 1, 1, 0, 0, 0), utc(2028, 2, 29, 0, 0, 0)
|
||||
)
|
||||
val baseline = HashMap<Long, List<String>>()
|
||||
TimeZone.setDefault(TimeZone.getTimeZone("UTC"))
|
||||
for (i in instants) baseline[i] = labelsAt(i)
|
||||
|
||||
for (z in zones) {
|
||||
TimeZone.setDefault(TimeZone.getTimeZone(z))
|
||||
for (i in instants) {
|
||||
assertEquals("zone $z at $i", baseline[i], labelsAt(i))
|
||||
// and the placement of a report must not move either
|
||||
val s = repo.buildStatuses(
|
||||
listOf("X"), listOf(rep("X", i - 3600, "r")), i
|
||||
).single()
|
||||
val cell = s.days.withIndex().flatMap { (d, day) ->
|
||||
day.slots.withIndex().filter { "r" in it.value.reportIds }
|
||||
.map { d to it.index }
|
||||
}
|
||||
assertEquals("zone $z placement at $i", 1, cell.size)
|
||||
baseline["p$i".hashCode().toLong()]?.let { }
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
TimeZone.setDefault(original)
|
||||
}
|
||||
}
|
||||
|
||||
/** Locale can swap the calendar system out from under Calendar.getInstance. */
|
||||
@Test
|
||||
fun auditDefaultLocaleCannotInfluenceTheGrid() {
|
||||
val original = Locale.getDefault()
|
||||
try {
|
||||
val want = run {
|
||||
Locale.setDefault(Locale.US)
|
||||
labelsAt(utc(2026, 8, 22, 12))
|
||||
}
|
||||
for (l in listOf(
|
||||
Locale("th", "TH", "TH"), Locale("ja", "JP", "JP"),
|
||||
Locale("ar", "SA"), Locale.forLanguageTag("th-TH-u-ca-buddhist")
|
||||
)) {
|
||||
Locale.setDefault(l)
|
||||
assertEquals("locale $l", want, labelsAt(utc(2026, 8, 22, 12)))
|
||||
}
|
||||
} finally {
|
||||
Locale.setDefault(original)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- aliasing / shared Calendar state leak ----------
|
||||
|
||||
/**
|
||||
* The shared Calendar is mutated by the labels loop after todayMidnightSec is read.
|
||||
* If any later step re-read it, day 0 would inherit day 2's date. Prove day 0's
|
||||
* slots are anchored on today, not on the last value the Calendar held.
|
||||
*/
|
||||
@Test
|
||||
fun auditSharedCalendarIsNotReReadAfterTheLabelsLoop() {
|
||||
val now = utc(2026, 8, 22, 12)
|
||||
// A report at today 12:30 must be in day 0. If the anchor had leaked to Aug 20
|
||||
// it would fall outside the grid entirely.
|
||||
val s = repo.buildStatuses(
|
||||
listOf("X"), listOf(rep("X", utc(2026, 8, 22, 12, 30), "r")), now
|
||||
).single()
|
||||
assertEquals("Aug 22", s.days[0].dateLabel)
|
||||
assertTrue("today's report must be in day 0 slot 5", "r" in s.days[0].slots[5].reportIds)
|
||||
assertTrue(
|
||||
"no other day may hold it",
|
||||
s.days.drop(1).all { d -> d.slots.all { it.count == 0 } }
|
||||
)
|
||||
}
|
||||
|
||||
/** Two consecutive calls on the same repository must be identical (no instance state). */
|
||||
@Test
|
||||
fun auditRepeatedCallsAreIdempotent() {
|
||||
val now = utc(2026, 8, 22, 12)
|
||||
val reports = listOf(
|
||||
rep("X", utc(2026, 8, 22, 1), "a"), rep("X", utc(2026, 8, 21, 23), "b"),
|
||||
rep("X", utc(2026, 8, 20, 0, 0, 0), "c")
|
||||
)
|
||||
fun shape() = repo.buildStatuses(listOf("X"), reports, now).single()
|
||||
.days.map { d -> d.dateLabel to d.slots.map { it.reportIds } }
|
||||
val first = shape()
|
||||
repeat(5) { assertEquals("call must not drift", first, shape()) }
|
||||
}
|
||||
|
||||
// ---------- slot boundary exactness ----------
|
||||
|
||||
/** No report may appear in two cells, and none inside the window may vanish. */
|
||||
@Test
|
||||
fun auditEveryBoundaryInstantLandsInExactlyOneCell() {
|
||||
val now = utc(2026, 8, 22, 12)
|
||||
val mid = utc(2026, 8, 22, 0, 0, 0)
|
||||
// every slot edge of all three days, and one second either side of each
|
||||
val probes = ArrayList<Long>()
|
||||
for (d in 0 until 3) for (s in 0..12) {
|
||||
val edge = mid - d * 86400L + s * 7200L
|
||||
probes.add(edge - 1); probes.add(edge); probes.add(edge + 1)
|
||||
}
|
||||
for (t in probes.distinct()) {
|
||||
val s = repo.buildStatuses(listOf("X"), listOf(rep("X", t, "r")), now).single()
|
||||
val hits = s.days.withIndex().flatMap { (di, day) ->
|
||||
day.slots.withIndex().filter { "r" in it.value.reportIds }.map { di to it.index }
|
||||
}
|
||||
val inWindow = t >= mid - 2 * 86400L && t < mid + 86400L
|
||||
if (inWindow) {
|
||||
assertEquals("epoch $t must occupy exactly one cell, got $hits", 1, hits.size)
|
||||
// and the cell's day must match the report's UTC date
|
||||
val c = Calendar.getInstance(TimeZone.getTimeZone("UTC"))
|
||||
c.timeInMillis = t * 1000
|
||||
val want = expectLabel(c.get(Calendar.YEAR), c.get(Calendar.MONTH) + 1,
|
||||
c.get(Calendar.DAY_OF_MONTH))
|
||||
assertEquals("epoch $t day label", want, s.days[hits[0].first].dateLabel)
|
||||
// slot index must invert the hour band
|
||||
assertEquals("epoch $t slot", 11 - c.get(Calendar.HOUR_OF_DAY) / 2, hits[0].second)
|
||||
} else {
|
||||
assertEquals("epoch $t is outside the window", 0, hits.size)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Counts must sum to the number of in-window reports: nothing dropped, nothing doubled. */
|
||||
@Test
|
||||
fun auditCountsConserveReports() {
|
||||
val now = utc(2026, 8, 22, 12)
|
||||
val mid = utc(2026, 8, 22, 0, 0, 0)
|
||||
val reports = ArrayList<ApiReport>()
|
||||
var i = 0
|
||||
var t = mid - 2 * 86400L
|
||||
while (t < mid + 86400L) { reports.add(rep("X", t, "r${i++}")); t += 1801 }
|
||||
val s = repo.buildStatuses(listOf("X"), reports, now).single()
|
||||
val total = s.days.sumOf { d -> d.slots.sumOf { it.count } }
|
||||
val ids = s.days.flatMap { d -> d.slots.flatMap { it.reportIds } }
|
||||
assertEquals("every in-window report must be counted once", reports.size, total)
|
||||
assertEquals("no id may repeat", ids.size, ids.toSet().size)
|
||||
assertEquals("id set must be complete", reports.map { it.id }.toSet(), ids.toSet())
|
||||
}
|
||||
|
||||
// ---------- duplicate catalogue names ----------
|
||||
|
||||
@Test
|
||||
fun auditDuplicateCatalogueNamesProduceDuplicateRows() {
|
||||
val s = repo.buildStatuses(
|
||||
listOf("DUP", "DUP", "OTHER"),
|
||||
listOf(rep("DUP", utc(2026, 8, 22, 11), "r")),
|
||||
utc(2026, 8, 22, 12)
|
||||
)
|
||||
assertEquals("a duplicated catalogue name yields a duplicated row", 3, s.size)
|
||||
assertEquals(2, s.count { it.name == "DUP" })
|
||||
// both duplicated rows carry the same report -> the tap dialog double lists it
|
||||
assertEquals(
|
||||
listOf(1, 1),
|
||||
s.filter { it.name == "DUP" }.map { it.days[0].slots[6].count }
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun auditReportsForNamesAbsentFromCatalogueAreSilentlyDropped() {
|
||||
val s = repo.buildStatuses(
|
||||
listOf("IN-CATALOG"),
|
||||
listOf(rep("NOT-IN-CATALOG", utc(2026, 8, 22, 11), "ghost")),
|
||||
utc(2026, 8, 22, 12)
|
||||
)
|
||||
assertTrue(
|
||||
"a report whose satellite is not in the catalogue never renders",
|
||||
s.single().days.all { d -> d.slots.all { it.count == 0 } }
|
||||
)
|
||||
}
|
||||
|
||||
// ---------- unparsable timestamps ----------
|
||||
|
||||
@Test
|
||||
fun auditZeroTimestampFromFailedParseIsDroppedNotShownAsEpoch() {
|
||||
val s = repo.buildStatuses(
|
||||
listOf("X"), listOf(rep("X", 0L, "unparsable")), utc(2026, 8, 22, 12)
|
||||
).single()
|
||||
assertTrue(
|
||||
"a 0L timestamp (parse failure) must not render",
|
||||
s.days.all { d -> d.slots.all { it.count == 0 } }
|
||||
)
|
||||
}
|
||||
|
||||
// ---------- future reports ----------
|
||||
|
||||
@Test
|
||||
fun auditFutureReportsLaterTodayStillRender() {
|
||||
// Fetched at 07:00; a report stamped 23:00 today lands in slot 0 of today.
|
||||
val s = repo.buildStatuses(
|
||||
listOf("X"), listOf(rep("X", utc(2026, 8, 22, 23), "later")), utc(2026, 8, 22, 7)
|
||||
).single()
|
||||
assertTrue("today's later bands are pre-drawn", "later" in s.days[0].slots[0].reportIds)
|
||||
}
|
||||
|
||||
// ---------- complexity ----------
|
||||
|
||||
/** One pass per slot over the satellite's own reports; not O(all reports x slots). */
|
||||
@Test
|
||||
fun auditBuildIsLinearInReportsNotQuadratic() {
|
||||
fun timeFor(nSats: Int, nReports: Int): Long {
|
||||
val names = (0 until nSats).map { "S$it" }
|
||||
val now = utc(2026, 8, 22, 12)
|
||||
val mid = utc(2026, 8, 22, 0, 0, 0)
|
||||
val reports = (0 until nReports).map {
|
||||
rep(names[it % nSats], mid - (it % 172800).toLong(), "r$it")
|
||||
}
|
||||
repo.buildStatuses(names, reports, now) // warm
|
||||
val t0 = System.nanoTime()
|
||||
repeat(3) { repo.buildStatuses(names, reports, now) }
|
||||
return System.nanoTime() - t0
|
||||
}
|
||||
val small = timeFor(88, 500)
|
||||
val big = timeFor(88, 5000)
|
||||
val ratio = big.toDouble() / small
|
||||
println("AUDIT complexity: 500 reports=${small / 1_000_000}ms 5000=${big / 1_000_000}ms ratio=$ratio")
|
||||
assertNotNull(ratio)
|
||||
assertTrue("10x the reports must not cost >40x the time (ratio=$ratio)", ratio < 40)
|
||||
}
|
||||
|
||||
/** toSatReport's YEAR is locale sensitive: proves whether the dialog date corrupts. */
|
||||
@Test
|
||||
fun auditReportDialogDateUnderThaiLocale() {
|
||||
val original = Locale.getDefault()
|
||||
try {
|
||||
val c = Calendar.getInstance(TimeZone.getTimeZone("UTC"))
|
||||
Locale.setDefault(Locale.US)
|
||||
c.timeInMillis = utc(2026, 8, 22, 11) * 1000
|
||||
val gregorianYear = c.get(Calendar.YEAR)
|
||||
Locale.setDefault(Locale("th", "TH", "TH"))
|
||||
val c2 = Calendar.getInstance(TimeZone.getTimeZone("UTC"))
|
||||
c2.timeInMillis = utc(2026, 8, 22, 11) * 1000
|
||||
val thaiYear = c2.get(Calendar.YEAR)
|
||||
println("AUDIT locale year: gregorian=$gregorianYear thai=$thaiYear class=${c2.javaClass.name}")
|
||||
assertEquals(
|
||||
"if these differ, toSatReport prints a Buddhist year in the dialog",
|
||||
gregorianYear, thaiYear
|
||||
)
|
||||
} finally {
|
||||
Locale.setDefault(original)
|
||||
}
|
||||
}
|
||||
}
|
||||
+365
@@ -0,0 +1,365 @@
|
||||
package com.rtbishop.look4sat.core.data.repository
|
||||
|
||||
import com.rtbishop.look4sat.core.domain.source.IRemoteSource
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import java.util.Calendar
|
||||
import java.util.TimeZone
|
||||
|
||||
/**
|
||||
* Pins the grid the AMSAT status page draws.
|
||||
*
|
||||
* Two contracts matter. The day cell renders one stripe per slot, so "every day has
|
||||
* exactly 12 slots, newest first" became load-bearing. And the day columns are UTC
|
||||
* calendar days, so a report must land in the cell whose label matches its UTC date - an
|
||||
* earlier rolling window anchored on "now" put 17.9 hours of yesterday into the cell
|
||||
* labelled today, and 73% of a live 1021-report page landed in the wrong column.
|
||||
*
|
||||
* This drives [AmSatRepository.buildStatuses] directly rather than `fetchStatus`, because
|
||||
* the parsing around it uses Android's `JSONObject`, a stub on the JVM: a `fetchStatus`
|
||||
* test returns null for every input and proves nothing.
|
||||
*/
|
||||
class AmSatSlotBuildTest {
|
||||
|
||||
private object UnusedSource : IRemoteSource {
|
||||
override suspend fun getFileBytes(uri: String): ByteArray? = null
|
||||
override suspend fun getNetworkBytes(url: String): ByteArray? = null
|
||||
override suspend fun getAmSatCatalog(): String? = null
|
||||
override suspend fun getAmSatReports(hours: Int, limit: Int): String? = null
|
||||
override suspend fun getAmSatSummary(hours: Int): String? = null
|
||||
}
|
||||
|
||||
private val repo = AmSatRepository(UnusedSource)
|
||||
|
||||
/** Epoch seconds for a UTC wall-clock instant, so every case reads unambiguously. */
|
||||
private fun utc(year: Int, month: Int, day: Int, hour: Int, minute: Int = 0): Long {
|
||||
val cal = Calendar.getInstance(TimeZone.getTimeZone("UTC"))
|
||||
cal.clear()
|
||||
cal.set(year, month - 1, day, hour, minute, 0)
|
||||
return cal.timeInMillis / 1000
|
||||
}
|
||||
|
||||
/** Midday, so "today" has hours on both sides of the fetch. */
|
||||
private val nowSec = utc(2026, 8, 22, 12)
|
||||
|
||||
private fun report(name: String, status: String, at: Long, id: String = "r-$name-$at") =
|
||||
ApiReport(
|
||||
id = id,
|
||||
name = name,
|
||||
callsign = "TEST",
|
||||
report = status,
|
||||
gridSquare = "AA00",
|
||||
reportedTimeUtcSec = at
|
||||
)
|
||||
|
||||
private fun build(names: List<String>, reports: List<ApiReport>) =
|
||||
repo.buildStatuses(names, reports, nowSec)
|
||||
|
||||
@Test
|
||||
fun `every day carries exactly twelve slots`() {
|
||||
val statuses = build(
|
||||
listOf("AO-91", "SO-50", "ISS"),
|
||||
listOf(report("AO-91", "heard", utc(2026, 8, 22, 11)))
|
||||
)
|
||||
assertEquals(3, statuses.size)
|
||||
for (status in statuses) {
|
||||
assertEquals("${status.name} must have 3 days", 3, status.days.size)
|
||||
for (day in status.days) {
|
||||
assertEquals(
|
||||
"${status.name} ${day.dateLabel} must have 12 slots for the stripe renderer",
|
||||
12, day.slots.size
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a satellite nobody reported still gets twelve slots per day`() {
|
||||
// The renderer must never receive an empty list, which would draw nothing at all.
|
||||
val status = build(listOf("QUIET-1"), emptyList()).single()
|
||||
assertEquals(3, status.days.size)
|
||||
status.days.forEach { assertEquals(12, it.slots.size) }
|
||||
assertTrue(
|
||||
"a silent satellite must be all no-report slots",
|
||||
status.days.all { day -> day.slots.all { it.count == 0 } }
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `days are labelled with UTC calendar dates`() {
|
||||
val status = build(listOf("AO-91"), emptyList()).single()
|
||||
assertEquals("today", "Aug 22", status.days[0].dateLabel)
|
||||
assertEquals("yesterday", "Aug 21", status.days[1].dateLabel)
|
||||
assertEquals("the day before", "Aug 20", status.days[2].dateLabel)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the label does not drift with the time of day`() {
|
||||
// The old rolling window relabelled the same data depending on when it was
|
||||
// fetched. A calendar day must not care.
|
||||
for (hour in listOf(0, 6, 12, 18, 23)) {
|
||||
val labels = repo.buildStatuses(listOf("AO-91"), emptyList(), utc(2026, 8, 22, hour))
|
||||
.single().days.map { it.dateLabel }
|
||||
assertEquals("fetched at ${hour}:00 UTC", listOf("Aug 22", "Aug 21", "Aug 20"), labels)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `slots cover fixed UTC bands, newest first`() {
|
||||
// Slot 0 is 22:00-24:00 and slot 11 is 00:00-02:00, matching amsat.org.
|
||||
val status = build(
|
||||
listOf("AO-91"),
|
||||
listOf(
|
||||
report("AO-91", "heard", utc(2026, 8, 22, 23), id = "lateToday"),
|
||||
report("AO-91", "not heard", utc(2026, 8, 22, 1), id = "earlyToday")
|
||||
)
|
||||
).single()
|
||||
val today = status.days[0]
|
||||
|
||||
assertTrue(
|
||||
"23:00 belongs in slot 0, the day's last band",
|
||||
"lateToday" in today.slots[0].reportIds
|
||||
)
|
||||
assertTrue(
|
||||
"01:00 belongs in slot 11, the day's first band",
|
||||
"earlyToday" in today.slots[11].reportIds
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a report lands in the day matching its UTC date`() {
|
||||
val status = build(
|
||||
listOf("AO-91"),
|
||||
listOf(
|
||||
report("AO-91", "heard", utc(2026, 8, 22, 11), id = "today"),
|
||||
report("AO-91", "heard", utc(2026, 8, 21, 15), id = "yesterday"),
|
||||
report("AO-91", "heard", utc(2026, 8, 20, 5), id = "dayBefore")
|
||||
)
|
||||
).single()
|
||||
|
||||
// Positions computed from the UTC bands: 11:00 -> slot 6, 15:00 -> slot 4,
|
||||
// 05:00 -> slot 9.
|
||||
assertTrue("today's report", "today" in status.days[0].slots[6].reportIds)
|
||||
assertTrue("yesterday's report", "yesterday" in status.days[1].slots[4].reportIds)
|
||||
assertTrue("the day before", "dayBefore" in status.days[2].slots[9].reportIds)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a report just after midnight stays in the new day`() {
|
||||
// The boundary the rolling window got wrong: 00:30 today must not appear as
|
||||
// yesterday.
|
||||
val status = build(
|
||||
listOf("AO-91"),
|
||||
listOf(report("AO-91", "heard", utc(2026, 8, 22, 0, 30), id = "justAfterMidnight"))
|
||||
).single()
|
||||
|
||||
assertTrue(
|
||||
"00:30 belongs to today's first band",
|
||||
"justAfterMidnight" in status.days[0].slots[11].reportIds
|
||||
)
|
||||
assertTrue(
|
||||
"yesterday must stay empty",
|
||||
status.days[1].slots.all { it.count == 0 }
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `each status maps to its own colour`() {
|
||||
// The stripes are now the only carrier of status, so distinct states must stay
|
||||
// distinct all the way out of the repository.
|
||||
val at = utc(2026, 8, 22, 11)
|
||||
val statuses = build(
|
||||
listOf("A", "B", "C", "D"),
|
||||
listOf(
|
||||
report("A", "heard", at),
|
||||
report("B", "telemetry only", at),
|
||||
report("C", "not heard", at),
|
||||
report("D", "something the api invented", at)
|
||||
)
|
||||
)
|
||||
val colours = statuses.map { status -> status.days[0].slots[6].statusColor }
|
||||
assertTrue("no state may be colourless", colours.none { it == 0L })
|
||||
assertEquals(
|
||||
"heard, telemetry and not heard must be visually distinct",
|
||||
3, colours.take(3).toSet().size
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a slot keeps every report it contains`() {
|
||||
// The tap dialog lists reports from the slots, so none may be dropped when several
|
||||
// land in the same two-hour window. 10:00-12:00 is slot 6.
|
||||
val status = build(
|
||||
listOf("AO-91"),
|
||||
listOf(
|
||||
report("AO-91", "heard", utc(2026, 8, 22, 10, 15), id = "a"),
|
||||
report("AO-91", "heard", utc(2026, 8, 22, 11, 0), id = "b"),
|
||||
report("AO-91", "not heard", utc(2026, 8, 22, 11, 45), id = "c")
|
||||
)
|
||||
).single()
|
||||
val slot = status.days[0].slots[6]
|
||||
assertEquals("all three reports fall in the same band", 3, slot.count)
|
||||
assertEquals(setOf("a", "b", "c"), slot.reportIds.toSet())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a slot shows the newest status when reports disagree`() {
|
||||
// Within one band the most recent observation wins; anything else would keep
|
||||
// showing a failure after the satellite recovered.
|
||||
fun colourFor(firstStatus: String, secondStatus: String): Long = build(
|
||||
listOf("AO-91"),
|
||||
listOf(
|
||||
report("AO-91", firstStatus, utc(2026, 8, 22, 10, 15), id = "older"),
|
||||
report("AO-91", secondStatus, utc(2026, 8, 22, 11, 45), id = "newer")
|
||||
)
|
||||
).single().days[0].slots[6].statusColor
|
||||
|
||||
assertTrue(
|
||||
"the slot colour must follow the newest report, not the first",
|
||||
colourFor("not heard", "heard") != colourFor("heard", "not heard")
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `reports outside the three-day window are ignored`() {
|
||||
val status = build(
|
||||
listOf("AO-91"),
|
||||
listOf(
|
||||
report("AO-91", "heard", utc(2026, 8, 18, 12), id = "tooOld"),
|
||||
report("AO-91", "heard", utc(2026, 8, 23, 12), id = "future")
|
||||
)
|
||||
).single()
|
||||
assertTrue(
|
||||
"nothing outside the window may appear",
|
||||
status.days.all { day -> day.slots.all { it.count == 0 } }
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `reports for other satellites do not leak between rows`() {
|
||||
val statuses = build(
|
||||
listOf("AO-91", "SO-50"),
|
||||
listOf(report("AO-91", "heard", utc(2026, 8, 22, 11), id = "onlyAo91"))
|
||||
)
|
||||
val ao91 = statuses.first { it.name == "AO-91" }
|
||||
val so50 = statuses.first { it.name == "SO-50" }
|
||||
|
||||
assertEquals("AO-91 has its report", 1, ao91.days[0].slots[6].count)
|
||||
assertTrue(
|
||||
"SO-50 must stay empty",
|
||||
so50.days.all { day -> day.slots.all { it.count == 0 } }
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an empty catalog yields no rows rather than a malformed grid`() {
|
||||
assertTrue(build(emptyList(), emptyList()).isEmpty())
|
||||
}
|
||||
|
||||
/**
|
||||
* Slots older than the data we received must not claim nobody was listening.
|
||||
*
|
||||
* The API caps at 500 records however many hours are asked for. Measured live, a
|
||||
* 72-hour request returned 500 reports covering only 49 hours, so the oldest 9.5 hours
|
||||
* of the third day had no data at all - 352 of 3168 cells were painting "nobody heard
|
||||
* it" over "we never looked".
|
||||
*/
|
||||
@Test
|
||||
fun `slots before the data starts are marked no-data, not no-report`() {
|
||||
// The only report is midday yesterday, so nothing older than that was covered.
|
||||
val oldestReport = utc(2026, 8, 21, 12)
|
||||
val status = build(
|
||||
listOf("AO-91"),
|
||||
listOf(report("AO-91", "heard", oldestReport, id = "only"))
|
||||
).single()
|
||||
|
||||
val noReport = 0xFFC0C0C0
|
||||
val noData = 0xFFE8E8E8
|
||||
|
||||
// The day before yesterday is entirely before the data begins.
|
||||
assertTrue(
|
||||
"every slot older than the data must read as no-data",
|
||||
status.days[2].slots.all { it.statusColor == noData }
|
||||
)
|
||||
|
||||
// Yesterday straddles it: bands after midday are covered, bands before are not.
|
||||
val yesterday = status.days[1]
|
||||
assertEquals("the report's own band", 1, yesterday.slots[5].count)
|
||||
assertTrue(
|
||||
"bands after the oldest report are covered, so silence there is real",
|
||||
yesterday.slots.take(6).all { it.statusColor != noData }
|
||||
)
|
||||
assertTrue(
|
||||
"the earliest band of yesterday is before any data",
|
||||
yesterday.slots[11].statusColor == noData
|
||||
)
|
||||
|
||||
// Today is entirely after the data starts, so its silence is genuine.
|
||||
assertTrue(
|
||||
"today's empty slots mean nobody reported",
|
||||
status.days[0].slots.all { it.statusColor == noReport }
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `coverage is judged from all reports, not one satellite's`() {
|
||||
// A satellite nobody reported must not show as no-data for the whole grid: the
|
||||
// slots were covered, that satellite simply was not heard.
|
||||
val statuses = build(
|
||||
listOf("LOUD", "QUIET"),
|
||||
listOf(report("LOUD", "heard", utc(2026, 8, 20, 1), id = "early"))
|
||||
)
|
||||
val quiet = statuses.first { it.name == "QUIET" }
|
||||
val noData = 0xFFE8E8E8
|
||||
|
||||
assertTrue(
|
||||
"coverage reaches back to the earliest report of any satellite",
|
||||
quiet.days.all { day -> day.slots.none { it.statusColor == noData } }
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A report whose timestamp failed to parse must not disable the distinction.
|
||||
*
|
||||
* parseIsoUtcSec returns 0 for an unparseable reported_time, and coverage is the
|
||||
* minimum timestamp in the response - so one such record would put the coverage
|
||||
* boundary in 1970 and mark every slot as reported-on. Measured on a grid that should
|
||||
* have had 18 no-data cells, a single zero timestamp took it to none.
|
||||
*/
|
||||
@Test
|
||||
fun `a report with an unparseable timestamp does not disable the no-data marking`() {
|
||||
val noData = 0xFFE8E8E8
|
||||
val realReport = report("AO-91", "heard", utc(2026, 8, 21, 12), id = "real")
|
||||
val brokenTimestamp = ApiReport(
|
||||
id = "broken",
|
||||
name = "AO-91",
|
||||
callsign = "TEST",
|
||||
report = "heard",
|
||||
gridSquare = "AA00",
|
||||
reportedTimeUtcSec = 0L
|
||||
)
|
||||
|
||||
val withoutBroken = build(listOf("AO-91"), listOf(realReport))
|
||||
.single().days.sumOf { day -> day.slots.count { it.statusColor == noData } }
|
||||
val withBroken = build(listOf("AO-91"), listOf(realReport, brokenTimestamp))
|
||||
.single().days.sumOf { day -> day.slots.count { it.statusColor == noData } }
|
||||
|
||||
assertTrue("the baseline must have uncovered slots to compare", withoutBroken > 0)
|
||||
assertEquals(
|
||||
"a zero timestamp must not change what counts as covered",
|
||||
withoutBroken, withBroken
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an empty response marks nothing as covered`() {
|
||||
// With no reports at all there is no evidence about any slot.
|
||||
val status = build(listOf("AO-91"), emptyList()).single()
|
||||
val noData = 0xFFE8E8E8
|
||||
assertTrue(
|
||||
"yesterday and earlier cannot be claimed as silent",
|
||||
status.days.drop(1).all { day -> day.slots.all { it.statusColor == noData } }
|
||||
)
|
||||
}
|
||||
}
|
||||
+123
-19
@@ -18,6 +18,7 @@
|
||||
package com.rtbishop.look4sat.core.data.repository
|
||||
|
||||
import com.rtbishop.look4sat.core.domain.model.DataSourcesSettings
|
||||
import com.rtbishop.look4sat.core.domain.source.Sources
|
||||
import com.rtbishop.look4sat.core.domain.model.DatabaseState
|
||||
import com.rtbishop.look4sat.core.domain.model.OtherSettings
|
||||
import com.rtbishop.look4sat.core.domain.model.PassesSettings
|
||||
@@ -40,7 +41,6 @@ import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import java.io.InputStream
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class DatabaseRepoTest {
|
||||
@@ -53,7 +53,7 @@ class DatabaseRepoTest {
|
||||
val uri = "content://look4sat/import/satellites"
|
||||
val localSource = FakeLocalSource()
|
||||
val remoteSource = FakeRemoteSource().apply {
|
||||
fileStreams[uri] = { validCsvStream() }
|
||||
fileData[uri] = { validCsvBytes() }
|
||||
}
|
||||
val settingsRepo = FakeSettingsRepo()
|
||||
val repository = DatabaseRepo(dispatcher, dataParser, localSource, remoteSource, settingsRepo)
|
||||
@@ -71,7 +71,7 @@ class DatabaseRepoTest {
|
||||
val uri = "content://look4sat/import/legacy"
|
||||
val localSource = FakeLocalSource()
|
||||
val remoteSource = FakeRemoteSource().apply {
|
||||
fileStreams[uri] = { validTleStream() }
|
||||
fileData[uri] = { validTleBytes() }
|
||||
}
|
||||
val settingsRepo = FakeSettingsRepo()
|
||||
val repository = DatabaseRepo(dispatcher, dataParser, localSource, remoteSource, settingsRepo)
|
||||
@@ -87,7 +87,7 @@ class DatabaseRepoTest {
|
||||
val customCsvUrl = "https://example.com/custom-omm.csv"
|
||||
val localSource = FakeLocalSource()
|
||||
val remoteSource = FakeRemoteSource().apply {
|
||||
networkStreams[customCsvUrl] = { validCsvStream() }
|
||||
networkData[customCsvUrl] = { validCsvBytes() }
|
||||
}
|
||||
val settingsRepo = FakeSettingsRepo(
|
||||
dataSources = DataSourcesSettings(
|
||||
@@ -102,29 +102,129 @@ class DatabaseRepoTest {
|
||||
repository.updateFromRemote()
|
||||
|
||||
assertTrue(localSource.insertedEntries.any { it.catnum == 25544 })
|
||||
// 新语义: 开关开 + URL 非空 -> All 源用自定义 URL, 数据归入 All 类型
|
||||
assertEquals(listOf(25544), settingsRepo.satelliteTypeIdsByType["All"])
|
||||
// A custom URL replaces the built-in TLE sources: none of them is requested. The
|
||||
// transceivers group is separate and its own switch is off here, so it still fetches.
|
||||
val builtInTle = Sources.satelliteDataUrls.values.filter { it.isNotBlank() }
|
||||
assertTrue(
|
||||
"no built-in TLE source may be fetched, got " + remoteSource.requestedUrls,
|
||||
builtInTle.none { it in remoteSource.requestedUrls }
|
||||
)
|
||||
assertTrue(
|
||||
"the operator's URL must be fetched",
|
||||
customCsvUrl in remoteSource.requestedUrls
|
||||
)
|
||||
// Indexed under "Custom". It used to go under "All", where setSatelliteTypeIds
|
||||
// early-returns, so the type filter never saw these satellites and the old assertion here
|
||||
// was checking a no-op.
|
||||
assertEquals(listOf(25544), settingsRepo.satelliteTypeIdsByType["Custom"])
|
||||
assertEquals(null, settingsRepo.satelliteTypeIdsByType["All"])
|
||||
// NOT "Other": that key belongs to manual file import, and setSatelliteTypeIds overwrites
|
||||
// rather than merges, so sharing it would have each source wipe the other's index.
|
||||
assertEquals(null, settingsRepo.satelliteTypeIdsByType["Other"])
|
||||
}
|
||||
|
||||
private fun validCsvStream(): InputStream = """
|
||||
/** The switch-off path must be untouched: all built-in sources, exactly as before. */
|
||||
@Test
|
||||
fun `without a custom source every built-in source is fetched`() = runTest(dispatcher) {
|
||||
val localSource = FakeLocalSource()
|
||||
val remoteSource = FakeRemoteSource().apply {
|
||||
// Every built-in TLE source has to answer, or updateFromRemote throws because all of
|
||||
// them failed, which would mask what this test checks. The transceivers group is left
|
||||
// unanswered on purpose: org.json is compileOnly in core:domain, so DataParser cannot
|
||||
// parse a radio payload on the JVM anyway.
|
||||
Sources.satelliteDataUrls.values.filter { it.isNotBlank() }
|
||||
.forEach { networkData[it] = { validCsvBytes() } }
|
||||
}
|
||||
val settingsRepo = FakeSettingsRepo(
|
||||
dataSources = DataSourcesSettings(
|
||||
useCustomTLE = false,
|
||||
useCustomTransceivers = false,
|
||||
tleUrl = "https://example.com/ignored.csv",
|
||||
transceiversUrl = ""
|
||||
)
|
||||
)
|
||||
val repository = DatabaseRepo(dispatcher, dataParser, localSource, remoteSource, settingsRepo)
|
||||
|
||||
repository.updateFromRemote()
|
||||
|
||||
val expected = Sources.satelliteDataUrls.values.filter { it.isNotBlank() }
|
||||
assertTrue(
|
||||
"expected all built-in sources, got " + remoteSource.requestedUrls.size,
|
||||
expected.all { it in remoteSource.requestedUrls }
|
||||
)
|
||||
assertTrue(
|
||||
"the custom URL must not be fetched when the switch is off",
|
||||
"https://example.com/ignored.csv" !in remoteSource.requestedUrls
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A dead custom URL must fail the update even when the transceivers source answers.
|
||||
*
|
||||
* The counts used to be added together, so one transceivers success covered a total orbital
|
||||
* failure: no exception, and a fresh "updated successfully" timestamp for an update that
|
||||
* refreshed nothing. Replacing the built-in sources shrank the denominator from 28 to 2 and
|
||||
* made that easy to hit.
|
||||
*/
|
||||
@Test
|
||||
fun `a dead custom url fails the update even if transceivers succeed`() = runTest(dispatcher) {
|
||||
val localSource = FakeLocalSource()
|
||||
val remoteSource = FakeRemoteSource().apply {
|
||||
Sources.transceiversDataUrls.values.filter { it.isNotBlank() }
|
||||
.forEach { networkData[it] = { "[]".encodeToByteArray() } }
|
||||
}
|
||||
val settingsRepo = FakeSettingsRepo(
|
||||
dataSources = DataSourcesSettings(
|
||||
useCustomTLE = true,
|
||||
useCustomTransceivers = false,
|
||||
tleUrl = "https://example.com/dead.csv",
|
||||
transceiversUrl = ""
|
||||
)
|
||||
)
|
||||
val repository = DatabaseRepo(dispatcher, dataParser, localSource, remoteSource, settingsRepo)
|
||||
|
||||
var threw = false
|
||||
try {
|
||||
repository.updateFromRemote()
|
||||
} catch (_: java.io.IOException) {
|
||||
threw = true
|
||||
}
|
||||
|
||||
assertTrue("a total orbital failure must raise", threw)
|
||||
assertTrue("no entries may be inserted", localSource.insertedEntries.isEmpty())
|
||||
}
|
||||
|
||||
private fun validCsvBytes(): ByteArray = """
|
||||
OBJECT_NAME,OBJECT_ID,EPOCH,MEAN_MOTION,ECCENTRICITY,INCLINATION,RA_OF_ASC_NODE,ARG_OF_PERICENTER,MEAN_ANOMALY,EPHEMERIS_TYPE,CLASSIFICATION_TYPE,NORAD_CAT_ID,ELEMENT_SET_NO,REV_AT_EPOCH,BSTAR,MEAN_MOTION_DOT,MEAN_MOTION_DDOT
|
||||
ISS (ZARYA),1998-067A,2021-11-16T12:28:09.322176,15.48582035,.0004694,51.6447,309.4881,203.6966,299.8876,0,U,25544,999,31220,.31985E-4,.1288E-4,0
|
||||
""".trimIndent().byteInputStream()
|
||||
""".trimIndent().encodeToByteArray()
|
||||
|
||||
private fun validTleStream(): InputStream = """
|
||||
private fun validTleBytes(): ByteArray = """
|
||||
ISS (ZARYA)
|
||||
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()
|
||||
""".trimIndent().encodeToByteArray()
|
||||
}
|
||||
|
||||
private class FakeRemoteSource : IRemoteSource {
|
||||
val fileStreams: MutableMap<String, () -> InputStream> = mutableMapOf()
|
||||
val networkStreams: MutableMap<String, () -> InputStream> = mutableMapOf()
|
||||
val fileData: MutableMap<String, () -> ByteArray> = mutableMapOf()
|
||||
val networkData: MutableMap<String, () -> ByteArray> = mutableMapOf()
|
||||
|
||||
override suspend fun getFileStream(uri: String): InputStream? = fileStreams[uri]?.invoke()
|
||||
/** Every URL asked for, so a test can assert WHICH sources were fetched, not just the result. */
|
||||
val requestedUrls = mutableListOf<String>()
|
||||
|
||||
override suspend fun getNetworkStream(url: String): InputStream? = networkStreams[url]?.invoke()
|
||||
override suspend fun getFileBytes(uri: String): ByteArray? = fileData[uri]?.invoke()
|
||||
|
||||
override suspend fun getNetworkBytes(url: String): ByteArray? {
|
||||
requestedUrls += url
|
||||
return networkData[url]?.invoke()
|
||||
}
|
||||
|
||||
override suspend fun getAmSatCatalog(): String? = null
|
||||
|
||||
override suspend fun getAmSatReports(hours: Int, limit: Int): String? = null
|
||||
|
||||
override suspend fun getAmSatSummary(hours: Int): String? = null
|
||||
}
|
||||
|
||||
private class FakeLocalSource : ILocalSource {
|
||||
@@ -166,10 +266,10 @@ private class FakeSettingsRepo(dataSources: DataSourcesSettings = defaultDataSou
|
||||
|
||||
override val selectedIds: StateFlow<List<Int>> = MutableStateFlow(emptyList())
|
||||
|
||||
override val selectedTypes: StateFlow<List<String>> = MutableStateFlow(emptyList())
|
||||
override val selectedSatModes: StateFlow<List<String>> = MutableStateFlow(emptyList())
|
||||
|
||||
override val passesSettings: StateFlow<PassesSettings> = MutableStateFlow(
|
||||
PassesSettings(hoursAhead = 24, minElevation = 0.0, selectedModes = emptyList())
|
||||
PassesSettings(hoursAhead = 24, minElevation = 0.0)
|
||||
)
|
||||
|
||||
override val stationPosition: StateFlow<GeoPos> = MutableStateFlow(GeoPos(0.0, 0.0))
|
||||
@@ -177,7 +277,7 @@ private class FakeSettingsRepo(dataSources: DataSourcesSettings = defaultDataSou
|
||||
override val databaseState: MutableStateFlow<DatabaseState> = MutableStateFlow(DatabaseState(0, 0, 0L))
|
||||
|
||||
override val rcSettings: StateFlow<RCSettings> = MutableStateFlow(
|
||||
RCSettings(false, "", "", "", false, "", "", "", false, "", "", "", false, "", "")
|
||||
RCSettings(false, "", "", "", false, "", "", "", 0L, false, "", "", "", false, "", "")
|
||||
)
|
||||
|
||||
override val otherSettings: StateFlow<OtherSettings> = MutableStateFlow(
|
||||
@@ -194,13 +294,13 @@ private class FakeSettingsRepo(dataSources: DataSourcesSettings = defaultDataSou
|
||||
|
||||
override fun setSelectedIds(ids: List<Int>) = Unit
|
||||
|
||||
override fun setSelectedTypes(types: List<String>) = Unit
|
||||
override fun setSelectedSatModes(modes: List<String>) = Unit
|
||||
|
||||
override fun setPassesSettings(settings: PassesSettings) = Unit
|
||||
|
||||
override fun setStationPosition(latitude: Double, longitude: Double, altitude: Double): Boolean = true
|
||||
|
||||
override fun setStationPosition(): Boolean = true
|
||||
override suspend fun setStationPosition(): Boolean = true
|
||||
|
||||
override fun setStationPosition(locator: String): Boolean = true
|
||||
|
||||
@@ -223,6 +323,10 @@ private class FakeSettingsRepo(dataSources: DataSourcesSettings = defaultDataSou
|
||||
}
|
||||
|
||||
override fun updateRadioControlSettings(settings: RadioControlSettings) = Unit
|
||||
|
||||
override fun getSatelliteOffset(catnum: Int): String = ""
|
||||
|
||||
override fun setSatelliteOffset(catnum: Int, offset: String) = Unit
|
||||
}
|
||||
|
||||
private fun defaultDataSourcesSettings(): DataSourcesSettings {
|
||||
|
||||
+181
@@ -0,0 +1,181 @@
|
||||
/*
|
||||
* 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 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>) = Unit
|
||||
|
||||
override suspend fun deleteRadios() = Unit
|
||||
}
|
||||
|
||||
private class FakeSettingsRepo(
|
||||
selectedModes: List<String>
|
||||
) : ISettingsRepo {
|
||||
|
||||
override val appVersionName: String = "test"
|
||||
|
||||
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(false, false, "", "")
|
||||
)
|
||||
|
||||
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 suspend fun setStationPosition(): Boolean = true
|
||||
|
||||
override fun setStationPosition(locator: String): Boolean = true
|
||||
|
||||
override fun getSatelliteTypesIds(types: List<String>): List<Int> = emptyList()
|
||||
|
||||
override fun setSatelliteTypeIds(type: String, ids: List<Int>) = Unit
|
||||
|
||||
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 updateRadioControlSettings(settings: RadioControlSettings) = Unit
|
||||
|
||||
override fun getSatelliteOffset(catnum: Int): String = ""
|
||||
|
||||
override fun setSatelliteOffset(catnum: Int, offset: String) = Unit
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,3 @@
|
||||
plugins {
|
||||
alias(libs.plugins.convention.coreDomainPlugin)
|
||||
}
|
||||
|
||||
dependencies {
|
||||
// 编译期使用 org.json(构造/解析 WaveLog API 请求体); 运行时用 Android 系统自带的 org.json
|
||||
compileOnly("org.json:json:20240303")
|
||||
}
|
||||
+156
@@ -0,0 +1,156 @@
|
||||
/*
|
||||
* 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.aprs
|
||||
|
||||
/**
|
||||
* Builds the position line this station puts on APRS-IS, or refuses to.
|
||||
*
|
||||
* Separated from the reporter so the packet can be tested without a socket. Every rule here
|
||||
* comes from aprs-is.net/connecting.aspx, and each of them was being broken:
|
||||
*
|
||||
* - the path must be exactly `TCPIP*`, and was absent entirely
|
||||
* - the line must not exceed 512 bytes including CRLF, and had no cap
|
||||
* - the comment must not contain a line break, or it injects a second packet
|
||||
* - a station with no position must not transmit, where 0.0 was substituted so 0 degrees north,
|
||||
* 0 degrees east - a point in the Gulf of Guinea - went out under the operator's callsign
|
||||
*/
|
||||
object AprsBeacon {
|
||||
|
||||
/** The only path a client-originated packet may carry. */
|
||||
const val PATH = "TCPIP*"
|
||||
|
||||
/** Destination for a position report with no addressee. */
|
||||
const val DESTINATION = "APRS"
|
||||
|
||||
/** Maximum line length including the CRLF the caller appends. */
|
||||
const val MAX_LINE_BYTES = 512
|
||||
|
||||
/** Comment limit for this position format, per the APRS specification. */
|
||||
const val MAX_COMMENT = 43
|
||||
|
||||
/** Why a beacon could not be built. */
|
||||
sealed interface Refusal {
|
||||
|
||||
/** No position was available. Transmitting 0,0 would claim the Gulf of Guinea. */
|
||||
data object NoPosition : Refusal
|
||||
|
||||
/** The callsign is missing, so the packet would have no valid source. */
|
||||
data object NoCallsign : Refusal
|
||||
|
||||
/** Latitude or longitude outside the possible range. */
|
||||
data class ImpossiblePosition(val latitude: Double, val longitude: Double) : Refusal
|
||||
}
|
||||
|
||||
/** Either a line ready to send, or the reason there is none. */
|
||||
sealed interface Result {
|
||||
data class Line(val text: String) : Result
|
||||
data class Blocked(val refusal: Refusal) : Result
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the position line.
|
||||
*
|
||||
* Returns [Result.Blocked] rather than a placeholder: a beacon is a claim about where the
|
||||
* operator is, and there is no honest default for "nowhere".
|
||||
*/
|
||||
fun build(
|
||||
callsign: String,
|
||||
ssid: String,
|
||||
latitude: Double?,
|
||||
longitude: Double?,
|
||||
symbolTable: String,
|
||||
symbolCode: String,
|
||||
comment: String
|
||||
): Result {
|
||||
if (callsign.isBlank()) return Result.Blocked(Refusal.NoCallsign)
|
||||
if (latitude == null || longitude == null) return Result.Blocked(Refusal.NoPosition)
|
||||
if (latitude !in -90.0..90.0 || longitude !in -180.0..180.0) {
|
||||
return Result.Blocked(Refusal.ImpossiblePosition(latitude, longitude))
|
||||
}
|
||||
val source = AprsPacket.formatCallSsid(callsign.trim().uppercase(), ssid.trim())
|
||||
val position = AprsPosition(
|
||||
latitude = latitude,
|
||||
longitude = longitude,
|
||||
symbolTable = tableOf(symbolTable),
|
||||
symbolCode = codeOf(symbolCode)
|
||||
)
|
||||
val header = "$source>$DESTINATION,$PATH:="
|
||||
val body = position.toUncompressedString()
|
||||
val room = MAX_LINE_BYTES - CRLF_BYTES - header.encodeToByteArray().size - body.encodeToByteArray().size
|
||||
return Result.Line(header + body + sanitiseComment(comment, room))
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip anything that would break the line, then trim to fit.
|
||||
*
|
||||
* A newline typed into the comment field used to end the packet early and start a second one
|
||||
* from the remaining text - an injection the operator could trigger by accident.
|
||||
*/
|
||||
fun sanitiseComment(comment: String, room: Int = MAX_COMMENT): String {
|
||||
if (room <= 0) return ""
|
||||
val cleaned = comment.asSequence()
|
||||
// Printable ASCII only: line breaks split the packet, and control characters have no
|
||||
// meaning in a comment while being able to confuse a parser.
|
||||
.filter { it.code in 0x20..0x7E }
|
||||
.joinToString("")
|
||||
.trim()
|
||||
val limit = minOf(MAX_COMMENT, room)
|
||||
return if (cleaned.length <= limit) cleaned else cleaned.take(limit)
|
||||
}
|
||||
|
||||
/**
|
||||
* The symbol table byte, defaulting to the primary table.
|
||||
*
|
||||
* Must be `/`, `\` or an overlay character. It was previously whatever the operator typed
|
||||
* first - any character at all, including one that breaks the fixed-width parse. aprs.fi
|
||||
* names symbol misconfiguration as the most common reason a station never appears on the map.
|
||||
*/
|
||||
fun tableOf(entry: String): Char {
|
||||
val candidate = entry.trim().firstOrNull() ?: return TABLE_PRIMARY
|
||||
return when {
|
||||
candidate == TABLE_PRIMARY || candidate == TABLE_ALTERNATE -> candidate
|
||||
candidate.isDigit() -> candidate
|
||||
candidate in 'A'..'Z' -> candidate
|
||||
else -> TABLE_PRIMARY
|
||||
}
|
||||
}
|
||||
|
||||
/** The symbol byte. Any printable character is a valid symbol; anything else is not. */
|
||||
fun codeOf(entry: String): Char {
|
||||
val candidate = entry.trim().firstOrNull() ?: return DEFAULT_SYMBOL
|
||||
return if (candidate.code in 0x21..0x7E) candidate else DEFAULT_SYMBOL
|
||||
}
|
||||
|
||||
private const val TABLE_PRIMARY = '/'
|
||||
private const val TABLE_ALTERNATE = '\\'
|
||||
|
||||
/** Bytes the caller adds after the line. */
|
||||
private const val CRLF_BYTES = 2
|
||||
|
||||
/** Fallback symbol. `>` is a car on the primary table - a reasonable stand-in for a phone. */
|
||||
/**
|
||||
* Substituted when the stored code is unusable.
|
||||
*
|
||||
* A house, not a car. The old default was '>' (CAR) with a comment conceding it was "a
|
||||
* reasonable stand-in for a phone" - but a station beaconing from a handset showed up as a
|
||||
* vehicle for every operator who was not driving, and aprs.fi names transmit-side symbol
|
||||
* misconfiguration among the first things to check when a station looks wrong. A house is
|
||||
* correct for most users and obviously wrong rather than misleading for the rest.
|
||||
*/
|
||||
private const val DEFAULT_SYMBOL = '-'
|
||||
}
|
||||
+140
@@ -0,0 +1,140 @@
|
||||
/*
|
||||
* 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.aprs
|
||||
|
||||
/**
|
||||
* The APRS-IS login line and the verdict the server returns for it.
|
||||
*
|
||||
* Kept apart from the socket so it can be tested: whether a login was verified decides whether
|
||||
* anything this app sends reaches the network, and that distinction used to be made by a
|
||||
* substring check inside a runCatching whose result was discarded, so it could not fail loudly.
|
||||
*
|
||||
* Format per aprs-is.net/connecting.aspx:
|
||||
* `user mycall[-ss] pass passcode [vers softwarename softwarevers [filter ...]]`
|
||||
*/
|
||||
object AprsLogin {
|
||||
|
||||
/** What the server decided about a login attempt. */
|
||||
sealed interface Outcome {
|
||||
|
||||
/** The passcode matched the callsign. Packets from this client are accepted. */
|
||||
data class Verified(val callsign: String) : Outcome
|
||||
|
||||
/**
|
||||
* The server accepted the connection but did not verify the login.
|
||||
*
|
||||
* Not an error at the socket level, which is exactly why it needs surfacing: writes keep
|
||||
* succeeding while the server discards every packet. A receive-only login (passcode -1)
|
||||
* lands here legitimately.
|
||||
*/
|
||||
data class Unverified(val callsign: String) : Outcome
|
||||
|
||||
/** The server refused the login outright. */
|
||||
data class Rejected(val detail: String) : Outcome
|
||||
|
||||
/** Nothing recognisable arrived. The connection may still work; we simply do not know. */
|
||||
data class Unknown(val detail: String) : Outcome
|
||||
}
|
||||
|
||||
/** Any run of whitespace, collapsed to a hyphen inside a single token. */
|
||||
private val WHITESPACE = Regex("""\s+""")
|
||||
|
||||
/** Passcode value that asks for a receive-only connection. */
|
||||
const val RECEIVE_ONLY_PASSCODE = -1
|
||||
|
||||
/**
|
||||
* Build the login line.
|
||||
*
|
||||
* `vers` takes TWO tokens - a software name and a version, separated by a space. An earlier
|
||||
* version of this replaced that space with a hyphen, reading the rule "softwarename must not
|
||||
* contain a space" as "the field must be a single token". Live aprsc 2.1.21 rejects the result:
|
||||
*
|
||||
* sent: user N0CALL pass -1 vers Look4Sat-4.5.4
|
||||
* got: # Invalid login: software name and version are not separated by a space
|
||||
*
|
||||
* So name and version stay apart, and whitespace is collapsed WITHIN each of them instead.
|
||||
*/
|
||||
fun line(
|
||||
callsign: String,
|
||||
ssid: String,
|
||||
passcode: Int,
|
||||
name: String,
|
||||
version: String,
|
||||
filter: String = ""
|
||||
): String {
|
||||
val callSsid = AprsPacket.formatCallSsid(callsign, ssid)
|
||||
val safeName = name.trim().replace(WHITESPACE, "-").ifEmpty { "Look4Sat" }
|
||||
val safeVersion = version.trim().replace(WHITESPACE, "-").ifEmpty { "0" }
|
||||
val base = "user $callSsid pass $passcode vers $safeName $safeVersion"
|
||||
val trimmedFilter = filter.trim()
|
||||
return if (trimmedFilter.isEmpty()) base else "$base $trimmedFilter"
|
||||
}
|
||||
|
||||
/**
|
||||
* Interpret one line of server output, or null when it carries no verdict.
|
||||
*
|
||||
* Classified by what is KNOWN HARMLESS rather than by a list of known refusals, because that
|
||||
* list was incomplete and the failure is silent. aprsc refuses with `# Invalid login: ...` but
|
||||
* also `# Login by user not allowed` - observed live on rotate.aprs2.net - and `# Port full`
|
||||
* and `# Server full`. Each was skipped as chatter, the login timed out into Unknown, Unknown
|
||||
* is deliberately read as "may be working", and every send afterwards reported success to an
|
||||
* operator the server had refused.
|
||||
*
|
||||
* So identification and keepalive comments return null, a logresp is parsed, and anything else
|
||||
* the server bothers to say during login counts as it objecting.
|
||||
*
|
||||
* Note that "unverified" contains "verified", so the negative is tested first - a naive
|
||||
* contains("verified") reports every refusal as acceptance.
|
||||
*/
|
||||
fun parse(line: String): Outcome? {
|
||||
val trimmed = line.trim()
|
||||
if (trimmed.isEmpty()) return null
|
||||
if (!trimmed.startsWith("#")) {
|
||||
// A non-comment line during login is the server objecting in plain text.
|
||||
return Outcome.Rejected(trimmed)
|
||||
}
|
||||
val lower = trimmed.lowercase()
|
||||
if (lower.contains("logresp")) {
|
||||
val callsign = callsignFrom(trimmed)
|
||||
return when {
|
||||
lower.contains("unverified") -> Outcome.Unverified(callsign)
|
||||
lower.contains("verified") -> Outcome.Verified(callsign)
|
||||
else -> Outcome.Unknown(trimmed)
|
||||
}
|
||||
}
|
||||
// Identification and keepalives are the only comments that mean "keep reading".
|
||||
if (HARMLESS.any { lower.startsWith(it) }) return null
|
||||
return Outcome.Rejected(trimmed.removePrefix("#").trim())
|
||||
}
|
||||
|
||||
/**
|
||||
* Comment prefixes that carry no verdict.
|
||||
*
|
||||
* Matching a prefix rather than searching for refusal words means a refusal nobody anticipated
|
||||
* is treated as a refusal instead of being ignored.
|
||||
*/
|
||||
private val HARMLESS = listOf("# aprsc", "# javaprssrvr", "# aprsis", "# filter")
|
||||
|
||||
/** The callsign token in `# logresp CALL verified, ...`, or empty when absent. */
|
||||
private fun callsignFrom(response: String): String {
|
||||
val tokens = response.removePrefix("#").trim().split(Regex("\\s+"))
|
||||
val index = tokens.indexOfFirst { it.equals("logresp", ignoreCase = true) }
|
||||
if (index < 0) return ""
|
||||
return tokens.getOrNull(index + 1)?.trimEnd(',') ?: ""
|
||||
}
|
||||
}
|
||||
+34
-28
@@ -2,14 +2,15 @@ package com.rtbishop.look4sat.core.domain.aprs
|
||||
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.round
|
||||
import com.rtbishop.look4sat.core.domain.utility.formatString
|
||||
|
||||
/**
|
||||
* APRS-IS 协议核心(纯 Kotlin,无 Android 依赖)。
|
||||
* 从 APRSdroid 1.6.3d 逆向移植:AprsPacket$.scala + ab0oo Position.java。
|
||||
* APRS-IS protocol core (pure Kotlin, no Android dependencies).
|
||||
* Reverse-ported from APRSdroid 1.6.3d: AprsPacket$.scala + ab0oo Position.java.
|
||||
*/
|
||||
object AprsPacket {
|
||||
|
||||
/** APRS-IS passcode 算法(标准):0x73E2 初值,呼号大写+\0,每对字符 XOR */
|
||||
/** APRS-IS passcode algorithm (standard): 0x73E2 seed, uppercase callsign + \0, XOR each char pair */
|
||||
fun passcode(callsign: String): Int {
|
||||
val s = callsign.split("-")[0].uppercase() + "\u0000"
|
||||
var hash = 29666 // 0x73E2
|
||||
@@ -21,40 +22,45 @@ object AprsPacket {
|
||||
return hash and 0x7FFF
|
||||
}
|
||||
|
||||
/** 登录行:user CALL-SSID pass XXXX vers XXXX */
|
||||
fun formatLogin(callsign: String, ssid: String, passcode: Int, version: String): String {
|
||||
val callSsid = formatCallSsid(callsign, ssid)
|
||||
return "user $callSsid pass $passcode vers $version"
|
||||
}
|
||||
|
||||
/** 呼号-SSID 拼接(BG7NTA + 5 → BG7NTA-5) */
|
||||
/** Callsign-SSID join (BG7NTA + 5 -> BG7NTA-5) */
|
||||
fun formatCallSsid(callsign: String, ssid: String): String {
|
||||
if (ssid.isNullOrEmpty()) return callsign
|
||||
return "$callsign-$ssid"
|
||||
}
|
||||
|
||||
/** 可选距离过滤: filter r/lat/lon/dist */
|
||||
/** Optional distance filter: filter r/lat/lon/dist */
|
||||
fun formatRangeFilter(latitude: Double, longitude: Double, distKm: Int): String {
|
||||
return String.format("r/%.3f/%.3f/%d", latitude, longitude, distKm)
|
||||
return formatString("r/%.3f/%.3f/%d", latitude, longitude, distKm)
|
||||
}
|
||||
|
||||
/** 高度扩展 /A=00000(英尺) */
|
||||
/**
|
||||
* Altitude extension /A=000000 (feet). The field is a fixed six-digit
|
||||
* decimal, so a negative altitude (below sea level, or a bad GPS fix) must
|
||||
* be clamped: "%06d" of -164 yields "/A=-00164", which is not a valid
|
||||
* extension and corrupts the rest of the comment field.
|
||||
*/
|
||||
fun formatAltitude(altitudeMeters: Double?): String {
|
||||
if (altitudeMeters == null) return ""
|
||||
return String.format("/A=%06d", (altitudeMeters * 3.2808399).toInt())
|
||||
val feet = (altitudeMeters * 3.2808399).toInt().coerceIn(0, 999999)
|
||||
return formatString("/A=%06d", feet)
|
||||
}
|
||||
|
||||
/** 航速航向扩展(节/度) */
|
||||
/**
|
||||
* Speed/course extension /CCC/SSS (degrees/knots). Course wraps into
|
||||
* 0..359 and speed is clamped to three digits, because "%03d" of an
|
||||
* out-of-range value widens the field and breaks the fixed-width format.
|
||||
*/
|
||||
fun formatCourseSpeed(speedMps: Double?, bearing: Float?): String {
|
||||
if (speedMps == null || bearing == null) return ""
|
||||
val knots = (speedMps * 1.94384449).toInt()
|
||||
return String.format("/%03d/%03d", bearing.toInt(), knots)
|
||||
val knots = (speedMps * 1.94384449).toInt().coerceIn(0, 999)
|
||||
val course = ((bearing.toInt() % 360) + 360) % 360
|
||||
return formatString("/%03d/%03d", course, knots)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* APRS 位置编码(ab0oo Position.java 逆向移植)。
|
||||
* 非压缩:DDMM.MMN/DDDMM.MME;压缩:base91。
|
||||
* APRS position encoding (reverse-ported from ab0oo Position.java).
|
||||
* Uncompressed: DDMM.MMN/DDDMM.MME; compressed: base91.
|
||||
*/
|
||||
class AprsPosition(
|
||||
val latitude: Double,
|
||||
@@ -64,14 +70,14 @@ class AprsPosition(
|
||||
val positionAmbiguity: Int = 0
|
||||
) {
|
||||
|
||||
/** 非压缩格式(APRS-IS 默认上报格式) */
|
||||
/** Uncompressed format (APRS-IS default reporting format) */
|
||||
fun toUncompressedString(): String {
|
||||
val lat = getDMS(latitude, true)
|
||||
val lon = getDMS(longitude, false)
|
||||
return "$lat$symbolTable$lon$symbolCode"
|
||||
}
|
||||
|
||||
/** 压缩格式(base91,标准 APRS 算法) */
|
||||
/** Compressed format (base91, standard APRS algorithm) */
|
||||
fun toCompressedString(): String {
|
||||
val jRound = round((90.0 - latitude) * 380926.0).toLong()
|
||||
val j = jRound / 753571 + 33
|
||||
@@ -91,7 +97,7 @@ class AprsPosition(
|
||||
((j8 % 91).toInt() + 33).toChar() + symbolCode
|
||||
}
|
||||
|
||||
/** 单轴 DMS 编码(百分位) */
|
||||
/** Single-axis DMS encoding (hundredths) */
|
||||
private fun getDMS(value: Double, isLat: Boolean): String {
|
||||
var iRound = round(value * 6000.0).toInt()
|
||||
if (iRound < 0) iRound = -iRound
|
||||
@@ -100,17 +106,17 @@ class AprsPosition(
|
||||
val hundredths = iRound % 100
|
||||
val frac = when (positionAmbiguity) {
|
||||
1 -> " . "
|
||||
2 -> String.format("%d . ", minutes / 10)
|
||||
3 -> String.format("%02d. ", minutes)
|
||||
4 -> String.format("%02d.%d ", minutes, hundredths / 10)
|
||||
else -> String.format("%02d.%02d", minutes, hundredths)
|
||||
2 -> formatString("%d . ", minutes / 10)
|
||||
3 -> formatString("%02d. ", minutes)
|
||||
4 -> formatString("%02d.%d ", minutes, hundredths / 10)
|
||||
else -> formatString("%02d.%02d", minutes, hundredths)
|
||||
}
|
||||
return if (isLat) {
|
||||
val ns = if (value >= 0) 'N' else 'S'
|
||||
String.format("%02d%s%c", degrees, frac, ns)
|
||||
formatString("%02d%s%c", degrees, frac, ns)
|
||||
} else {
|
||||
val ew = if (value >= 0) 'E' else 'W'
|
||||
String.format("%03d%s%c", degrees, frac, ew)
|
||||
formatString("%03d%s%c", degrees, frac, ew)
|
||||
}
|
||||
}
|
||||
}
|
||||
+101
@@ -0,0 +1,101 @@
|
||||
/*
|
||||
* 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.aprs
|
||||
|
||||
/**
|
||||
* Decides what passcode to present to APRS-IS, and whether the operator's entry is usable.
|
||||
*
|
||||
* The app must not derive a transmit passcode for the operator. APRS-IS states that supplying
|
||||
* the correct passcode to a user is the software author's responsibility, and the passcode
|
||||
* exists as a licence check - deriving it in-app and shipping the algorithm defeats the point.
|
||||
* APRSdroid has the same algorithm in the same file and deliberately does not use it for this
|
||||
* reason, validating the operator's entry instead and linking out to request one.
|
||||
*
|
||||
* So this validates. [AprsPacket.passcode] stays, because checking an entry means recomputing
|
||||
* the expected value, but nothing here substitutes a derived code for a missing one.
|
||||
*/
|
||||
object AprsPasscode {
|
||||
|
||||
/** Value that asks APRS-IS for a receive-only connection. Always legitimate. */
|
||||
const val RECEIVE_ONLY = -1
|
||||
|
||||
/** What the operator's passcode entry amounts to. */
|
||||
sealed interface Entry {
|
||||
|
||||
/** A passcode that matches the callsign. Reports will be forwarded. */
|
||||
data class Transmit(val passcode: Int) : Entry
|
||||
|
||||
/**
|
||||
* An explicit -1, or a blank entry.
|
||||
*
|
||||
* A blank entry lands here rather than being filled in with a derived code: connecting
|
||||
* receive-only is honest about what an operator without a passcode can do, where a
|
||||
* derived code silently claims a licence check that was never performed.
|
||||
*/
|
||||
data object ReceiveOnly : Entry
|
||||
|
||||
/** Something was typed but it is not this callsign's passcode. */
|
||||
data class Mismatch(val expectedFor: String) : Entry
|
||||
|
||||
/** Something was typed that is not a number at all. */
|
||||
data object NotANumber : Entry
|
||||
}
|
||||
|
||||
/**
|
||||
* Classify what the operator typed.
|
||||
*
|
||||
* A mismatch is reported rather than corrected, so the UI can refuse to save and say why.
|
||||
* Silently swapping in a derived code is how an operator ends up believing they are
|
||||
* transmitting under a passcode they never obtained.
|
||||
*/
|
||||
fun classify(callsign: String, entry: String): Entry {
|
||||
val trimmed = entry.trim()
|
||||
if (trimmed.isEmpty()) return Entry.ReceiveOnly
|
||||
val value = trimmed.toIntOrNull() ?: return Entry.NotANumber
|
||||
// Checked before the callsign comparison: -1 is the documented receive-only value and
|
||||
// is never anyone's passcode, so comparing it would report a deliberate choice as a typo.
|
||||
if (value == RECEIVE_ONLY) return Entry.ReceiveOnly
|
||||
val call = callsign.trim()
|
||||
if (call.isEmpty()) return Entry.Mismatch("")
|
||||
return if (value == AprsPacket.passcode(call)) {
|
||||
Entry.Transmit(value)
|
||||
} else {
|
||||
Entry.Mismatch(call.uppercase())
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The number to send in the login line for this entry.
|
||||
*
|
||||
* Anything not usable becomes [RECEIVE_ONLY]: the connection still works, the operator is
|
||||
* told separately that their reports are not being forwarded, and no packet goes out under
|
||||
* a passcode the app invented. The previous code sent a derived transmit passcode here,
|
||||
* and `takeIf { it >= 0 }` additionally made an explicit -1 impossible to use - which also
|
||||
* blocked the one safe way to test a setup, since a receive-only login is how you confirm
|
||||
* the connection works without putting anything on the network.
|
||||
*/
|
||||
fun loginValue(callsign: String, entry: String): Int =
|
||||
when (val classified = classify(callsign, entry)) {
|
||||
is Entry.Transmit -> classified.passcode
|
||||
else -> RECEIVE_ONLY
|
||||
}
|
||||
|
||||
/** True when this entry lets the operator's reports reach the network. */
|
||||
fun canTransmit(callsign: String, entry: String): Boolean =
|
||||
classify(callsign, entry) is Entry.Transmit
|
||||
}
|
||||
+81
@@ -0,0 +1,81 @@
|
||||
/*
|
||||
* 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.aprs
|
||||
|
||||
/**
|
||||
* One APRS symbol an operator might plausibly want.
|
||||
*
|
||||
* [table] and [code] are the two characters that go into the packet. [descriptionKey] names a
|
||||
* string resource rather than holding text, because core:domain has no access to resources and
|
||||
* hardcoding English here would put wording outside the locale files.
|
||||
*/
|
||||
data class AprsSymbol(val table: Char, val code: Char, val descriptionKey: String)
|
||||
|
||||
/**
|
||||
* A short list of symbols worth offering, instead of two free-text fields.
|
||||
*
|
||||
* The fields accepted anything and used only the first character, so typing "satellite" into the
|
||||
* table field persisted the whole word and beaconed as `/` - the field lied about what it did.
|
||||
* aprs.fi's own troubleshooting guidance puts transmit-side symbol misconfiguration among the first
|
||||
* things to check when a station does not appear as expected.
|
||||
*
|
||||
* The strongest single argument for a list: `\S` is Satellite/Pacsat but `/S` is SHUTTLE. One
|
||||
* keystroke apart, and both look correct to someone typing from memory.
|
||||
*
|
||||
* Renderings are from aprs.org/symbols/symbolsX.txt (WB4APR, 25 Nov 2015). The list is deliberately
|
||||
* short - it covers fixed, portable, vehicle and satellite postures, not all 400-odd symbols.
|
||||
*/
|
||||
object AprsSymbols {
|
||||
|
||||
/** Fixed home station. Correct for most users, and wrong in an obvious way for the rest. */
|
||||
val HOUSE = AprsSymbol('/', '-', "aprs_symbol_house")
|
||||
|
||||
val curated = listOf(
|
||||
HOUSE,
|
||||
AprsSymbol('\\', '-', "aprs_symbol_house_alt"),
|
||||
AprsSymbol('/', '[', "aprs_symbol_person"),
|
||||
AprsSymbol('/', 'y', "aprs_symbol_yagi"),
|
||||
// Alternate table. /S is SHUTTLE, which is not what anyone means here.
|
||||
AprsSymbol('\\', 'S', "aprs_symbol_satellite"),
|
||||
AprsSymbol('/', ';', "aprs_symbol_portable"),
|
||||
AprsSymbol('/', '$', "aprs_symbol_phone"),
|
||||
AprsSymbol('/', 'I', "aprs_symbol_tcpip"),
|
||||
AprsSymbol('\\', 'K', "aprs_symbol_ht"),
|
||||
AprsSymbol('/', '>', "aprs_symbol_car"),
|
||||
AprsSymbol('/', 'k', "aprs_symbol_truck"),
|
||||
AprsSymbol('/', 'v', "aprs_symbol_van"),
|
||||
AprsSymbol('/', 'R', "aprs_symbol_rv"),
|
||||
AprsSymbol('/', 'b', "aprs_symbol_bike")
|
||||
)
|
||||
|
||||
/**
|
||||
* Find the curated entry matching a stored pair, or null when it is not on the list.
|
||||
*
|
||||
* Null matters: an operator may have set a symbol this list does not offer, and the picker must
|
||||
* show it as-is rather than silently substituting the nearest entry.
|
||||
*/
|
||||
fun find(table: Char, code: Char): AprsSymbol? =
|
||||
curated.firstOrNull { it.table == table && it.code == code }
|
||||
|
||||
/** Same, from whatever strings the settings screen holds. Blank means the shipped default. */
|
||||
fun find(table: String, code: String): AprsSymbol? {
|
||||
val t = table.firstOrNull() ?: HOUSE.table
|
||||
val c = code.firstOrNull() ?: HOUSE.code
|
||||
return find(t, c)
|
||||
}
|
||||
}
|
||||
+195
@@ -0,0 +1,195 @@
|
||||
/*
|
||||
* 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
|
||||
|
||||
/**
|
||||
* Low-pass filter applied before decimating to the model's sample rate.
|
||||
*
|
||||
* [CwDeepSpectrogram.resampleLinear] drops from the capture rate to 3200 Hz by
|
||||
* interpolating between samples, with nothing removing the content above the new
|
||||
* Nyquist of 1600 Hz first. Everything higher folds back into the audible window,
|
||||
* which is not a subtle degradation - measured on 44100 Hz input a 3000 Hz tone
|
||||
* reappears at 200 Hz at 119 times the spectral mean, indistinguishable from a real
|
||||
* signal, and 1800 Hz lands on 1400 Hz. Worse for copy, the whole 1600-22050 Hz band
|
||||
* of hiss folds down on top of the signal and lifts the noise floor across the entire
|
||||
* display.
|
||||
*
|
||||
* The resampler itself is deliberately left alone: its comment notes it matches the
|
||||
* reference implementation the model was trained against, so changing its arithmetic
|
||||
* would move the spectrogram away from what DeepCW expects. Filtering first fixes the
|
||||
* aliasing without touching that contract.
|
||||
*
|
||||
* A windowed-sinc FIR rather than a biquad cascade: the transition band has to be
|
||||
* steep to keep 1600 Hz while rejecting 1800 Hz, and a linear-phase FIR does not
|
||||
* smear the keying envelope the way a high-order IIR would.
|
||||
*/
|
||||
object CwAntiAlias {
|
||||
|
||||
/**
|
||||
* Cut-off as a fraction of the target Nyquist.
|
||||
*
|
||||
* Below 1.0 so the transition band lands inside the discarded region rather than
|
||||
* straddling it. At 0.92 the response is flat to 1470 Hz, which still covers the
|
||||
* model's 1200 Hz window and the shifter's detection range with room to spare.
|
||||
*/
|
||||
private const val CUTOFF_FRACTION = 0.92
|
||||
|
||||
/**
|
||||
* Filter length. Odd so the group delay is a whole number of samples.
|
||||
*
|
||||
* 127 taps at 44100 Hz gives roughly a 700 Hz transition width - enough to put
|
||||
* 1800 Hz down by more than 40 dB while passing 1470 Hz unattenuated. Longer would
|
||||
* be sharper and slower; this runs on a phone during a pass.
|
||||
*/
|
||||
private const val TAPS = 127
|
||||
|
||||
/** Delay introduced by [TAPS], for callers that need to align another path. */
|
||||
const val GROUP_DELAY_SAMPLES = TAPS / 2
|
||||
|
||||
/**
|
||||
* Filter [audio] so that decimating to [targetRate] cannot alias.
|
||||
*
|
||||
* A no-op when [sourceRate] is at or below [targetRate], since there is nothing
|
||||
* above the target Nyquist to remove. Returns a new array; [audio] is unchanged.
|
||||
*/
|
||||
fun prepareForDecimation(audio: FloatArray, sourceRate: Int, targetRate: Int): FloatArray {
|
||||
if (audio.isEmpty() || sourceRate <= targetRate) return audio
|
||||
val cutoffHz = targetRate / 2.0 * CUTOFF_FRACTION
|
||||
return applyFir(audio, kernelFor(cutoffHz, sourceRate))
|
||||
}
|
||||
|
||||
/**
|
||||
* Windowed-sinc low-pass kernel, normalised to unity gain at DC.
|
||||
*
|
||||
* Blackman window: its sidelobes are around -58 dB against the Hamming window's
|
||||
* -41 dB, and sidelobe level is exactly what decides how much of the folded band
|
||||
* survives.
|
||||
*/
|
||||
private fun kernelFor(cutoffHz: Double, sampleRate: Int): FloatArray {
|
||||
val normalised = cutoffHz / sampleRate
|
||||
val half = TAPS / 2
|
||||
val raw = DoubleArray(TAPS) { i ->
|
||||
val n = i - half
|
||||
val sinc = if (n == 0) {
|
||||
2.0 * normalised
|
||||
} else {
|
||||
sin(2.0 * PI * normalised * n) / (PI * n)
|
||||
}
|
||||
val window = 0.42 -
|
||||
0.5 * cos(2.0 * PI * i / (TAPS - 1)) +
|
||||
0.08 * cos(4.0 * PI * i / (TAPS - 1))
|
||||
sinc * window
|
||||
}
|
||||
val sum = raw.sum()
|
||||
// Unity DC gain, so filtering does not change the level the model was trained on.
|
||||
return FloatArray(TAPS) { i -> (raw[i] / sum).toFloat() }
|
||||
}
|
||||
|
||||
/**
|
||||
* Convolve, compensating for the filter's own delay so the output lines up with
|
||||
* the input. Edge taps that fall outside the buffer see zeros, which costs the
|
||||
* first and last [GROUP_DELAY_SAMPLES] samples of an isolated buffer.
|
||||
*/
|
||||
private fun applyFir(audio: FloatArray, kernel: FloatArray): FloatArray {
|
||||
val out = FloatArray(audio.size)
|
||||
for (i in audio.indices) {
|
||||
var sum = 0f
|
||||
for (k in kernel.indices) {
|
||||
val j = i - k + GROUP_DELAY_SAMPLES
|
||||
if (j >= 0 && j < audio.size) sum += kernel[k] * audio[j]
|
||||
}
|
||||
out[i] = sum
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* Chunk-by-chunk filter that carries the state [prepareForDecimation] cannot.
|
||||
*
|
||||
* Two things are needed for concatenated chunks to match a whole-buffer filter.
|
||||
* History is the obvious one: the FIR spans [TAPS] samples, so a chunk's first
|
||||
* outputs need the tail of the one before it.
|
||||
*
|
||||
* The second is less obvious and was measured rather than reasoned about. A
|
||||
* linear-phase FIR is centred, so output sample `i` needs input up to
|
||||
* `i + GROUP_DELAY_SAMPLES` - samples that have not been captured yet when the
|
||||
* chunk arrives. A first attempt let those taps fall off the end of the buffer and
|
||||
* read as zeros; against a whole-buffer filter that diverged by 0.134 across the
|
||||
* last 44 samples of every chunk, which is a click at each boundary rather than a
|
||||
* rounding difference.
|
||||
*
|
||||
* So output is held back by [GROUP_DELAY_SAMPLES] samples: each call emits the
|
||||
* samples whose lookahead has now arrived, and keeps the rest until the next chunk
|
||||
* completes them. The cost is a fixed 63-sample delay, about 1.4 ms at 44100 Hz,
|
||||
* against a 20 WPM dot of roughly 60 ms.
|
||||
*
|
||||
* Not thread-safe: driven from the single capture coroutine.
|
||||
*/
|
||||
class Streaming(sourceRate: Int, targetRate: Int) {
|
||||
|
||||
private val kernel: FloatArray? =
|
||||
if (sourceRate <= targetRate) {
|
||||
null
|
||||
} else {
|
||||
kernelFor(targetRate / 2.0 * CUTOFF_FRACTION, sourceRate)
|
||||
}
|
||||
|
||||
/** Samples not yet emitted: filter history plus the lookahead still owed. */
|
||||
private var pending = FloatArray(0)
|
||||
|
||||
/** Filter one chunk, continuing from the previous call. */
|
||||
fun process(chunk: FloatArray): FloatArray {
|
||||
val k = kernel ?: return chunk
|
||||
if (chunk.isEmpty()) return chunk
|
||||
|
||||
val combined = FloatArray(pending.size + chunk.size)
|
||||
pending.copyInto(combined)
|
||||
chunk.copyInto(combined, pending.size)
|
||||
|
||||
// Only samples with a full window on both sides are ready. Everything from
|
||||
// here on still needs input that has not arrived.
|
||||
val ready = combined.size - TAPS + 1
|
||||
if (ready <= 0) {
|
||||
pending = combined
|
||||
return FloatArray(0)
|
||||
}
|
||||
|
||||
val out = FloatArray(ready)
|
||||
for (i in 0 until ready) {
|
||||
var sum = 0f
|
||||
for (t in k.indices) {
|
||||
sum += k[t] * combined[i + TAPS - 1 - t]
|
||||
}
|
||||
out[i] = sum
|
||||
}
|
||||
|
||||
// Carry the tail that the next chunk will complete.
|
||||
pending = combined.copyOfRange(ready, combined.size)
|
||||
return out
|
||||
}
|
||||
|
||||
/** Clear pending state, e.g. after a decoder reset. */
|
||||
fun reset() {
|
||||
pending = FloatArray(0)
|
||||
}
|
||||
}
|
||||
}
|
||||
+61
@@ -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
|
||||
|
||||
/**
|
||||
* Turns the model's `log_probs` output into text.
|
||||
*
|
||||
* Mirrors the reference `greedy_ctc_decode`: take the best class per frame,
|
||||
* drop blanks, and collapse runs of the same label. A blank between two
|
||||
* identical labels is what keeps a genuine double letter (for example the
|
||||
* two N's in "5NN") from collapsing into one.
|
||||
*/
|
||||
object CwCtcDecoder {
|
||||
|
||||
/**
|
||||
* @param logProbs `[batch, time, class]`; only batch 0 is read.
|
||||
* @param chars class index to symbol, excluding the blank.
|
||||
* @param blankIndex the CTC blank class (41 for this model).
|
||||
*/
|
||||
fun greedy(
|
||||
logProbs: Array<Array<FloatArray>>,
|
||||
chars: List<String>,
|
||||
blankIndex: Int
|
||||
): String {
|
||||
if (logProbs.isEmpty()) return ""
|
||||
val frames = logProbs[0]
|
||||
val builder = StringBuilder()
|
||||
var previous = -1
|
||||
|
||||
for (frame in frames) {
|
||||
var best = 0
|
||||
for (i in 1 until frame.size) {
|
||||
if (frame[i] > frame[best]) best = i
|
||||
}
|
||||
if (best == blankIndex) {
|
||||
previous = -1
|
||||
continue
|
||||
}
|
||||
if (best != previous && best < chars.size) {
|
||||
builder.append(chars[best])
|
||||
}
|
||||
previous = best
|
||||
}
|
||||
return builder.toString()
|
||||
}
|
||||
}
|
||||
+140
@@ -0,0 +1,140 @@
|
||||
/*
|
||||
* 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
|
||||
|
||||
/**
|
||||
* Bounded rolling audio buffer that drives periodic full re-decodes.
|
||||
*
|
||||
* DeepCW is a whole-segment CTC model. It rewrites earlier output as more
|
||||
* context arrives — measured on one clip, 6 of 11 progressively longer reads
|
||||
* revised the prefix (`BM` -> `BG7` -> `BG7NTI` -> `BG7NTA`). Appending only
|
||||
* the newest fragment therefore leaves those intermediate guesses on screen
|
||||
* forever; measured character error rate for sliding-window stitching ranged
|
||||
* from 67% to 294%, against 0% for decoding the whole segment at once.
|
||||
*
|
||||
* So we keep a fixed window, re-run the model over all of it every
|
||||
* [redecodeIntervalMs], and replace the displayed text outright.
|
||||
*
|
||||
* Defaults come from measurement: 20 s is the smallest window that reaches
|
||||
* 0.0% CER (16 s still errs at 5.9%), while inference cost grows
|
||||
* super-linearly — 60 s of audio needs roughly 13x longer to decode than
|
||||
* 20 s does, leaving too little real-time headroom.
|
||||
*/
|
||||
class CwDeepBuffer(
|
||||
sampleRate: Int = CwDeepSpectrogram.SAMPLE_RATE,
|
||||
maxSeconds: Double = DEFAULT_MAX_SECONDS,
|
||||
private val redecodeIntervalMs: Int = DEFAULT_REDECODE_INTERVAL_MS
|
||||
) {
|
||||
companion object {
|
||||
const val DEFAULT_MAX_SECONDS = 20.0
|
||||
const val DEFAULT_REDECODE_INTERVAL_MS = 1500
|
||||
}
|
||||
|
||||
/** Maximum number of samples retained. */
|
||||
val capacity: Int = (sampleRate * maxSeconds).toInt()
|
||||
|
||||
private val samplesPerInterval: Int = sampleRate * redecodeIntervalMs / 1000
|
||||
private val ring = FloatArray(capacity)
|
||||
private var writeIndex = 0
|
||||
private var filled = 0
|
||||
private var sinceLastRedecode = 0
|
||||
|
||||
/**
|
||||
* Samples evicted from the ring once it is full. They are the audio that
|
||||
* has scrolled out of the 20 s window, and are handed off (via
|
||||
* [drainOverflow]) so the decoder can archive them into permanent history
|
||||
* instead of silently dropping the corresponding text. Pre-allocated to
|
||||
* [capacity]: overflow never exceeds one window before it is drained.
|
||||
*/
|
||||
private val overflow = FloatArray(capacity)
|
||||
private var overflowSize = 0
|
||||
|
||||
/** Samples currently buffered, never above [capacity]. */
|
||||
val size: Int get() = filled
|
||||
|
||||
/** Samples currently held in the overflow (awaiting archival). */
|
||||
val overflowCount: Int get() = overflowSize
|
||||
|
||||
/** True once there is enough audio for the spectrogram to yield a frame. */
|
||||
val hasEnoughAudio: Boolean get() = filled >= CwDeepSpectrogram.FFT_LENGTH
|
||||
|
||||
/**
|
||||
* Append captured audio, overwriting the oldest samples when full.
|
||||
*
|
||||
* @return true when [redecodeIntervalMs] of audio has accumulated since
|
||||
* the last time this returned true, meaning the caller should re-decode.
|
||||
*/
|
||||
fun append(chunk: FloatArray): Boolean {
|
||||
if (chunk.isNotEmpty()) {
|
||||
// A chunk longer than the window can only contribute its tail.
|
||||
val start = maxOf(0, chunk.size - capacity)
|
||||
for (i in start until chunk.size) {
|
||||
if (filled == capacity) {
|
||||
// The slot we are about to overwrite holds the oldest
|
||||
// sample — move it to the overflow for archival.
|
||||
overflow[overflowSize++] = ring[writeIndex]
|
||||
}
|
||||
ring[writeIndex] = chunk[i]
|
||||
writeIndex = (writeIndex + 1) % capacity
|
||||
if (filled < capacity) filled++
|
||||
}
|
||||
}
|
||||
|
||||
sinceLastRedecode += chunk.size
|
||||
if (sinceLastRedecode >= samplesPerInterval) {
|
||||
sinceLastRedecode -= samplesPerInterval
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/** Buffered audio in chronological order, as a copy safe to hand off. */
|
||||
fun snapshot(): FloatArray {
|
||||
val out = FloatArray(filled)
|
||||
if (filled == 0) return out
|
||||
val start = (writeIndex - filled + capacity) % capacity
|
||||
val firstRun = minOf(filled, capacity - start)
|
||||
ring.copyInto(out, 0, start, start + firstRun)
|
||||
if (firstRun < filled) {
|
||||
ring.copyInto(out, firstRun, 0, filled - firstRun)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the evicted samples (chronological order) and clear the overflow.
|
||||
* Safe to call every append; returns an empty array when nothing has been
|
||||
* evicted yet.
|
||||
*/
|
||||
fun drainOverflow(): FloatArray {
|
||||
if (overflowSize == 0) return FloatArray(0)
|
||||
val out = overflow.copyOf(overflowSize)
|
||||
overflowSize = 0
|
||||
return out
|
||||
}
|
||||
|
||||
/** Drop all audio (including pending overflow) and restart the interval. */
|
||||
fun reset() {
|
||||
writeIndex = 0
|
||||
filled = 0
|
||||
sinceLastRedecode = 0
|
||||
overflowSize = 0
|
||||
ring.fill(0f)
|
||||
overflow.fill(0f)
|
||||
}
|
||||
}
|
||||
+242
@@ -0,0 +1,242 @@
|
||||
/*
|
||||
* 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.ceil
|
||||
import kotlin.math.cos
|
||||
import kotlin.math.floor
|
||||
import kotlin.math.ln1p
|
||||
import kotlin.math.roundToInt
|
||||
import kotlin.math.sqrt
|
||||
|
||||
/**
|
||||
* Audio front-end for the DeepCW model: turns PCM samples into the
|
||||
* `[time, frequency]` log-magnitude spectrogram the network expects.
|
||||
*
|
||||
* Mirrors the upstream Python reference (deepcw-engine
|
||||
* `examples/python/decode_morse.py`) step for step:
|
||||
*
|
||||
* resample -> 3200 Hz, reflect-pad by fft/2, periodic Hann window of 256,
|
||||
* real FFT, keep bins [32, 97) i.e. 400-1200 Hz, then log1p.
|
||||
*
|
||||
* The model's fixed 400-1200 Hz window means pitch detection is built in —
|
||||
* no spectral peak tracking or squelch gating is needed on our side.
|
||||
*/
|
||||
object CwDeepSpectrogram {
|
||||
|
||||
/** Model input sample rate, from `model.onnx.json`. */
|
||||
const val SAMPLE_RATE = 3200
|
||||
|
||||
/** FFT window length in samples. */
|
||||
const val FFT_LENGTH = 256
|
||||
|
||||
/** Hop between consecutive frames; 48/3200 = 15.0 ms per frame. */
|
||||
const val HOP_LENGTH = 48
|
||||
|
||||
/**
|
||||
* Lower edge of the model's analysis window. Public so [CwToneShifter] can
|
||||
* decide whether a detected tone falls outside it; the value is fixed by the
|
||||
* trained model and must not be changed without retraining.
|
||||
*/
|
||||
const val MIN_FREQ_HZ = 400.0
|
||||
|
||||
/** Upper edge of the model's analysis window; see [MIN_FREQ_HZ]. */
|
||||
const val MAX_FREQ_HZ = 1200.0
|
||||
|
||||
/** Number of frequency bins the model expects. */
|
||||
const val FREQUENCY_BINS = 65
|
||||
|
||||
/**
|
||||
* Widest span worth displaying: DC to Nyquist.
|
||||
*
|
||||
* The model reads [MIN_FREQ_HZ]..[MAX_FREQ_HZ], but a tone outside that range leaves
|
||||
* no trace inside it - measured on keyed audio, the brightest column in the narrow
|
||||
* view swings 1.01x between key-down and key-up, against 13.76x for a tone the model
|
||||
* can see. So the narrow view cannot even show that a signal exists, and the display
|
||||
* spans the whole band instead. Nothing above Nyquist can be shown at all: it aliases.
|
||||
*/
|
||||
const val DISPLAY_MIN_FREQ_HZ = 0.0
|
||||
|
||||
/** Upper end of the display span; see [DISPLAY_MIN_FREQ_HZ]. */
|
||||
const val DISPLAY_MAX_FREQ_HZ = SAMPLE_RATE / 2.0
|
||||
|
||||
/** Milliseconds of audio represented by one output frame. */
|
||||
const val MS_PER_FRAME = 1000.0 * HOP_LENGTH / SAMPLE_RATE
|
||||
|
||||
private val hannWindow: FloatArray = FloatArray(FFT_LENGTH) { i ->
|
||||
// numpy: np.hanning(N + 1)[:-1] — the periodic (not symmetric) variant.
|
||||
(0.5 - 0.5 * cos(2.0 * PI * i / FFT_LENGTH)).toFloat()
|
||||
}
|
||||
|
||||
/**
|
||||
* Inclusive-exclusive bin range covering [minHz, maxHz].
|
||||
* Returns `start to stop`, matching the reference's `frequency_bin_range`.
|
||||
*/
|
||||
fun frequencyBinRange(
|
||||
sampleRate: Int,
|
||||
fftLength: Int,
|
||||
minHz: Double,
|
||||
maxHz: Double
|
||||
): Pair<Int, Int> {
|
||||
val binHz = sampleRate.toDouble() / fftLength
|
||||
val start = ceil(minHz / binHz).toInt()
|
||||
val stop = floor(maxHz / binHz).toInt() + 1
|
||||
return start to stop
|
||||
}
|
||||
|
||||
/**
|
||||
* Linear-interpolation resampler. Deliberately dependency-light and
|
||||
* identical to the reference implementation so spectrograms match.
|
||||
*/
|
||||
fun resampleLinear(audio: FloatArray, sourceRate: Int, targetRate: Int): FloatArray {
|
||||
if (sourceRate == targetRate || audio.isEmpty()) return audio
|
||||
val targetLength = (audio.size.toDouble() * targetRate / sourceRate).roundToInt()
|
||||
val out = FloatArray(targetLength)
|
||||
val ratio = sourceRate.toDouble() / targetRate
|
||||
for (i in 0 until targetLength) {
|
||||
val position = i * ratio
|
||||
val left = floor(position).toInt()
|
||||
val right = minOf(left + 1, audio.size - 1)
|
||||
val fraction = (position - left).toFloat()
|
||||
out[i] = audio[left] * (1f - fraction) + audio[right] * fraction
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the log-magnitude spectrogram. Input must already be at
|
||||
* [SAMPLE_RATE]; use [resampleLinear] first when it is not.
|
||||
*
|
||||
* @return `[frames][FREQUENCY_BINS]` values, all non-negative.
|
||||
*/
|
||||
fun compute(
|
||||
audio: FloatArray,
|
||||
minHz: Double = MIN_FREQ_HZ,
|
||||
maxHz: Double = MAX_FREQ_HZ
|
||||
): Array<FloatArray> {
|
||||
require(audio.size >= FFT_LENGTH) {
|
||||
"audio is too short for fftLength=$FFT_LENGTH, got ${audio.size}"
|
||||
}
|
||||
|
||||
val (startBin, stopBin) = frequencyBinRange(SAMPLE_RATE, FFT_LENGTH, minHz, maxHz)
|
||||
val bins = stopBin - startBin
|
||||
require(bins > 0) { "empty bin range for $minHz..${maxHz}Hz" }
|
||||
// The model's range must yield exactly the bin count it was trained on. Written as
|
||||
// an implication rather than a disjunction of all three terms: `a != x || b != y ||
|
||||
// bins == n` is satisfied by any custom range regardless of the bin count, which
|
||||
// would leave the invariant unenforced for the caller most likely to break it.
|
||||
val isModelRange = minHz == MIN_FREQ_HZ && maxHz == MAX_FREQ_HZ
|
||||
require(!isModelRange || bins == FREQUENCY_BINS) {
|
||||
"expected $FREQUENCY_BINS bins for the model range, computed $bins"
|
||||
}
|
||||
// Nothing may run off the end of the FFT output: a real signal has FFT_LENGTH / 2
|
||||
// + 1 distinct bins, and asking beyond Nyquist would index past them.
|
||||
require(stopBin <= FFT_LENGTH / 2 + 1) {
|
||||
"maxHz ${maxHz}Hz is above Nyquist ${SAMPLE_RATE / 2}Hz"
|
||||
}
|
||||
|
||||
val padded = reflectPad(audio, FFT_LENGTH / 2)
|
||||
val frames = 1 + (padded.size - FFT_LENGTH) / HOP_LENGTH
|
||||
val result = Array(frames) { FloatArray(bins) }
|
||||
|
||||
val real = FloatArray(FFT_LENGTH)
|
||||
val imag = FloatArray(FFT_LENGTH)
|
||||
for (frame in 0 until frames) {
|
||||
val offset = frame * HOP_LENGTH
|
||||
for (i in 0 until FFT_LENGTH) {
|
||||
real[i] = padded[offset + i] * hannWindow[i]
|
||||
imag[i] = 0f
|
||||
}
|
||||
fftInPlace(real, imag)
|
||||
val row = result[frame]
|
||||
for (bin in startBin until stopBin) {
|
||||
val magnitude = sqrt(real[bin] * real[bin] + imag[bin] * imag[bin])
|
||||
row[bin - startBin] = ln1p(magnitude.toDouble()).toFloat()
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* numpy `mode="reflect"`: mirrors around the edge samples without
|
||||
* repeating them, so [1,2,3] padded by 2 becomes [3,2,1,2,3,2,1].
|
||||
*/
|
||||
private fun reflectPad(audio: FloatArray, pad: Int): FloatArray {
|
||||
if (pad == 0) return audio
|
||||
val out = FloatArray(audio.size + 2 * pad)
|
||||
for (i in 0 until pad) out[i] = audio[pad - i]
|
||||
audio.copyInto(out, pad)
|
||||
val last = audio.size - 1
|
||||
for (i in 0 until pad) out[pad + audio.size + i] = audio[last - 1 - i]
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* Iterative radix-2 Cooley-Tukey FFT. [FFT_LENGTH] is a power of two, so
|
||||
* no padding case is needed. Only the first half of the output is read by
|
||||
* [compute], which is the real-input equivalent of numpy's `rfft`.
|
||||
*/
|
||||
private fun fftInPlace(real: FloatArray, imag: FloatArray) {
|
||||
val n = real.size
|
||||
|
||||
// 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 or bit
|
||||
if (i < j) {
|
||||
var tmp = real[i]; real[i] = real[j]; real[j] = tmp
|
||||
tmp = imag[i]; imag[i] = imag[j]; imag[j] = tmp
|
||||
}
|
||||
}
|
||||
|
||||
var length = 2
|
||||
while (length <= n) {
|
||||
val angle = -2.0 * PI / length
|
||||
val wReal = cos(angle).toFloat()
|
||||
val wImag = kotlin.math.sin(angle).toFloat()
|
||||
var i = 0
|
||||
while (i < n) {
|
||||
var curReal = 1f
|
||||
var curImag = 0f
|
||||
for (k in 0 until length / 2) {
|
||||
val evenReal = real[i + k]
|
||||
val evenImag = imag[i + k]
|
||||
val oddReal = real[i + k + length / 2]
|
||||
val oddImag = imag[i + k + length / 2]
|
||||
val mulReal = oddReal * curReal - oddImag * curImag
|
||||
val mulImag = oddReal * curImag + oddImag * curReal
|
||||
real[i + k] = evenReal + mulReal
|
||||
imag[i + k] = evenImag + mulImag
|
||||
real[i + k + length / 2] = evenReal - mulReal
|
||||
imag[i + k + length / 2] = evenImag - mulImag
|
||||
val nextReal = curReal * wReal - curImag * wImag
|
||||
curImag = curReal * wImag + curImag * wReal
|
||||
curReal = nextReal
|
||||
}
|
||||
i += length
|
||||
}
|
||||
length = length shl 1
|
||||
}
|
||||
}
|
||||
}
|
||||
+88
@@ -0,0 +1,88 @@
|
||||
/*
|
||||
* 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
|
||||
|
||||
/**
|
||||
* Pools capture chunks until enough audio is available for tone detection.
|
||||
*
|
||||
* [CwToneShifter.detectToneHz] scans bin by bin, so it needs a few hundred
|
||||
* milliseconds to resolve a pitch. A capture chunk is only 320 samples once
|
||||
* resampled to [CwDeepSpectrogram.SAMPLE_RATE], hence the pooling: without it a
|
||||
* per-chunk size check can never be satisfied and detection silently never runs.
|
||||
*
|
||||
* A ring buffer rather than a sliding array. Detection is throttled to a couple of
|
||||
* seconds while the pool fills in a few hundred milliseconds, so most chunks arrive
|
||||
* at a full buffer; shifting the array down one slot per sample cost 320 copies of
|
||||
* 1280 floats per chunk, measured at 24320 whole-array moves per 10 s of audio on
|
||||
* the capture thread. Writing to a ring index is O(1).
|
||||
*
|
||||
* Not thread-safe: the decoder drives it from a single capture coroutine.
|
||||
*
|
||||
* @param capacity samples retained; also the size [drain] returns once full.
|
||||
*/
|
||||
class CwDetectionPool(val capacity: Int) {
|
||||
|
||||
init {
|
||||
require(capacity > 0) { "capacity must be positive, was $capacity" }
|
||||
}
|
||||
|
||||
private val samples = FloatArray(capacity)
|
||||
private var writeIndex = 0
|
||||
|
||||
/** Samples currently pooled, never above [capacity]. */
|
||||
var size: Int = 0
|
||||
private set
|
||||
|
||||
/** True once [capacity] samples are pooled and detection can run. */
|
||||
val isReady: Boolean get() = size >= capacity
|
||||
|
||||
/** Add a chunk, overwriting the oldest samples once full. */
|
||||
fun add(chunk: FloatArray) {
|
||||
if (chunk.isEmpty()) return
|
||||
// A chunk longer than the pool can only contribute its tail.
|
||||
val start = maxOf(0, chunk.size - capacity)
|
||||
for (i in start until chunk.size) {
|
||||
samples[writeIndex] = chunk[i]
|
||||
writeIndex = (writeIndex + 1) % capacity
|
||||
if (size < capacity) size++
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hand over the pooled audio in chronological order and empty the pool.
|
||||
*
|
||||
* Oldest sample first: the detector measures a waveform, so returning the ring in
|
||||
* storage order would splice it at the wrap point and corrupt every estimate.
|
||||
*/
|
||||
fun drain(): FloatArray {
|
||||
val out = FloatArray(size)
|
||||
// Once full the oldest sample sits at the write cursor; before that at index 0.
|
||||
val oldest = if (size == capacity) writeIndex else 0
|
||||
for (i in 0 until size) {
|
||||
out[i] = samples[(oldest + i) % capacity]
|
||||
}
|
||||
clear()
|
||||
return out
|
||||
}
|
||||
|
||||
/** Discard everything pooled so far. */
|
||||
fun clear() {
|
||||
size = 0
|
||||
writeIndex = 0
|
||||
}
|
||||
}
|
||||
+115
@@ -0,0 +1,115 @@
|
||||
/*
|
||||
* 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.abs
|
||||
|
||||
/**
|
||||
* Decides what shift to apply from a sequence of tone estimates.
|
||||
*
|
||||
* Kept out of the decoder so the rule can be exercised directly. The decoder needs an
|
||||
* Android Context and a loaded ONNX session, so a rule living inside it can only be
|
||||
* tested by restating it - and a restated rule cannot fail when the real one is wrong.
|
||||
* Mutation testing proved that: four defects injected into an in-decoder version of this
|
||||
* logic left the whole suite green.
|
||||
*
|
||||
* @param hysteresisHz how far the tone must move before the shift is revised.
|
||||
*/
|
||||
class CwShiftDecider(private val hysteresisHz: Float = DEFAULT_HYSTERESIS_HZ) {
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* Default margin before re-shifting, in Hz.
|
||||
*
|
||||
* Detection resolves to 12.5 Hz and a real tone wanders, so a couple of scan bins
|
||||
* of jitter must not count as a retune: revising the shift costs the whole 20 s
|
||||
* decode window, which is worth far more than perfect centring.
|
||||
*/
|
||||
const val DEFAULT_HYSTERESIS_HZ = 40f
|
||||
}
|
||||
|
||||
/** Shift currently applied to incoming audio; 0 when the tone needs no move. */
|
||||
var shiftHz: Float = 0f
|
||||
private set
|
||||
|
||||
/**
|
||||
* Tone that produced [shiftHz]. Hysteresis compares against this rather than against
|
||||
* the previous shift, because a shift of 0 is a real state: at the window edge one
|
||||
* 12.5 Hz estimate hop flips between "inside" (shift 0) and "outside" (a large
|
||||
* shift), and a shift-space comparison lapses exactly where the jump is largest.
|
||||
*/
|
||||
var anchorToneHz: Float? = null
|
||||
private set
|
||||
|
||||
/** What [accept] decided, for logging. */
|
||||
enum class Outcome {
|
||||
/** No tone in the window; the existing shift was retained. */
|
||||
NO_TONE,
|
||||
|
||||
/** The tone moved less than the margin; the existing shift was retained. */
|
||||
WITHIN_HYSTERESIS,
|
||||
|
||||
/** The tone is inside the model window, so no shift is needed. */
|
||||
NO_SHIFT_NEEDED,
|
||||
|
||||
/** The shift was updated to move an out-of-window tone into range. */
|
||||
SHIFTED
|
||||
}
|
||||
|
||||
/** Result of feeding one detection to the decider. */
|
||||
data class Decision(
|
||||
val outcome: Outcome,
|
||||
/** Shift in force after the decision. */
|
||||
val shiftHz: Float,
|
||||
/** True when [shiftHz] differs from the value before this decision. */
|
||||
val changed: Boolean,
|
||||
/** Tone the decision was based on, null when none was detected. */
|
||||
val toneHz: Float?
|
||||
)
|
||||
|
||||
/**
|
||||
* Feed one tone analysis and get the shift to apply.
|
||||
*
|
||||
* Silence retains the current shift rather than clearing it: CW is keyed, so a
|
||||
* detection window landing in a gap carries no information about the pitch. Treating
|
||||
* it as an authoritative "no shift" collapsed established shifts - measured over
|
||||
* 180 s of keyed audio at 1400 Hz, 11 of 90 windows saw no tone, and each one left
|
||||
* the following audio unshifted and therefore invisible to the model.
|
||||
*/
|
||||
fun accept(analysis: CwToneShifter.Analysis): Decision {
|
||||
val previousShift = shiftHz
|
||||
val toneHz = analysis.toneHz
|
||||
?: return Decision(Outcome.NO_TONE, previousShift, changed = false, toneHz = null)
|
||||
|
||||
val anchor = anchorToneHz
|
||||
if (anchor != null && abs(toneHz - anchor) < hysteresisHz) {
|
||||
return Decision(Outcome.WITHIN_HYSTERESIS, previousShift, changed = false, toneHz = toneHz)
|
||||
}
|
||||
|
||||
shiftHz = analysis.shiftHz
|
||||
anchorToneHz = toneHz
|
||||
val outcome = if (analysis.needsShift) Outcome.SHIFTED else Outcome.NO_SHIFT_NEEDED
|
||||
return Decision(outcome, shiftHz, changed = shiftHz != previousShift, toneHz = toneHz)
|
||||
}
|
||||
|
||||
/** Forget the current shift and anchor, e.g. when the feature is toggled or reset. */
|
||||
fun reset() {
|
||||
shiftHz = 0f
|
||||
anchorToneHz = null
|
||||
}
|
||||
}
|
||||
+323
@@ -0,0 +1,323 @@
|
||||
/*
|
||||
* 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.hypot
|
||||
import kotlin.math.sin
|
||||
|
||||
/**
|
||||
* Moves an out-of-range CW tone into the model's analysis window.
|
||||
*
|
||||
* The DeepCW model only sees [CwDeepSpectrogram.MIN_FREQ_HZ]..[CwDeepSpectrogram.MAX_FREQ_HZ];
|
||||
* its input tensor width is fixed, so the window itself cannot be widened without
|
||||
* retraining. Instead a tone that sits outside the window is frequency-shifted to
|
||||
* [TARGET_HZ] before the spectrogram is built, which extends the usable pitch range
|
||||
* to roughly 100 Hz..Nyquist without touching the model.
|
||||
*
|
||||
* ### Why single-sideband mixing
|
||||
* Plain real mixing (`x * cos(2*pi*delta*t)`) produces both `tone+delta` and
|
||||
* `tone-delta`. Measured on a 1500 Hz tone shifted to 800 Hz, the unwanted image
|
||||
* folded back to 1000 Hz at 0.999 of the wanted amplitude — inside the window and
|
||||
* as loud as the signal. Upsampling first only moves the problem: shifting a 300 Hz
|
||||
* tone up produced a 200 Hz image at 0.996.
|
||||
*
|
||||
* A Hilbert transformer removes the negative-frequency half first, so mixing the
|
||||
* resulting analytic signal yields one sideband only. Across nine probe tones
|
||||
* (150..1550 Hz) that leaves a single spectral peak at the target with no component
|
||||
* above 0.3 relative amplitude.
|
||||
*
|
||||
* All functions are pure; the caller decides whether shifting is wanted.
|
||||
*/
|
||||
object CwToneShifter {
|
||||
|
||||
/**
|
||||
* Where an out-of-window tone is moved to: the centre of the analysis window,
|
||||
* so the keying sidebands have equal headroom on both sides.
|
||||
*/
|
||||
const val TARGET_HZ = 800.0
|
||||
|
||||
/**
|
||||
* Tones below this are treated as absent rather than shifted. Mains hum and DC
|
||||
* drift live down here, and a real CW note that low is unusable anyway.
|
||||
*/
|
||||
const val MIN_DETECTABLE_HZ = 100.0
|
||||
|
||||
/**
|
||||
* A detected peak must exceed the spectrum mean by this factor to count as a tone.
|
||||
*
|
||||
* Chosen from measurements on 1280-sample (400 ms) windows of keyed CW in noise.
|
||||
* Pure noise peaks at 2.2-3.4 times its own spectral mean, so 3.0 admitted roughly
|
||||
* one noise window in five. Raising it as far as 8.0 then rejected comfortably
|
||||
* copyable signals: keyed CW measures 7.6-9.0 at 0 dB SNR and only 5.2-6.7 at -3 dB.
|
||||
*
|
||||
* 4.5 gives zero false positives across 40 noise windows while keeping the weaker
|
||||
* end of usable signals. The asymmetry is deliberate: a false tone is worse than a
|
||||
* missed one, because it moves a perfectly good signal out of the model's range,
|
||||
* whereas a miss just leaves the audio alone until a stronger window arrives.
|
||||
*
|
||||
* Windows dominated by keying gaps (a slow fist, under ~25% tone) sit at 2.4 and are
|
||||
* indistinguishable from noise at any threshold; those are skipped, not guessed at.
|
||||
*/
|
||||
const val MIN_PROMINENCE = 4.5
|
||||
|
||||
/** Hilbert transformer length. Odd so the group delay is a whole sample. */
|
||||
private const val HILBERT_TAPS = 63
|
||||
|
||||
/** Frequency resolution of [detectToneHz], in Hz. */
|
||||
private const val DETECT_STEP_HZ = 12.5
|
||||
|
||||
/** Windowed Hilbert transformer: h[n] = 2/(pi*n) for odd n, 0 otherwise. */
|
||||
private val hilbertKernel: FloatArray = FloatArray(HILBERT_TAPS) { i ->
|
||||
val n = i - HILBERT_TAPS / 2
|
||||
val ideal = if (n == 0 || n % 2 == 0) 0.0 else 2.0 / (PI * n)
|
||||
// Hamming window; without it the truncated kernel ripples badly.
|
||||
val window = 0.54 - 0.46 * cos(2.0 * PI * i / (HILBERT_TAPS - 1))
|
||||
(ideal * window).toFloat()
|
||||
}
|
||||
|
||||
/** Group delay of [hilbertKernel], applied to the real path to keep them aligned. */
|
||||
private const val HILBERT_DELAY = HILBERT_TAPS / 2
|
||||
|
||||
/** Outcome of inspecting a chunk of audio. */
|
||||
data class Analysis(
|
||||
/** Detected tone in Hz, or null when the audio is noise. */
|
||||
val toneHz: Float?,
|
||||
/** True when [toneHz] sits outside the model's window and can be shifted. */
|
||||
val needsShift: Boolean,
|
||||
/** Hz the tone would be moved by; 0 when no shift applies. */
|
||||
val shiftHz: Float
|
||||
)
|
||||
|
||||
/**
|
||||
* Estimate the dominant tone by scanning [MIN_DETECTABLE_HZ]..Nyquist with a
|
||||
* Goertzel-style single-bin DFT.
|
||||
*
|
||||
* Deliberately not reusing [CwDeepSpectrogram]: that clips to the model window,
|
||||
* which is exactly the region an out-of-range tone is *not* in.
|
||||
*
|
||||
* @return the peak frequency, or null when nothing stands out from the noise.
|
||||
*/
|
||||
fun detectToneHz(audio: FloatArray, sampleRate: Int): Float? {
|
||||
if (audio.size < 64) return null
|
||||
val nyquist = sampleRate / 2.0
|
||||
// A Hann window stops the scan from smearing energy across neighbours.
|
||||
val window = FloatArray(audio.size) { i ->
|
||||
(0.5 - 0.5 * cos(2.0 * PI * i / (audio.size - 1))).toFloat()
|
||||
}
|
||||
|
||||
var bestHz = 0.0
|
||||
var bestMagnitude = 0.0
|
||||
var total = 0.0
|
||||
var bins = 0
|
||||
|
||||
var hz = MIN_DETECTABLE_HZ
|
||||
while (hz <= nyquist) {
|
||||
var real = 0.0
|
||||
var imag = 0.0
|
||||
val omega = 2.0 * PI * hz / sampleRate
|
||||
for (i in audio.indices) {
|
||||
val value = audio[i] * window[i]
|
||||
real += value * cos(omega * i)
|
||||
imag -= value * sin(omega * i)
|
||||
}
|
||||
val magnitude = hypot(real, imag) / audio.size
|
||||
total += magnitude
|
||||
bins++
|
||||
if (magnitude > bestMagnitude) {
|
||||
bestMagnitude = magnitude
|
||||
bestHz = hz
|
||||
}
|
||||
hz += DETECT_STEP_HZ
|
||||
}
|
||||
|
||||
if (bins == 0 || bestMagnitude <= 0.0) return null
|
||||
val mean = total / bins
|
||||
// Pure noise has a flat spectrum, so the peak barely beats the mean.
|
||||
if (mean <= 0.0 || bestMagnitude < mean * MIN_PROMINENCE) return null
|
||||
return bestHz.toFloat()
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide whether [audio] needs shifting, without modifying it.
|
||||
*
|
||||
* A tone already inside the window is left alone: shifting it would add filter
|
||||
* ringing and rounding for no benefit, and the model handles it natively.
|
||||
*/
|
||||
fun analyse(audio: FloatArray, sampleRate: Int): Analysis {
|
||||
val tone = detectToneHz(audio, sampleRate)
|
||||
?: return Analysis(toneHz = null, needsShift = false, shiftHz = 0f)
|
||||
val inWindow = tone >= CwDeepSpectrogram.MIN_FREQ_HZ && tone <= CwDeepSpectrogram.MAX_FREQ_HZ
|
||||
if (inWindow) return Analysis(toneHz = tone, needsShift = false, shiftHz = 0f)
|
||||
return Analysis(
|
||||
toneHz = tone,
|
||||
needsShift = true,
|
||||
shiftHz = (TARGET_HZ - tone).toFloat()
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Shift [audio] by [shiftHz] using single-sideband mixing.
|
||||
*
|
||||
* The Hilbert transformer suppresses the negative-frequency half, so only the
|
||||
* wanted sideband survives; see the class docs for the measured alternative.
|
||||
* Returns a new array; [audio] is not modified.
|
||||
*
|
||||
* Stateless: [audio] is treated as an isolated signal, so the first and last
|
||||
* [HILBERT_DELAY] samples convolve against zeros instead of the neighbouring
|
||||
* audio. Fine for a whole buffer, but it corrupts 62 of every 320 samples when
|
||||
* called per capture chunk, so streaming callers must use [Streaming].
|
||||
*/
|
||||
fun shift(audio: FloatArray, shiftHz: Float, sampleRate: Int): FloatArray {
|
||||
if (shiftHz == 0f || audio.isEmpty()) return audio
|
||||
|
||||
// Quadrature path: audio convolved with the Hilbert kernel.
|
||||
val quadrature = FloatArray(audio.size)
|
||||
for (i in audio.indices) {
|
||||
var sum = 0f
|
||||
for (k in hilbertKernel.indices) {
|
||||
val j = i - k + HILBERT_DELAY
|
||||
if (j >= 0 && j < audio.size) sum += hilbertKernel[k] * audio[j]
|
||||
}
|
||||
quadrature[i] = sum
|
||||
}
|
||||
|
||||
// Re{(inPhase + j*quadrature) * e^(j*2*pi*shift*t)}
|
||||
val out = FloatArray(audio.size)
|
||||
val step = 2.0 * PI * shiftHz / sampleRate
|
||||
for (i in audio.indices) {
|
||||
val phase = step * i
|
||||
out[i] = clampToUnit(audio[i] * cos(phase) - quadrature[i] * sin(phase))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* Chunk-by-chunk shifter that carries the state [shift] cannot.
|
||||
*
|
||||
* Two things must survive across calls for concatenated chunks to form a clean
|
||||
* signal:
|
||||
*
|
||||
* 1. **Filter history.** The Hilbert FIR spans [HILBERT_TAPS] samples, so the
|
||||
* first outputs of a chunk need the previous chunk's tail. Without it those
|
||||
* samples convolve against zeros; measured on 320-sample chunks that distorts
|
||||
* 62 of them (19%) and inflates envelope ripple to 8.7x the whole-buffer
|
||||
* baseline.
|
||||
* 2. **Mixer phase.** Restarting the local oscillator at zero every chunk puts a
|
||||
* phase step at every boundary.
|
||||
*
|
||||
* One difference from [shift] remains and is unavoidable: output sample `i` ideally
|
||||
* needs input up to `i + HILBERT_DELAY`, which for the last samples of a chunk has
|
||||
* not been captured yet. Those trailing taps therefore see zeros. Measured against
|
||||
* a whole-buffer shift the divergence is confined to the final 3 samples of each
|
||||
* 320-sample chunk and disappears immediately after the boundary — under 1% of the
|
||||
* audio, versus a 20 WPM dot spanning 192 samples. Buffering a chunk to remove it
|
||||
* would add 10 ms of latency for no decoding benefit.
|
||||
*
|
||||
* Not thread-safe: the decoder drives it from a single capture coroutine.
|
||||
*/
|
||||
class Streaming {
|
||||
|
||||
private val history = FloatArray(HILBERT_TAPS - 1)
|
||||
private var phase = 0.0
|
||||
|
||||
/** Shift one chunk, continuing the filter and oscillator state. */
|
||||
fun process(chunk: FloatArray, shiftHz: Float, sampleRate: Int): FloatArray {
|
||||
if (shiftHz == 0f || chunk.isEmpty()) {
|
||||
// Still advance the history, so enabling a shift later starts from real
|
||||
// audio rather than the silence left over from before.
|
||||
pushHistory(chunk)
|
||||
return chunk
|
||||
}
|
||||
|
||||
// Convolve over [history || chunk] so every output sees real samples.
|
||||
val combined = FloatArray(history.size + chunk.size)
|
||||
history.copyInto(combined)
|
||||
chunk.copyInto(combined, history.size)
|
||||
|
||||
val out = FloatArray(chunk.size)
|
||||
val step = 2.0 * PI * shiftHz / sampleRate
|
||||
for (i in chunk.indices) {
|
||||
val centre = history.size + i
|
||||
var quadrature = 0f
|
||||
for (k in hilbertKernel.indices) {
|
||||
val j = centre - k + HILBERT_DELAY
|
||||
if (j >= 0 && j < combined.size) quadrature += hilbertKernel[k] * combined[j]
|
||||
}
|
||||
val currentPhase = phase + step * i
|
||||
val mixed = combined[centre] * cos(currentPhase) - quadrature * sin(currentPhase)
|
||||
out[i] = clampToUnit(mixed)
|
||||
}
|
||||
|
||||
// Keep the phase bounded; letting it grow loses float precision.
|
||||
phase = (phase + step * chunk.size) % (2.0 * PI)
|
||||
pushHistory(chunk)
|
||||
return out
|
||||
}
|
||||
|
||||
/** Clear filter history and phase, e.g. after a decoder reset. */
|
||||
fun reset() {
|
||||
history.fill(0f)
|
||||
phase = 0.0
|
||||
}
|
||||
|
||||
/** Keep the most recent [history] samples of the stream. */
|
||||
private fun pushHistory(chunk: FloatArray) {
|
||||
if (chunk.isEmpty()) return
|
||||
if (chunk.size >= history.size) {
|
||||
chunk.copyInto(history, 0, chunk.size - history.size, chunk.size)
|
||||
} else {
|
||||
history.copyInto(history, 0, chunk.size, history.size)
|
||||
chunk.copyInto(history, history.size - chunk.size)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience wrapper: analyse [audio] and shift it only when the tone is
|
||||
* outside the model window.
|
||||
*
|
||||
* @return the audio to feed the model (the original array when no shift was
|
||||
* needed) paired with the [Analysis] that produced the decision, so callers
|
||||
* can log what happened.
|
||||
*/
|
||||
fun shiftIfOutsideWindow(audio: FloatArray, sampleRate: Int): Pair<FloatArray, Analysis> {
|
||||
val analysis = analyse(audio, sampleRate)
|
||||
if (!analysis.needsShift) return audio to analysis
|
||||
return shift(audio, analysis.shiftHz, sampleRate) to analysis
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep a mixed sample inside the +/-1.0 range the spectrogram assumes.
|
||||
*
|
||||
* The Hilbert kernel has an L1 gain of 2.51, so summing the in-phase and quadrature
|
||||
* paths can exceed unity even for a full-scale sine (measured 1.05 at 1500 Hz, 2.35
|
||||
* for a square wave). The spectrogram takes log1p of the magnitude, so an overshoot
|
||||
* is not fatal, but it shifts the level the model was trained on.
|
||||
*/
|
||||
private fun clampToUnit(value: Double): Float = when {
|
||||
value > 1.0 -> 1f
|
||||
value < -1.0 -> -1f
|
||||
else -> value.toFloat()
|
||||
}
|
||||
|
||||
/** True when [toneHz] lies inside the model's analysis window. */
|
||||
fun isInsideWindow(toneHz: Float): Boolean =
|
||||
toneHz >= CwDeepSpectrogram.MIN_FREQ_HZ && toneHz <= CwDeepSpectrogram.MAX_FREQ_HZ
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
/*
|
||||
* 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.StateFlow
|
||||
|
||||
/**
|
||||
* A CW (Morse code) decoder fed with microphone PCM.
|
||||
*
|
||||
* Implementations live outside `core:domain` when they need platform APIs;
|
||||
* this contract stays pure Kotlin so the UI can depend on it directly.
|
||||
*/
|
||||
interface ICwDecoder {
|
||||
|
||||
/**
|
||||
* Decoded text for the *current* window.
|
||||
*
|
||||
* Note this is **replace** semantics, not append: a whole-segment model
|
||||
* revises earlier characters as more audio arrives, so consumers must show
|
||||
* the current value rather than accumulating emissions.
|
||||
*/
|
||||
val decodedText: StateFlow<String>
|
||||
|
||||
/**
|
||||
* Transcript of audio that has been archived, and will not be revised.
|
||||
*
|
||||
* Only ever grows until [reset]. [decodedText] is rewritten from scratch on every
|
||||
* redecode, so a pane that concatenates it loses text the operator has already read -
|
||||
* which a paper log does not do. This is the flow such a pane must bind to.
|
||||
*/
|
||||
val historyText: StateFlow<String>
|
||||
|
||||
/**
|
||||
* Pitch of the tone the model is decoding, in Hz, or null before one is found.
|
||||
*
|
||||
* Derived from the spectrogram, so it can only ever report a frequency inside the
|
||||
* model's analysis window. For the pitch of a tone the model cannot see, use
|
||||
* [detectedToneHz].
|
||||
*/
|
||||
val estimatedPitch: StateFlow<Float?>
|
||||
|
||||
/**
|
||||
* Pitch of the loudest tone in the raw audio, in Hz, or null when none stands out.
|
||||
*
|
||||
* Unlike [estimatedPitch] this is measured before any shifting and over the full
|
||||
* audio bandwidth, so it can report a tone the model's window excludes — which is
|
||||
* the only way to tell the operator that nothing is being decoded because their tone
|
||||
* is out of range.
|
||||
*/
|
||||
val detectedToneHz: StateFlow<Float?>
|
||||
|
||||
/** Current shift applied to bring the tone into the model's window, 0f when idle. */
|
||||
val activeShiftHz: StateFlow<Float>
|
||||
|
||||
/** Relative signal strength in 0..1 for level meters. */
|
||||
val signalStrength: StateFlow<Float>
|
||||
|
||||
/** Most recent inference duration in milliseconds, for diagnostics. */
|
||||
val lastInferenceMs: StateFlow<Int>
|
||||
|
||||
/** Non-null when the decoder cannot run, for example the model failed to load. */
|
||||
val errorMessage: StateFlow<String?>
|
||||
|
||||
/**
|
||||
* Decode whatever audio is still held in the pipeline into [historyText].
|
||||
*
|
||||
* Nothing reaches [historyText] until audio has been pushed out of the live window and
|
||||
* then accumulated into a full archive batch, so the last stretch of a session is always
|
||||
* still in flight when capture stops - and neither holding place drains on its own. That
|
||||
* stretch is the end of the transmission, the part with the call sign in it. Call on
|
||||
* pause and before [close].
|
||||
*/
|
||||
suspend fun flush()
|
||||
|
||||
/** Feed captured mono PCM in -1..1. Safe to call from a capture thread. */
|
||||
suspend fun processBuffer(samples: FloatArray, sampleRate: Int)
|
||||
|
||||
/** Clear decoded text and buffered audio. */
|
||||
fun reset()
|
||||
|
||||
/** Release native resources. Must be called when the decoder goes away. */
|
||||
fun close()
|
||||
}
|
||||
+4
-6
@@ -15,11 +15,9 @@
|
||||
* 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.usecase
|
||||
package com.rtbishop.look4sat.core.domain.model
|
||||
|
||||
interface IShowToast {
|
||||
operator fun invoke(message: String)
|
||||
|
||||
/** 按资源 ID 显示(四语文案) */
|
||||
operator fun invoke(resId: Int)
|
||||
object Constants {
|
||||
const val FREQ_OFFSET_MIN_HZ = -50_000L
|
||||
const val FREQ_OFFSET_MAX_HZ = 50_000L
|
||||
}
|
||||
File renamed without changes.
File renamed without changes.
+7
-6
@@ -1,6 +1,6 @@
|
||||
package com.rtbishop.look4sat.core.domain.model
|
||||
|
||||
/** 单条卫星状态报告(AMSAT 网站 tooltip 数据) */
|
||||
/** One satellite status report (AMSAT site tooltip data) */
|
||||
data class SatReport(
|
||||
val id: String, // 报告 ID(a885153)
|
||||
val statusText: String, // Heard / Telemetry Only / Not Heard ...
|
||||
@@ -10,26 +10,27 @@ data class SatReport(
|
||||
val timeUtc: String // 2:46-:59 UTC
|
||||
)
|
||||
|
||||
/** 单个 2 小时槽的状态 */
|
||||
/** State of one 2-hour slot */
|
||||
data class SatSlot(
|
||||
val statusColor: Long, // ARGB 状态色(-1 = 无报告)
|
||||
val count: Int, // 报告数量(0 = 无)
|
||||
val reportIds: List<String> = emptyList() // 该槽报告 ID 列表
|
||||
)
|
||||
|
||||
/** 卫星一天的状态(12 个 2 小时槽) */
|
||||
/** One satellite day (12 two-hour slots) */
|
||||
data class SatDay(
|
||||
val dateLabel: String, // "Aug 4"
|
||||
val slots: List<SatSlot> // 12 槽(00-02 ... 22-24)
|
||||
)
|
||||
|
||||
/** 单个卫星 6 天状态 */
|
||||
/** One satellite, 3 days of state */
|
||||
data class SatStatus(
|
||||
val name: String, // "AO-123_[FM]"
|
||||
val days: List<SatDay> // 6 天(新→旧)
|
||||
val days: List<SatDay>, // 3 天(新→旧)
|
||||
val summaryCount: Int = 0 // 0 means unknown; used for data-completeness marking
|
||||
)
|
||||
|
||||
/** 页面整体解析结果 */
|
||||
/** Overall page parse result */
|
||||
data class SatStatusPage(
|
||||
val fetchedAtUtcMs: Long,
|
||||
val statuses: List<SatStatus>,
|
||||
+25
-7
@@ -29,8 +29,7 @@ data class PassesSettings(
|
||||
val minElevation: Double,
|
||||
val aosStartMinute: Int = 0,
|
||||
val aosEndMinute: Int = 23 * 60 + 59,
|
||||
val invertAosTimeWindow: Boolean = false,
|
||||
val selectedModes: List<String>
|
||||
val invertAosTimeWindow: Boolean = false
|
||||
)
|
||||
|
||||
data class RCSettings(
|
||||
@@ -42,6 +41,7 @@ data class RCSettings(
|
||||
val frequencyAddress: String,
|
||||
val frequencyPort: String,
|
||||
val frequencyFormat: String,
|
||||
val frequencyOffsetHz: Long = 0L,
|
||||
val bluetoothRotatorState: Boolean,
|
||||
val bluetoothRotatorFormat: String,
|
||||
val bluetoothRotatorName: String,
|
||||
@@ -63,17 +63,35 @@ data class OtherSettings(
|
||||
val sstvMode: String = "Auto",
|
||||
val lowElevation: Double = 15.0,
|
||||
val highElevation: Double = 45.0,
|
||||
// UI 设置: 底部导航栏隐藏的页面(Screen simpleName 列表, 默认空 = 全部显示)
|
||||
// UI settings: pages hidden from the bottom nav (Screen simpleName list; empty default = all shown)
|
||||
val hiddenScreens: List<String> = emptyList(),
|
||||
// UI 设置: 页面顺序(空 = 默认顺序: 卫星/过境/雷达/地图/设置)
|
||||
// UI settings: page order (empty = default: Satellites/Passes/Radar/Map/Settings)
|
||||
val screenOrder: List<String> = emptyList(),
|
||||
// UI 设置: 更多菜单顺序(空 = 默认: 匹配/漫游/CW解码)
|
||||
// UI settings: More-menu order (empty = default: Mutual/Roaming/CW decode)
|
||||
val subMenuOrder: List<String> = emptyList(),
|
||||
// WaveLog 日志系统(4.5.2): 服务器配置
|
||||
// WaveLog logging (4.5.2): server config
|
||||
val wavelogUrl: String = "",
|
||||
val wavelogApiKey: String = "",
|
||||
val wavelogStationId: String = "",
|
||||
val wavelogAutoUpload: Boolean = false
|
||||
val wavelogAutoUpload: Boolean = false,
|
||||
// Upstream radar compass offset (merged from rt-bishop)
|
||||
val radarCompassOffset: Float = 0f,
|
||||
val radarCompassOffsetElev: Float = 0f,
|
||||
/**
|
||||
* Shift a CW tone that sits outside the model's 400-1200 Hz analysis window into
|
||||
* it before decoding. Off by default: when disabled the audio path is unchanged,
|
||||
* and a tone already inside the window is never touched either way.
|
||||
*/
|
||||
val cwToneShiftEnabled: Boolean = false,
|
||||
|
||||
/**
|
||||
* Draw each AMSAT day as twelve two-hour stripes rather than one colour.
|
||||
*
|
||||
* On by default: a single colour is taken from the first slot with a report, so a
|
||||
* satellite that worked all morning and failed all afternoon looks identical to one
|
||||
* that worked once. Some operators prefer the older, simpler tile, hence the switch.
|
||||
*/
|
||||
val amsatDayStripes: Boolean = true
|
||||
)
|
||||
|
||||
data class DataSourcesSettings(
|
||||
+143
@@ -0,0 +1,143 @@
|
||||
/*
|
||||
* 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.navigation
|
||||
|
||||
/**
|
||||
* Single source of truth for the navigation menu layout.
|
||||
*
|
||||
* The bottom bar holds at most [MAIN_SLOTS] pages; the rest live behind the More
|
||||
* button. Both the bar and the settings editor resolve through here, so the list
|
||||
* the user edits is exactly the list they get.
|
||||
*
|
||||
* Lives in `core:domain` (pure Kotlin) so it is unit-testable and KMP-ready.
|
||||
*/
|
||||
object MenuLayout {
|
||||
|
||||
/** Bottom-bar capacity, including the Settings entry. */
|
||||
const val MAIN_SLOTS = 5
|
||||
|
||||
/** Must stay reachable from some menu, so the user cannot lock themselves out. */
|
||||
const val SETTINGS_ID = "Settings"
|
||||
|
||||
/** Bar contents for a fresh install. */
|
||||
val defaultMainOrder = listOf("Satellites", "Passes", "Radar", "Map", SETTINGS_ID)
|
||||
|
||||
/** More-menu contents for a fresh install. */
|
||||
val defaultMoreOrder = listOf("Mutual", "Roaming", "CwDecode", "WavelogLog", "AMSAT")
|
||||
|
||||
/** What the bar and the More menu actually show. */
|
||||
data class Layout(val mainIds: List<String>, val moreIds: List<String>)
|
||||
|
||||
/** A menu assignment ready to be persisted to settings. */
|
||||
data class Assignment(val screenOrder: List<String>, val subMenuOrder: List<String>)
|
||||
|
||||
/**
|
||||
* Map persisted preferences onto the two menus.
|
||||
*
|
||||
* A page named by neither persisted list is new to this install and follows
|
||||
* the defaults, so upgrades never lose pages. Visible pages that overflow
|
||||
* [MAIN_SLOTS] fall through to the More menu instead of disappearing, and
|
||||
* [SETTINGS_ID] always survives the slot cut.
|
||||
*/
|
||||
fun resolve(
|
||||
allScreenIds: List<String>,
|
||||
screenOrder: List<String>,
|
||||
subMenuOrder: List<String>,
|
||||
hiddenScreenIds: List<String>
|
||||
): Layout {
|
||||
val visible = allScreenIds.filter { it !in hiddenScreenIds || it == SETTINGS_ID }
|
||||
val wantMain = ArrayList<String>()
|
||||
val wantMore = ArrayList<String>()
|
||||
for (id in visible) {
|
||||
when {
|
||||
id in screenOrder -> wantMain.add(id)
|
||||
id in subMenuOrder -> wantMore.add(id)
|
||||
id in defaultMainOrder -> wantMain.add(id)
|
||||
else -> wantMore.add(id)
|
||||
}
|
||||
}
|
||||
wantMain.sortBy { rank(it, screenOrder, defaultMainOrder) }
|
||||
wantMore.sortBy { rank(it, subMenuOrder, defaultMoreOrder) }
|
||||
|
||||
// Reserve the Settings slot before cutting so it cannot be truncated away.
|
||||
val settingsOnBar = SETTINGS_ID in wantMain
|
||||
val budget = if (settingsOnBar) MAIN_SLOTS - 1 else MAIN_SLOTS
|
||||
val main = ArrayList<String>(MAIN_SLOTS)
|
||||
for (id in wantMain) {
|
||||
if (id == SETTINGS_ID) continue
|
||||
if (main.size == budget) break
|
||||
main.add(id)
|
||||
}
|
||||
if (settingsOnBar) main.add(SETTINGS_ID)
|
||||
|
||||
val overflow = wantMain.filter { it !in main }
|
||||
return Layout(mainIds = main, moreIds = overflow + wantMore)
|
||||
}
|
||||
|
||||
/** Move [screenId] onto the bar, evicting the last movable page when full. */
|
||||
fun moveToMain(
|
||||
screenId: String,
|
||||
allScreenIds: List<String>,
|
||||
screenOrder: List<String>,
|
||||
subMenuOrder: List<String>
|
||||
): Assignment {
|
||||
val current = resolve(allScreenIds, screenOrder, subMenuOrder, emptyList())
|
||||
val main = current.mainIds.toMutableList()
|
||||
val more = current.moreIds.toMutableList()
|
||||
val wasInMore = screenId in more
|
||||
more.remove(screenId)
|
||||
if (screenId !in main) {
|
||||
val at = main.indexOf(SETTINGS_ID).let { if (it == -1) main.size else it }
|
||||
main.add(at, screenId)
|
||||
}
|
||||
// Only evict when we actually added a new page from More; internal reordering must not evict.
|
||||
if (wasInMore) {
|
||||
val movable = main.filter { it != SETTINGS_ID && it != screenId }
|
||||
if (main.size > MAIN_SLOTS && movable.isNotEmpty()) {
|
||||
val evicted = movable.last()
|
||||
main.remove(evicted)
|
||||
more.add(0, evicted)
|
||||
}
|
||||
}
|
||||
return Assignment(screenOrder = main, subMenuOrder = more)
|
||||
}
|
||||
|
||||
/** Move [screenId] off the bar; Settings is refused so it stays reachable. */
|
||||
fun moveToMore(
|
||||
screenId: String,
|
||||
allScreenIds: List<String>,
|
||||
screenOrder: List<String>,
|
||||
subMenuOrder: List<String>
|
||||
): Assignment {
|
||||
if (screenId == SETTINGS_ID) return Assignment(screenOrder, subMenuOrder)
|
||||
val current = resolve(allScreenIds, screenOrder, subMenuOrder, emptyList())
|
||||
val more = current.moreIds.toMutableList()
|
||||
if (screenId !in more) more.add(screenId)
|
||||
return Assignment(
|
||||
screenOrder = current.mainIds.filter { it != screenId },
|
||||
subMenuOrder = more
|
||||
)
|
||||
}
|
||||
|
||||
private fun rank(id: String, persisted: List<String>, fallback: List<String>): Int {
|
||||
val persistedIndex = persisted.indexOf(id)
|
||||
if (persistedIndex != -1) return persistedIndex
|
||||
val fallbackIndex = fallback.indexOf(id)
|
||||
return if (fallbackIndex != -1) fallbackIndex else Int.MAX_VALUE
|
||||
}
|
||||
}
|
||||
+6
-2
@@ -424,8 +424,12 @@ object CelestialComputer {
|
||||
}
|
||||
if (sunrise == 0.0) sunrise = daynum
|
||||
|
||||
// Phase 4: fast-forward through the day until sun drops back below threshold
|
||||
daynum = sunrise
|
||||
// Phase 4: fast-forward through the day until sun drops back below threshold.
|
||||
// Start from just after sunrise (small offset) so the sun is clearly above
|
||||
// the threshold. This prevents a bug where Phase 3 converges to a point
|
||||
// slightly below -threshold, causing Phase 4 to skip and Phase 5 to converge
|
||||
// to the same time as sunrise, producing identical sunrise/sunset times.
|
||||
daynum = sunrise + 0.001
|
||||
sunPos = getSunPosition(observer, daynumToMillis(daynum))
|
||||
guard = 0
|
||||
while (sunPos.elevation > -threshold && guard++ < 500) {
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
+45
@@ -0,0 +1,45 @@
|
||||
/*
|
||||
* 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.qrz
|
||||
|
||||
/**
|
||||
* Looks up a station's grid square on QRZ.
|
||||
*
|
||||
* An interface so the log screen can ask for a grid without reaching into core:data, and without
|
||||
* reading the stored cookie itself - a composable was fetching it straight out of
|
||||
* SharedPreferences through LocalContext, which put disk access in composition and bypassed the
|
||||
* repository layer entirely.
|
||||
*/
|
||||
interface IQrzGridLookup {
|
||||
|
||||
/**
|
||||
* Look up [callsign].
|
||||
*
|
||||
* Returns [QrzGrid.SignedOut] when no cookie is stored, since the operator's remedy is the
|
||||
* same either way: put a valid cookie in settings.
|
||||
*/
|
||||
suspend fun lookup(callsign: String): QrzGrid
|
||||
|
||||
/**
|
||||
* Check the stored cookie by asking QRZ whose account it belongs to.
|
||||
*
|
||||
* Returns the callsign QRZ reports, or null when the cookie is absent or no longer valid. Lets
|
||||
* settings tell the operator which account they pasted rather than only claiming success.
|
||||
*/
|
||||
suspend fun signedInAs(): String?
|
||||
}
|
||||
@@ -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.qrz
|
||||
|
||||
/**
|
||||
* Outcome of a QRZ grid lookup.
|
||||
*
|
||||
* Four outcomes rather than a nullable string, because the previous null meant any of "the
|
||||
* station has no grid on file", "the cookie expired", "the request timed out" and "QRZ changed
|
||||
* its markup" - and the operator saw the same blank either way, with no way to tell that
|
||||
* re-pasting the cookie would fix it.
|
||||
*/
|
||||
sealed interface QrzGrid {
|
||||
|
||||
/** The station's Maidenhead locator, as QRZ has it. */
|
||||
data class Found(val locator: String) : QrzGrid
|
||||
|
||||
/** The page was read and the station has no locator published. Not an error. */
|
||||
data object NotOnFile : QrzGrid
|
||||
|
||||
/** The detail table was absent, which is what QRZ serves when the cookie is not valid. */
|
||||
data object SignedOut : QrzGrid
|
||||
|
||||
/** The request never completed. [attempts] is how many tries were made before giving up. */
|
||||
data class Unreachable(val attempts: Int) : QrzGrid
|
||||
}
|
||||
|
||||
/**
|
||||
* Parsing of QRZ's callsign page, separate from the fetch so it can be tested without a
|
||||
* network. Pure string work over already-downloaded markup.
|
||||
*/
|
||||
object QrzGridParser {
|
||||
|
||||
/** The detail row QRZ renders for a station that published a locator. */
|
||||
private val gridRow = Regex("""<td class="dh">Grid Square</td>\s*<td class="di">([^<]+)</td>""")
|
||||
|
||||
/**
|
||||
* QRZ's own words on a callsign page served to a visitor who is not signed in.
|
||||
*
|
||||
* Classified on this positive notice rather than on the detail table being absent: measured
|
||||
* against live responses, a callsign QRZ has never heard of also returns HTTP 200 with zero
|
||||
* detail rows, because QRZ serves its search form instead of a callsign page. Keying on
|
||||
* absence therefore reported a mistyped callsign as an expired cookie, and would have sent
|
||||
* the operator off to re-paste a cookie that was never broken.
|
||||
*/
|
||||
private val signedOutNotice = Regex("""Login is required for additional detail""")
|
||||
|
||||
/** The account menu on a signed-in page, used to read back whose cookie this is. */
|
||||
private val accountCallsign = Regex("""<li class="leaf last"[^>]*>\s*([A-Z0-9/]+)\s*<ul""")
|
||||
|
||||
/** A cookie name=value pair inside a browser extension's JSON export. */
|
||||
private val jsonCookie = Regex(""""name"\s*:\s*"([^"]+)"\s*,\s*"value"\s*:\s*"([^"]*)"""")
|
||||
|
||||
/**
|
||||
* Interpret a callsign page.
|
||||
*
|
||||
* Only QRZ explicitly saying that a login is required counts as signed out, so an absent
|
||||
* locator degrades to the harmless [QrzGrid.NotOnFile] and only a genuinely stale cookie
|
||||
* sends the operator back to settings. Getting this wrong in either direction misdirects
|
||||
* them: the old client returned null for everything, and keying on the detail table being
|
||||
* absent would have blamed the cookie for a mistyped callsign.
|
||||
*/
|
||||
fun parseGrid(html: String): QrzGrid {
|
||||
val locator = gridRow.find(html)?.groupValues?.get(1)?.trim()
|
||||
if (!locator.isNullOrBlank()) return QrzGrid.Found(locator)
|
||||
if (signedOutNotice.containsMatchIn(html)) return QrzGrid.SignedOut
|
||||
return QrzGrid.NotOnFile
|
||||
}
|
||||
|
||||
/** The callsign this cookie is signed in as, or null when it is not signed in. */
|
||||
fun parseOwnCallsign(html: String): String? =
|
||||
accountCallsign.find(html)?.groupValues?.get(1)?.trim()?.takeIf { it.isNotBlank() }
|
||||
|
||||
/**
|
||||
* Normalise the pasted cookie into a Cookie header value.
|
||||
*
|
||||
* Accepts a raw `k=v; k=v` header or the JSON array a cookie-export extension produces,
|
||||
* since the operator pastes whatever their browser handed them. Parsed by regex rather
|
||||
* than a JSON library so it needs no extra dependency and stays testable as plain text.
|
||||
*/
|
||||
fun cookieHeader(raw: String): String {
|
||||
val text = raw.trim()
|
||||
if (text.isEmpty()) return ""
|
||||
if (!text.startsWith("[")) return text
|
||||
val pairs = jsonCookie.findAll(text)
|
||||
.map { it.groupValues[1] to it.groupValues[2] }
|
||||
.filter { it.first.isNotBlank() }
|
||||
.toList()
|
||||
return if (pairs.isEmpty()) text else pairs.joinToString("; ") { "${it.first}=${it.second}" }
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -2,8 +2,8 @@ package com.rtbishop.look4sat.core.domain.repository
|
||||
|
||||
import com.rtbishop.look4sat.core.domain.model.SatStatusPage
|
||||
|
||||
/** AMSAT 卫星状态数据源 */
|
||||
/** AMSAT satellite status data source */
|
||||
interface IAmSatRepository {
|
||||
/** 抓取并解析 AMSAT 状态页; 失败返回 null */
|
||||
/** Fetch and parse the AMSAT status page; null on failure */
|
||||
suspend fun fetchStatus(): SatStatusPage?
|
||||
}
|
||||
File renamed without changes.
+8
-1
@@ -35,6 +35,7 @@ interface IMainContainer {
|
||||
val mutualPassData: StateFlow<MutualPassData>
|
||||
fun setMutualPassData(data: MutualPassData)
|
||||
fun provideAddToCalendar(): IAddToCalendar
|
||||
fun providePairedBluetoothDevices(): List<Pair<String, String>>
|
||||
fun provideShowToast(): IShowToast
|
||||
fun provideBluetoothReporter(): IReporter
|
||||
fun provideNetworkReporter(): IReporter
|
||||
@@ -42,10 +43,16 @@ interface IMainContainer {
|
||||
fun provideTxRadioController(): IRadioController
|
||||
fun provideRxRadioController(): IRadioController
|
||||
fun provideAudioCapture(): IAudioCapture
|
||||
fun provideCwDecoder(): com.rtbishop.look4sat.core.domain.cw.ICwDecoder
|
||||
fun provideSaveImage(): ISaveImage
|
||||
// WaveLog 日志(4.5.2)
|
||||
// WaveLog logging (4.5.2)
|
||||
val wavelogQueue: com.rtbishop.look4sat.core.domain.wavelog.WavelogQueue
|
||||
fun provideWavelogUploader(): com.rtbishop.look4sat.core.domain.wavelog.WavelogUploader
|
||||
|
||||
fun provideLotwSatellitesRepo(): com.rtbishop.look4sat.core.domain.wavelog.ILotwSatellitesRepo
|
||||
|
||||
/** QRZ grid lookup, so the log screen never touches the stored cookie itself. */
|
||||
fun provideQrzGridLookup(): com.rtbishop.look4sat.core.domain.qrz.IQrzGridLookup
|
||||
}
|
||||
|
||||
data class MutualPassData(
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
+3
-3
@@ -21,10 +21,10 @@ import com.rtbishop.look4sat.core.domain.model.SatItem
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
interface ISelectionRepo {
|
||||
fun getCurrentTypes(): List<String>
|
||||
fun getTypesList(): List<String>
|
||||
fun getCurrentModes(): List<String>
|
||||
fun getModesList(): List<String>
|
||||
suspend fun getEntriesFlow(): Flow<List<SatItem>>
|
||||
suspend fun setTypes(types: List<String>)
|
||||
suspend fun setModes(modes: List<String>)
|
||||
suspend fun setQuery(query: String)
|
||||
suspend fun setSelection(selectAll: Boolean)
|
||||
suspend fun setSelection(ids: List<Int>, isTicked: Boolean)
|
||||
+7
-1
@@ -19,10 +19,16 @@ package com.rtbishop.look4sat.core.domain.repository
|
||||
|
||||
import com.rtbishop.look4sat.core.domain.predict.GeoPos
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlin.time.Clock
|
||||
import kotlin.time.ExperimentalTime
|
||||
|
||||
@OptIn(ExperimentalTime::class)
|
||||
interface ISensorsRepo {
|
||||
val sensorData: StateFlow<Pair<Float, Float>>
|
||||
fun getMagDeclination(geoPos: GeoPos, time: Long = System.currentTimeMillis()): Float
|
||||
|
||||
// The default used to be System.currentTimeMillis(), which Kotlin/Native does not have;
|
||||
// kotlin.time.Clock is the multiplatform equivalent.
|
||||
fun getMagDeclination(geoPos: GeoPos, time: Long = Clock.System.now().toEpochMilliseconds()): Float
|
||||
fun enableSensor()
|
||||
fun disableSensor()
|
||||
}
|
||||
+8
-3
@@ -32,9 +32,9 @@ interface ISettingsRepo {
|
||||
|
||||
//region # Satellites selection settings
|
||||
val selectedIds: StateFlow<List<Int>>
|
||||
val selectedTypes: StateFlow<List<String>>
|
||||
val selectedSatModes: StateFlow<List<String>>
|
||||
fun setSelectedIds(ids: List<Int>)
|
||||
fun setSelectedTypes(types: List<String>)
|
||||
fun setSelectedSatModes(modes: List<String>)
|
||||
//endregion
|
||||
|
||||
//region # Passes filter settings
|
||||
@@ -45,7 +45,7 @@ interface ISettingsRepo {
|
||||
//region # Station position settings
|
||||
val stationPosition: StateFlow<GeoPos>
|
||||
fun setStationPosition(latitude: Double, longitude: Double, altitude: Double): Boolean
|
||||
/** GPS 定位(挂起): 拿到位置才返回 true; 权限缺失/超时/无信号返回 false */
|
||||
/** GPS fix (suspend): true only with a fix; false on missing permission/timeout/no signal */
|
||||
suspend fun setStationPosition(): Boolean
|
||||
fun setStationPosition(locator: String): Boolean
|
||||
//endregion
|
||||
@@ -78,4 +78,9 @@ interface ISettingsRepo {
|
||||
val radioControlSettings: StateFlow<RadioControlSettings>
|
||||
fun updateRadioControlSettings(settings: RadioControlSettings)
|
||||
//endregion
|
||||
|
||||
//region # Per-satellite calculator offset settings
|
||||
fun getSatelliteOffset(catnum: Int): String
|
||||
fun setSatelliteOffset(catnum: Int, offset: String)
|
||||
//endregion
|
||||
}
|
||||
+8
-8
@@ -17,12 +17,12 @@
|
||||
*/
|
||||
package com.rtbishop.look4sat.core.domain.source
|
||||
|
||||
import java.io.InputStream
|
||||
|
||||
interface IRemoteSource {
|
||||
suspend fun getFileStream(uri: String): InputStream?
|
||||
suspend fun getNetworkStream(url: String): InputStream?
|
||||
|
||||
/** 抓取 AMSAT 状态页 HTML(带 UA, 返回 null = 失败) */
|
||||
suspend fun getStatusHtml(): String?
|
||||
/** Minimal platform HTTP client used by Wavelog/QRZ features. Implemented per platform
|
||||
* (OkHttp on Android, NSURLSession on iOS). */
|
||||
interface IHttpClient {
|
||||
suspend fun post(url: String, headers: Map<String, String>, body: String): HttpResult
|
||||
suspend fun get(url: String, headers: Map<String, String>): HttpResult
|
||||
}
|
||||
|
||||
/** [code] is the HTTP status code, or 0 when the request could not be sent at all. */
|
||||
data class HttpResult(val code: Int, val body: String, val failure: String? = null)
|
||||
File renamed without changes.
+34
@@ -0,0 +1,34 @@
|
||||
/*
|
||||
* 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.source
|
||||
|
||||
interface IRemoteSource {
|
||||
suspend fun getFileBytes(uri: String): ByteArray?
|
||||
suspend fun getNetworkBytes(url: String): ByteArray?
|
||||
|
||||
/** Fetch AMSAT API catalog (JSON string; null on failure) */
|
||||
suspend fun getAmSatCatalog(): String?
|
||||
|
||||
/** Fetch AMSAT API reports for the past N hours (JSON string; null on failure) */
|
||||
suspend fun getAmSatReports(hours: Int, limit: Int): String?
|
||||
|
||||
/** Fetch AMSAT API summary for the past N hours (JSON string; null on failure).
|
||||
* Used to compare against the global reports response and flag satellites whose data
|
||||
* was crowded out of the 500-record cap. */
|
||||
suspend fun getAmSatSummary(hours: Int): String?
|
||||
}
|
||||
+10
-1
@@ -18,7 +18,7 @@
|
||||
package com.rtbishop.look4sat.core.domain.source
|
||||
|
||||
object Sources {
|
||||
// 在线更新的默认 URL(用户可通过"自定义URL"对话框修改/重置)
|
||||
// Default URL for online updates (user can change/reset it via the "Custom URL" dialog)
|
||||
val defaultTleUrl = "https://celestrak.org/NORAD/elements/gp.php?GROUP=active&FORMAT=csv"
|
||||
val defaultTransceiversUrl = "https://db.satnogs.org/api/transmitters/?format=json&status=active"
|
||||
|
||||
@@ -52,6 +52,15 @@ object Sources {
|
||||
"R4UAB" to "https://r4uab.ru/satonline.txt",
|
||||
"Other" to "" // key for sats filter
|
||||
)
|
||||
val satelliteModes = listOf(
|
||||
"4FSK", "64-QAM", "AFSK", "AFSK TUBiX10", "AHRPT", "AM", "APT", "ASK", "BPSK",
|
||||
"BPSK PMT-A3", "CERTO", "CW", "DATV", "DBPSK", "DOKA", "DPSK", "DQPSK", "DSB", "DSTAR",
|
||||
"DUV", "DVB-S2", "FFSK", "FM", "FMN", "FSK", "FSK AX.100 Mode 5", "FSK AX.100 Mode 6",
|
||||
"FSK AX.25 G3RUH", "FT8", "GENESIS FSK", "GFSK", "GFSK Pkst", "GFSK Rktr", "GFSK/BPSK",
|
||||
"GMSK", "GMSK USP", "HRPT", "LoRa", "LRPT", "LSB", "MFSK", "MSK", "MSK AX.100 Mode 5",
|
||||
"MSK AX.100 Mode 6", "OFDM", "OQPSK", "PPM", "PSK", "PSK31", "PSK63", "QPSK", "QPSK31",
|
||||
"QPSK63", "SIDLOC", "SQPSK", "SSDV", "SSTV", "UNKNOWN", "USB", "WSJT"
|
||||
)
|
||||
val transceiversDataUrls = mapOf(
|
||||
"SatNOGS" to "https://db.satnogs.org/api/transmitters/?format=json&status=active"
|
||||
)
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
Loaded 100 of 304 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user